mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-17 20:56:59 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43a47e5a3b | ||
|
|
cebb2be4d4 | ||
|
|
f48b2ad895 | ||
|
|
0d2c77c258 | ||
|
|
5589827178 | ||
|
|
2218d97e89 | ||
|
|
f9b0c8c8a1 | ||
|
|
2b173f0bee | ||
|
|
e6587c3f82 | ||
|
|
d54673bb95 | ||
|
|
62eb6627a6 | ||
|
|
7b934ca2b2 | ||
|
|
346681561a | ||
|
|
f34c7a8a89 | ||
|
|
3885b61e6b | ||
|
|
0802892132 | ||
|
|
0da998f24d | ||
|
|
e8856c828c | ||
|
|
b17483a3ce | ||
|
|
e6dceb9f89 | ||
|
|
5e9f7f1a65 | ||
|
|
9ddb8d1824 | ||
|
|
8291f08ab7 | ||
|
|
1d7699056e | ||
|
|
2179473536 | ||
|
|
132b0e3ac8 | ||
|
|
00dced3dae | ||
|
|
4212907656 | ||
|
|
fd80e4eb2f | ||
|
|
d6f63f244f | ||
|
|
fd9d045c75 | ||
|
|
a22f9a6d46 | ||
|
|
51d8018e27 | ||
|
|
f0b5f0339a | ||
|
|
88ca6ed572 | ||
|
|
780872b158 | ||
|
|
809259a37d | ||
|
|
bc39923608 | ||
|
|
d9ed118c1d | ||
|
|
fc6719d1db | ||
|
|
a82a001d20 | ||
|
|
2f1acef58f | ||
|
|
a39e85da72 | ||
|
|
a94fc6bc84 | ||
|
|
3f69729d69 | ||
|
|
0270d30eda | ||
|
|
956420ea5f | ||
|
|
e44e7157fc | ||
|
|
c877e8e9ee | ||
|
|
296e2ce73c | ||
|
|
a77eafb32a | ||
|
|
73ee54fe62 | ||
|
|
11351ef141 | ||
|
|
202fcbb525 | ||
|
|
9ad2c1766b | ||
|
|
d5b2cd1474 | ||
|
|
97959610be | ||
|
|
99eba2e069 | ||
|
|
0fb809c671 | ||
|
|
e4519aa710 | ||
|
|
26e43cd691 | ||
|
|
480e96c030 | ||
|
|
2756414f46 | ||
|
|
3c0300fcbb | ||
|
|
b5733c7649 | ||
|
|
3d003e8c64 | ||
|
|
a981538d05 | ||
|
|
c870c32ce5 | ||
|
|
81849a499e | ||
|
|
2a4b53f86c | ||
|
|
b00d7c0267 | ||
|
|
77a37683bc | ||
|
|
dec7fd4eb2 | ||
|
|
51a784cd95 | ||
|
|
52a3b0ed84 | ||
|
|
cf0002b484 | ||
|
|
e5cc449bd1 | ||
|
|
edfd4bc80a | ||
|
|
66a0cd826c | ||
|
|
7f7401713a | ||
|
|
fbb9583d41 | ||
|
|
3c489dc408 | ||
|
|
4468917a1b | ||
|
|
60b938ab52 | ||
|
|
3b6d15355b | ||
|
|
9ca113defa | ||
|
|
76881c977e | ||
|
|
02000d98a4 | ||
|
|
8591cdfa6b | ||
|
|
0af2d36442 | ||
|
|
cab59313fd | ||
|
|
b3012745d5 | ||
|
|
f7204aa8dd | ||
|
|
8b73a2ec2f | ||
|
|
05aa012f37 | ||
|
|
a690478e52 | ||
|
|
2f58918162 | ||
|
|
7c5f36bd8c | ||
|
|
30168d759d | ||
|
|
bc7abfae14 | ||
|
|
7b49657a26 | ||
|
|
12eab11cf5 | ||
|
|
dc562c654a | ||
|
|
f27ca795c7 | ||
|
|
81a9569301 | ||
|
|
1e49095146 | ||
|
|
e48d5d428d | ||
|
|
372f39f8f7 | ||
|
|
af8843bf01 | ||
|
|
2475cf5e32 | ||
|
|
ab96593df5 | ||
|
|
bcffde1a06 | ||
|
|
68ef6ff3b3 | ||
|
|
f9076514e7 | ||
|
|
b29a1a8cc2 | ||
|
|
7af7b31af6 | ||
|
|
26a3ae09a1 | ||
|
|
77dc170ede | ||
|
|
b333199eb1 | ||
|
|
d08d8896b8 | ||
|
|
e3a13438e9 | ||
|
|
431194d5fd | ||
|
|
57503c9a4e | ||
|
|
1a120a86a2 | ||
|
|
f613aeba59 | ||
|
|
a0b55e66bb | ||
|
|
e6b2e6eefb | ||
|
|
eb4cfe6c56 | ||
|
|
a0401d6949 | ||
|
|
851aedc64d | ||
|
|
9ca3cd0926 | ||
|
|
38adc178a0 | ||
|
|
b8c5913afe | ||
|
|
56022ffc2a | ||
|
|
2b3b6e7871 | ||
|
|
211255770f | ||
|
|
a740c1f390 | ||
|
|
32e870d589 | ||
|
|
a2784ff9b3 | ||
|
|
4660e7079e | ||
|
|
133d575b8a | ||
|
|
2fd769963e | ||
|
|
084d95d630 | ||
|
|
ad23861454 | ||
|
|
5b3856ba2b | ||
|
|
f306bff0dc | ||
|
|
4d4a537079 | ||
|
|
87f0abbdb0 | ||
|
|
f8d1585c6f | ||
|
|
56cff6b11c | ||
|
|
f933851a9e | ||
|
|
9562532695 | ||
|
|
31f1f559dd | ||
|
|
43f9d51207 | ||
|
|
65f51751a1 | ||
|
|
3698f76c40 | ||
|
|
bde5385a6f | ||
|
|
c8c97883cd | ||
|
|
5734d2e71c | ||
|
|
1d006f7703 | ||
|
|
61fa2237f6 | ||
|
|
7f8232e12c | ||
|
|
18db8588b9 | ||
|
|
de5ec99f61 | ||
|
|
052aa9dccb | ||
|
|
5d1fade3d0 | ||
|
|
75e0a6dd2a | ||
|
|
5c60c3656d | ||
|
|
f03c4529c2 | ||
|
|
5e1a951008 | ||
|
|
33c321c6fb | ||
|
|
bbf8fc0f0b | ||
|
|
7ab2f2bfb4 | ||
|
|
213b09a2e9 | ||
|
|
327e67af9e | ||
|
|
f7fc3abb2f | ||
|
|
084c99bd8e | ||
|
|
556f3d040f | ||
|
|
6dab4bdc1b | ||
|
|
c809c249d1 | ||
|
|
fe2ba9191a | ||
|
|
e4ad628398 | ||
|
|
19f094c952 | ||
|
|
b476affb6b | ||
|
|
fdd05f4141 | ||
|
|
260d03ced6 | ||
|
|
2c4390e2d0 | ||
|
|
6cc0072843 | ||
|
|
f815f88958 | ||
|
|
622f9e6492 | ||
|
|
2011d19792 | ||
|
|
b985090c90 | ||
|
|
2a5fae54b0 | ||
|
|
e2f6555220 | ||
|
|
da677cd440 | ||
|
|
b350127d81 | ||
|
|
81a64ed13d | ||
|
|
5242f02838 | ||
|
|
9d45f6b168 | ||
|
|
141aec6ddf | ||
|
|
32789beade | ||
|
|
5473bf514e | ||
|
|
a8f0f5d60b | ||
|
|
9a64ecefc0 | ||
|
|
fcbbfac464 | ||
|
|
1aea595527 | ||
|
|
6f2ccf7e33 | ||
|
|
9107ea4bdf | ||
|
|
3be25e7559 | ||
|
|
d9508ff909 | ||
|
|
aa384d62f7 | ||
|
|
0bdb55cb4b | ||
|
|
60696fe7c1 | ||
|
|
0f842c8f87 | ||
|
|
de137ef5fc | ||
|
|
9854ca24cb | ||
|
|
8a406d0dfc | ||
|
|
1449be9281 | ||
|
|
b70dcba75b | ||
|
|
9131ff7000 | ||
|
|
da7534a2ed | ||
|
|
8308ff2034 | ||
|
|
cfd14ea7c1 | ||
|
|
2953b63576 | ||
|
|
ca6077ab81 | ||
|
|
b6eb559243 | ||
|
|
453757773d | ||
|
|
d8b52d9405 | ||
|
|
aba04aaf5c | ||
|
|
d3edca8000 | ||
|
|
6ab5e2b0d4 | ||
|
|
d0d9aa7df9 | ||
|
|
381b57dd0b | ||
|
|
a19cbdb17b | ||
|
|
89a51be77f | ||
|
|
0fc3155859 | ||
|
|
a993758693 | ||
|
|
6c5eacc839 | ||
|
|
39502d482c | ||
|
|
c99be924da | ||
|
|
28a6d97434 | ||
|
|
e0ed8c0270 | ||
|
|
9e597bf6dd | ||
|
|
ff66d188e8 | ||
|
|
260a460c11 | ||
|
|
58dc540cc0 | ||
|
|
e423d55ea1 | ||
|
|
cbf3664e4f | ||
|
|
15becc9eb4 | ||
|
|
88455a837c | ||
|
|
2138dc6e7d | ||
|
|
a68da45fc3 | ||
|
|
0bef63b6eb | ||
|
|
8735468e81 | ||
|
|
4377bd4604 | ||
|
|
e78e165594 | ||
|
|
d837ab1455 | ||
|
|
6ce8d5a571 | ||
|
|
618b4c723a | ||
|
|
35f347397d | ||
|
|
b70571448c | ||
|
|
cebaade144 | ||
|
|
5d88e64783 | ||
|
|
728471fcf5 | ||
|
|
af4c5e102c | ||
|
|
62da9e8581 | ||
|
|
206680fb22 | ||
|
|
e5c6e17bdb | ||
|
|
ba98dc66c9 | ||
|
|
f6c20bc0cc | ||
|
|
70a12af9a2 | ||
|
|
5c5370fc9b | ||
|
|
b5169e907c | ||
|
|
3080f71680 | ||
|
|
072bcfeaf1 | ||
|
|
d0a9ca09dd | ||
|
|
f50372d808 | ||
|
|
053b7420fe | ||
|
|
0366116c76 | ||
|
|
fb2a460532 | ||
|
|
6d493c9c73 | ||
|
|
66a202bbcf | ||
|
|
ea826b11cc | ||
|
|
3bb13afdea | ||
|
|
b0241748ee | ||
|
|
69346009d2 | ||
|
|
9b9a96a1b5 | ||
|
|
8283ae1687 | ||
|
|
1f87328c6d | ||
|
|
6d080de447 | ||
|
|
9ef40b9886 | ||
|
|
30ec48d9b3 | ||
|
|
6d43479d38 | ||
|
|
af0c1638fe | ||
|
|
ef3642c39f | ||
|
|
4352147910 | ||
|
|
2df471b142 | ||
|
|
bb9d3d4022 | ||
|
|
18e808c08f | ||
|
|
6f9aae0445 | ||
|
|
4551790eab | ||
|
|
6e0880769a | ||
|
|
1f4aaaec47 | ||
|
|
2b635bb167 | ||
|
|
37b8c256ad | ||
|
|
648b1b5a93 | ||
|
|
05352e18dd | ||
|
|
8a218bddc1 | ||
|
|
ac17c580f5 | ||
|
|
44629a0df1 | ||
|
|
b858d3e34a | ||
|
|
17e05759b4 | ||
|
|
3d80ed531c | ||
|
|
fbe3810aa8 | ||
|
|
0467c8904d | ||
|
|
e8818281d5 | ||
|
|
14d321c107 | ||
|
|
c472f52c84 | ||
|
|
3b4279fc24 | ||
|
|
f360ad949d | ||
|
|
1e55d36e36 | ||
|
|
7984667269 | ||
|
|
877ef342dd | ||
|
|
eac616b542 | ||
|
|
3aa6b3ba31 | ||
|
|
5f3bdbcb73 | ||
|
|
7919c1471d | ||
|
|
f776d98167 | ||
|
|
2b766142bc | ||
|
|
ead24aded9 | ||
|
|
975b08c334 | ||
|
|
2bc2f60277 | ||
|
|
c854e172e4 | ||
|
|
441f46683a | ||
|
|
1acbf88661 | ||
|
|
02d5d6bc03 | ||
|
|
b0b86aca84 | ||
|
|
697010d520 | ||
|
|
b60c74a6d7 | ||
|
|
3aaadd1456 | ||
|
|
e0690b5f63 | ||
|
|
d2523e8544 | ||
|
|
11fb6f3156 | ||
|
|
57375eb4b5 | ||
|
|
afacec1747 | ||
|
|
6bb52f09f6 | ||
|
|
c2c85f5d97 | ||
|
|
4ce0aad79c | ||
|
|
5ca5e0547c | ||
|
|
366e6891a0 | ||
|
|
43ba500c6e | ||
|
|
5f1037a192 | ||
|
|
540a281ccd | ||
|
|
73b9cc3708 | ||
|
|
857ffcd41c | ||
|
|
b52762797f | ||
|
|
971ad25b74 | ||
|
|
0cf062f508 | ||
|
|
6fb0d22326 | ||
|
|
a142179f20 | ||
|
|
0f398f6dba | ||
|
|
3c907e0c9d | ||
|
|
aa62ba05f9 | ||
|
|
f9c97ab57b | ||
|
|
78b014fb79 | ||
|
|
61ec5c596c | ||
|
|
6d5c19d288 | ||
|
|
f625c74c7a | ||
|
|
3ae306ecb3 | ||
|
|
8628c160df | ||
|
|
e5efaea6a8 | ||
|
|
61117e53e1 | ||
|
|
d598c304aa | ||
|
|
065818f524 | ||
|
|
707aa5aa92 | ||
|
|
bd2ad63b26 | ||
|
|
befd25f2a3 | ||
|
|
0bc2f79bb5 | ||
|
|
411f1fce19 | ||
|
|
50cb797872 | ||
|
|
b01b09b416 | ||
|
|
5f1464170e | ||
|
|
f29a340fa6 | ||
|
|
5773533e23 | ||
|
|
e5cd0c7ada | ||
|
|
1181b89048 | ||
|
|
64c780e72f | ||
|
|
8feda7a997 | ||
|
|
c03eb8222f | ||
|
|
ac677a2624 | ||
|
|
619c21288e | ||
|
|
61b4007c2e | ||
|
|
0242a75b5a | ||
|
|
d5b79bf9a1 | ||
|
|
e347d5fd8a | ||
|
|
363a1e2a67 | ||
|
|
dffd1951a0 | ||
|
|
9b9676ca85 | ||
|
|
83d388002d | ||
|
|
506566d436 | ||
|
|
a3071fe0f3 | ||
|
|
37deb70da9 | ||
|
|
853f3ddafe | ||
|
|
80a8acaa30 | ||
|
|
ee5ae8fa1d | ||
|
|
0cb32e384e | ||
|
|
2d42bdab03 | ||
|
|
3d14f50d78 | ||
|
|
f2f6f1386f | ||
|
|
ab64e9a563 | ||
|
|
d64a1ecc93 | ||
|
|
bc9d8a21b5 | ||
|
|
d747c9e0f6 | ||
|
|
f30e4af838 | ||
|
|
ba9262a4a0 | ||
|
|
a021ad2c56 | ||
|
|
09ea6a3f77 | ||
|
|
95cde76917 | ||
|
|
fdad5a4ad4 | ||
|
|
6e20f82360 | ||
|
|
e665aff1b3 | ||
|
|
f86459db58 | ||
|
|
939d422ff6 | ||
|
|
68e9d61aa6 | ||
|
|
4dfd78a8a8 | ||
|
|
eeb2ca2f6a | ||
|
|
12015179ea | ||
|
|
d8f62a29b2 | ||
|
|
4d240cf616 | ||
|
|
8d6070e224 | ||
|
|
8c64c467f5 | ||
|
|
ac84cd31ee | ||
|
|
bba2fe0bda | ||
|
|
33c362a7e4 | ||
|
|
d982a073c9 | ||
|
|
b677fbac0a | ||
|
|
85cc4273be | ||
|
|
6c5b4d5701 | ||
|
|
dfe87240b0 | ||
|
|
17a68863f6 | ||
|
|
21861c2f14 | ||
|
|
25e832b404 | ||
|
|
db69e288c8 | ||
|
|
8907f5f56f | ||
|
|
6467d54325 | ||
|
|
2dad18d2ec | ||
|
|
bd0ca28bb0 | ||
|
|
951d52788a | ||
|
|
0cd7513a8d | ||
|
|
25314aaacf | ||
|
|
5cb57ef4ea | ||
|
|
d9cea1404a | ||
|
|
eec4746a56 | ||
|
|
488edec6c8 | ||
|
|
b36b0d14b7 | ||
|
|
5d981c2c68 | ||
|
|
4f51f051e7 | ||
|
|
bc1f138ee3 | ||
|
|
79ab54e167 | ||
|
|
f407813569 | ||
|
|
5f2f013c8e | ||
|
|
967e57fa50 | ||
|
|
fe9e6e3433 | ||
|
|
be592cf2b1 | ||
|
|
cb8b797cd4 | ||
|
|
115b19bdcb | ||
|
|
64ccc20853 | ||
|
|
f4b7175429 | ||
|
|
aeb21ac813 | ||
|
|
bd50896ca1 | ||
|
|
63f68f5044 | ||
|
|
64f354fca3 | ||
|
|
6e5cd82ce7 | ||
|
|
146cb7b975 | ||
|
|
3f9bf67eae | ||
|
|
ed5c7d38eb | ||
|
|
db876a0a3d | ||
|
|
75c4b5c920 | ||
|
|
39de3777fa | ||
|
|
21b417fe3c | ||
|
|
4ea05a85ae | ||
|
|
f36020c3cf | ||
|
|
a89daef74a | ||
|
|
fd685e7680 | ||
|
|
38d53ccaee | ||
|
|
2faee15203 | ||
|
|
6246ea67a4 | ||
|
|
5c41859778 | ||
|
|
0391362f2f | ||
|
|
f89b70e1c1 | ||
|
|
1a5c4665da | ||
|
|
6a5158d4ed | ||
|
|
b446a5b829 | ||
|
|
18f583ca5f | ||
|
|
64e0395c95 | ||
|
|
5cbaf64579 | ||
|
|
65775b4716 | ||
|
|
9bc76a4e58 | ||
|
|
d289fafbe2 | ||
|
|
068caf361c | ||
|
|
32b5aefc5b | ||
|
|
3b32f82e9e | ||
|
|
8336f06d28 | ||
|
|
4f1ed6df65 | ||
|
|
aec225b6ac | ||
|
|
6402c83bb4 | ||
|
|
358a19c755 | ||
|
|
29ed703794 | ||
|
|
7fe040f66a | ||
|
|
d3690e76c4 | ||
|
|
675e661710 | ||
|
|
436c56a7ad | ||
|
|
d79dad9ab1 | ||
|
|
39c33a99d6 | ||
|
|
3f3ffa541a | ||
|
|
615d8a365e | ||
|
|
dbf2dcbfbd | ||
|
|
82045f4d2c | ||
|
|
3e42f2e05c | ||
|
|
8fbc53584f | ||
|
|
b7d77f16b8 | ||
|
|
9181aa0b6c | ||
|
|
f63522a234 | ||
|
|
b764715dc5 | ||
|
|
a6d048be8d | ||
|
|
08e692f7c9 | ||
|
|
bbd2e8cbd4 | ||
|
|
bb925f6d2e | ||
|
|
6178021107 | ||
|
|
a44a4baa31 | ||
|
|
c3be1ceead | ||
|
|
32819d4900 | ||
|
|
1477f892b5 | ||
|
|
c78b457c0d | ||
|
|
76f12dcabd | ||
|
|
0f40a2d2f5 | ||
|
|
594c046d52 | ||
|
|
cc3eb1c846 | ||
|
|
20822fa513 | ||
|
|
dafa00483a | ||
|
|
c0a1a6443a | ||
|
|
7421407d25 | ||
|
|
6ec7044ebf | ||
|
|
54c1048b57 | ||
|
|
d485dc9011 | ||
|
|
b2036ba50d | ||
|
|
2138175826 | ||
|
|
d14a44630d | ||
|
|
ccfff38548 | ||
|
|
b061563383 | ||
|
|
41c9cd3817 | ||
|
|
56d78344c1 | ||
|
|
efaa39b8b5 | ||
|
|
7b0fc19ebb | ||
|
|
b0972e4488 | ||
|
|
7e4ff5fddf | ||
|
|
857c15f3b3 | ||
|
|
29203d6eec | ||
|
|
ee8b56f1db | ||
|
|
8d692ed0a9 | ||
|
|
c822501a2b | ||
|
|
b391307009 | ||
|
|
6bfe2235af | ||
|
|
f98078415e | ||
|
|
0a09450809 | ||
|
|
1b8cb436a8 | ||
|
|
46e431a200 | ||
|
|
13dd73eb49 | ||
|
|
213047b3ff | ||
|
|
570b2a10c5 | ||
|
|
12dc533396 | ||
|
|
ae033aaf32 | ||
|
|
52875ec3f9 | ||
|
|
92077d0807 |
No files matched your search
@@ -1,16 +1,23 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
# FreeBSD build and package check in a QEMU VM.
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
- ".github/workflows/freebsd.yml"
|
||||
- "packaging/freebsd/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/freebsd.yml"
|
||||
- "packaging/freebsd/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -22,34 +29,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Optional font build input, so the Japanese font tests run (they skip without it).
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
|
||||
path: craft-fonts
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# 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
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
require-craft-fonts: true
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
@@ -0,0 +1,28 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
run: nix flake check --no-build --all-systems
|
||||
@@ -1,7 +1,5 @@
|
||||
# Quick checks for the release pipeline (no Rust build): workflow syntax, packaging
|
||||
# shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata (seconds), plus a
|
||||
# Windows job that builds and installs throwaway per-user MSI probes for the install-folder
|
||||
# searches (a few minutes).
|
||||
# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax,
|
||||
# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata.
|
||||
name: Packaging lint
|
||||
|
||||
on:
|
||||
@@ -21,63 +19,10 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
- 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
|
||||
# The interactive installer asks where to install and ends on a completion page (issues #18, #399).
|
||||
for want in '<UIRef Id="LightcraftUI" />' 'Value="InstallDirDlg"' '<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />' \
|
||||
'Key="Software\LightCraft" Name="InstallFolder"' 'Value="LaunchLightcraft"'; do
|
||||
grep -qF "$want" packaging/windows/lightcraft.wxs || { echo "::error::lightcraft.wxs lost: $want"; exit 1; }
|
||||
done
|
||||
# The completion page the Publish events point at must actually exist: a Publish alone
|
||||
# does not pull the dialog fragment into the MSI, and without the DialogRef the
|
||||
# installer finishes silently (issues #790, #892).
|
||||
grep -qF '<DialogRef Id="ExitDialog" />' packaging/windows/lightcraft.wxs \
|
||||
|| { echo "::error::ExitDialog is not referenced; installs and removes end without a completion dialog"; exit 1; }
|
||||
desktop-file-validate packaging/linux/ai.storyteller.lightcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
|
||||
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
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
with:
|
||||
extra-checks-script: packaging/check-app-specific.sh
|
||||
|
||||
windows-install-folder:
|
||||
runs-on: windows-latest
|
||||
|
||||
+25
-471
@@ -1,13 +1,5 @@
|
||||
# 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.
|
||||
#
|
||||
# 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, Linux riscv64, Flatpak, FreeBSD, web) run without it, so a dispatch on
|
||||
# any branch is a dry run of them; the release job needs the signed jobs, so a branch run never
|
||||
# drafts a release.
|
||||
# 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.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -21,471 +13,33 @@ on:
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
LIGHTCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
# 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 }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
v="${INPUT_VERSION#v}"
|
||||
v="${v:-$VERSION}"
|
||||
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{
|
||||
echo "version=$v"
|
||||
echo "date=$(date -u +%Y-%m-%d)"
|
||||
echo "prerelease=$pre"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
echo "### LightCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
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 }}
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
- name: Remove temporary keychain
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
|
||||
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
shell: bash
|
||||
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
|
||||
# package.ps1 adds the matching WixToolset.UI/Util.wixext (5.0.2) to the global extension cache.
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
- 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 }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# 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 }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- 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 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"
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
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: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/lightcraft$|metainfo|applications'
|
||||
ldd target/release/lightcraft
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.lightcraft.yml')); assert m['id'] == 'ai.storyteller.lightcraft'"
|
||||
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
|
||||
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
|
||||
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
|
||||
# CLI is smoke-tested under QEMU.
|
||||
runs-on: ubuntu-24.04
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-linux-riscv64
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
. /etc/os-release
|
||||
sudo dpkg --add-architecture riscv64
|
||||
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
fi
|
||||
if [ -f /etc/apt/sources.list ]; then
|
||||
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
|
||||
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
|
||||
fi
|
||||
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
|
||||
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
|
||||
done | sudo tee /etc/apt/sources.list.d/riscv64.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
|
||||
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
|
||||
libxi-dev:riscv64 libgl1-mesa-dev:riscv64
|
||||
- name: Build and package (tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: "1"
|
||||
CROSS_ARCH: riscv64
|
||||
CROSS_TARGET: riscv64gc-unknown-linux-gnu
|
||||
CROSS_COMPILE: riscv64-linux-gnu-
|
||||
EMULATOR: qemu-riscv64
|
||||
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
|
||||
run: packaging/linux/package.sh --formats tar
|
||||
- name: Check the tarball
|
||||
run: |
|
||||
tar -tzf dist/release/lightcraft-*-linux-riscv64.tar.gz \
|
||||
| grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$'
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- 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 # v1.5.9
|
||||
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
WASM_BINDGEN_VERSION: 0.2.129
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install wasm-bindgen CLI
|
||||
run: |
|
||||
# Must match the wasm-bindgen version in Cargo.lock exactly (cargo xtask web checks it).
|
||||
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web]
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Checksums
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
sha256sum -- * | tee SHA256SUMS.txt
|
||||
- name: Create or update the draft release
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
title="LightCraft v$VERSION"
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
if [ "$draft" != "true" ]; then
|
||||
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updating draft $tag: replacing its assets"
|
||||
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
|
||||
gh release delete-asset "$tag" "$asset" --yes
|
||||
done
|
||||
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
echo "Creating draft $tag"
|
||||
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
app-name: lightcraft
|
||||
app-title: LightCraft
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
@@ -1,22 +1,27 @@
|
||||
# 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.)
|
||||
# Windows ARM64 build and smoke test.
|
||||
#
|
||||
# Release packages on arm64-windows are produced on x64 runners via cross-compilation
|
||||
# (target: aarch64-pc-windows-msvc) using MSVC's ARM64 cross tools, then installed and run
|
||||
# on GitHub Actions' native windows-11-arm runner.
|
||||
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
|
||||
- ".github/workflows/windows-arm64.yml"
|
||||
- "packaging/windows/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/workflows/windows-arm64.yml"
|
||||
- "packaging/windows/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "crates/**"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -26,70 +31,10 @@ concurrency:
|
||||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
targets: aarch64-pc-windows-msvc
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
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@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
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'
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: lightcraft
|
||||
app-dir: LightCraft
|
||||
@@ -125,14 +125,18 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe
|
||||
≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG.
|
||||
- Never commit media: test images are procedural (`lightcraft-scenes`) or CC0 downloads in the gitignored `corpus/`.
|
||||
- A test that can't run here (no GPU, corpus file, craft-fonts face, model) calls `lightcraft_testkit::skip!(Gpu, "why")`
|
||||
before it returns, not a bare `eprintln!`. `cargo xtask ci` then lists the skips and the machine's GPU adapters and
|
||||
data, and writes `target/ci/report.json` and `target/ci/summary.md` (Markdown for a PR).
|
||||
|
||||
## 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`). `flake.nix` +
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`); `testkit` = test helpers
|
||||
(dev-dependency only). `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.
|
||||
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained; CI only
|
||||
evaluates it (`.github/workflows/nix.yml`), so the build may lag behind the workspace; see README → Quick start.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
|
||||
@@ -133,11 +133,15 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe
|
||||
≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG.
|
||||
- Never commit media: test images are procedural (`lightcraft-scenes`) or CC0 downloads in the gitignored `corpus/`.
|
||||
- A test that can't run here (no GPU, corpus file, craft-fonts face, model) calls `lightcraft_testkit::skip!(Gpu, "why")`
|
||||
before it returns, not a bare `eprintln!`. `cargo xtask ci` then lists the skips and the machine's GPU adapters and
|
||||
data, and writes `target/ci/report.json` and `target/ci/summary.md` (Markdown for a PR).
|
||||
|
||||
## 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`). `flake.nix` +
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`); `testkit` = test helpers
|
||||
(dev-dependency only). `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.
|
||||
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained; CI only
|
||||
evaluates it (`.github/workflows/nix.yml`), so the build may lag behind the workspace; see README → Quick start.
|
||||
Generated
+31
@@ -1072,6 +1072,11 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -3081,6 +3086,7 @@ dependencies = [
|
||||
"lightcraft-engine",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-sysmem",
|
||||
"lightcraft-testkit",
|
||||
"lightcraft-ui-egui",
|
||||
"log",
|
||||
"muda",
|
||||
@@ -3144,6 +3150,7 @@ dependencies = [
|
||||
"lightcraft-geom",
|
||||
"lightcraft-heif",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-testkit",
|
||||
"moxcms 0.9.1",
|
||||
"png",
|
||||
"proptest",
|
||||
@@ -3215,6 +3222,7 @@ name = "lightcraft-engine"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"craft-docpath",
|
||||
"harfrust",
|
||||
"jxl-oxide",
|
||||
"libc",
|
||||
@@ -3239,6 +3247,8 @@ dependencies = [
|
||||
"lightcraft-raw",
|
||||
"lightcraft-scenes",
|
||||
"lightcraft-segment",
|
||||
"lightcraft-slideshow",
|
||||
"lightcraft-testkit",
|
||||
"lightcraft-tiff",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -3254,6 +3264,7 @@ dependencies = [
|
||||
name = "lightcraft-faces"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"lightcraft-testkit",
|
||||
"matrixmultiply",
|
||||
"ndarray",
|
||||
"proptest",
|
||||
@@ -3299,11 +3310,13 @@ dependencies = [
|
||||
"lightcraft-color",
|
||||
"lightcraft-denoise",
|
||||
"lightcraft-develop",
|
||||
"lightcraft-faces",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-pipeline",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-raw",
|
||||
"lightcraft-scenes",
|
||||
"lightcraft-testkit",
|
||||
"log",
|
||||
"pollster 1.0.1",
|
||||
"wgpu",
|
||||
@@ -3418,12 +3431,14 @@ dependencies = [
|
||||
name = "lightcraft-raw"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"dhat",
|
||||
"jpeg-encoder",
|
||||
"jxl-oxide",
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-meta",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-testkit",
|
||||
"lightcraft-tiff",
|
||||
"miniz_oxide 0.8.9",
|
||||
"proptest",
|
||||
@@ -3454,16 +3469,30 @@ dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
"lightcraft-fetch",
|
||||
"lightcraft-testkit",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-slideshow"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"lightcraft-raster",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-sysmem"
|
||||
version = "0.8.0"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-testkit"
|
||||
version = "0.8.0"
|
||||
|
||||
[[package]]
|
||||
name = "lightcraft-tiff"
|
||||
version = "0.8.0"
|
||||
@@ -3493,6 +3522,8 @@ dependencies = [
|
||||
"lightcraft-pipeline",
|
||||
"lightcraft-preview",
|
||||
"lightcraft-raster",
|
||||
"lightcraft-slideshow",
|
||||
"lightcraft-testkit",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
+4
-1
@@ -11,6 +11,7 @@ repository = "https://github.com/storytold/lightcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
# internal
|
||||
lightcraft-denoise-core = { path = "crates/denoise-core" }
|
||||
lightcraft-denoise = { path = "crates/denoise", default-features = false }
|
||||
@@ -39,6 +40,8 @@ lightcraft-fetch = { path = "crates/fetch" }
|
||||
lightcraft-heif = { path = "crates/heif" }
|
||||
lightcraft-segment = { path = "crates/segment" }
|
||||
lightcraft-geocode = { path = "crates/geocode" }
|
||||
lightcraft-slideshow = { path = "crates/slideshow" }
|
||||
lightcraft-testkit = { path = "crates/testkit" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -52,7 +55,7 @@ bytemuck = { version = "1", features = ["derive"] }
|
||||
crc32fast = "1"
|
||||
siphasher = "1"
|
||||
libc = "0.2" # dev-only: process CPU time in benches
|
||||
dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap); never in default builds
|
||||
dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap) and heap tests; never in product builds
|
||||
|
||||
# formats
|
||||
quick-xml = "0.38"
|
||||
|
||||
@@ -51,9 +51,10 @@ 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.
|
||||
(BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic and Noto Sans
|
||||
Thai), under the SIL Open Font License 1.1. The web build embeds BIZ UDPGothic Regular and Noto
|
||||
Sans Thai Regular, and loads Noto Sans CJK SC separately. Release packages include each font's
|
||||
licence as OFL-<family>.txt; Windows installers include their texts in FONT-LICENCES.txt.
|
||||
|
||||
Place names for reverse geocoding (Photo > Fill Location from GPS) are not part of LightCraft and
|
||||
are never distributed with it. On the user's request (Photo > Download Place Names) the app
|
||||
|
||||
@@ -268,6 +268,20 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
with the same font coverage as watermarks (CJK requires craft-fonts), with long names shortened in the middle.
|
||||
This is a contact-sheet workflow,
|
||||
not a printer-driver or custom picture-package layout editor.
|
||||
- **Print layouts (native app / CLI):** **File → Print…** opens the Print module's layout editor: Single Image,
|
||||
Contact Sheet, Picture Package (Auto Layout packs cells onto pages) and Custom Package (drag and resize cells,
|
||||
stacking order, snap to cells or a grid), Zoom to Fill / Rotate to Fit, Photo Border and Inner Stroke, paper
|
||||
sizes from 4 × 6 to 13 × 19 and A3 to A5 or custom, margins, rulers in in/cm/mm/pt, page background, 72–720 ppi,
|
||||
and saved templates. **Print to PDF…** renders in the background. Everything is a `print.*` command
|
||||
(`print.get`, `print.set`, `print.cell.add`, `print.layout.autoLayout`, `print.exportPdf`,
|
||||
`print.template.save`…), so the CLI and MCP drive it too:
|
||||
|
||||
```sh
|
||||
lightcraft-cli run --import ~/Pictures/shoot print.set 'layout={"style":"picturePackage","page":{"paper":"8x10"}}' \
|
||||
print.cell.add width=5 height=7 print.cell.add width=2.5 height=3.5 print.layout.autoLayout print.exportPdf path=Package.pdf
|
||||
```
|
||||
|
||||
Printer colour management and page overlays (identity plate, watermark, page numbers) are not there yet.
|
||||
- **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a
|
||||
side door.
|
||||
- The screenshots in this README were produced end to end by the [`docs/showcase/`](docs/showcase/) scripts.
|
||||
@@ -307,11 +321,11 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
|
||||
|
||||
- **By feature count we're at ~81%** of Lightroom (core features 98%), tracked row by row in
|
||||
[docs/parity-checklist.md](docs/parity-checklist.md).
|
||||
- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~49% for a typical working photographer and ~64% for
|
||||
- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for
|
||||
casual use.** It's great for JPEG/DNG and most Sony / Panasonic /
|
||||
Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera.
|
||||
- **The biggest gaps:**
|
||||
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
|
||||
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
|
||||
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
|
||||
- **AI masks and denoise:** subject and sky selection are classical heuristics;
|
||||
- **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
|
||||
@@ -395,6 +409,13 @@ information combined with LTO and one codegen unit on low-memory builders. A PKG
|
||||
its own profile settings can override inherited variables; adjust the packaging recipe locally or
|
||||
contact its maintainer rather than assuming an environment prefix on `makepkg` will take precedence.
|
||||
|
||||
Tests that need a GPU, the corpus, the craft-fonts faces or a model pass without checking anything when
|
||||
those are missing (libtest has no "skipped" status), so `cargo xtask ci` lists them: tests record a skip
|
||||
with `lightcraft_testkit::skip!`, and the run ends with what the machine offered (OS, CPUs, RAM, GPU
|
||||
adapters per backend, corpus, fonts) and every skip by reason. It writes `target/ci/report.json` and
|
||||
`target/ci/summary.md`, a block to paste into a PR (`--summary-md` prints it); `--hardware` also runs the
|
||||
GPU tests on every backend that has a hardware adapter. Nothing is uploaded.
|
||||
|
||||
**Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it):
|
||||
|
||||
```sh
|
||||
@@ -405,7 +426,7 @@ 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 — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme).
|
||||
|
||||
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français) or **Settings → General →
|
||||
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Čeština, ქართული) 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
|
||||
|
||||
+1
-1
@@ -304,7 +304,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(
|
||||
- **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
|
||||
较老佳能的 raw 都很出色。
|
||||
- **最大的缺口:**
|
||||
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S 和 X-T4 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
|
||||
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
|
||||
- **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md);
|
||||
- **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法;
|
||||
- **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。**
|
||||
|
||||
+50
-15
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (Leica Q3 verified, 236 models; EOS R5 Mark II CR3 verified; RAW decoding 77%) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
|
||||
One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work
|
||||
from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in
|
||||
@@ -12,11 +12,11 @@ from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---:|---|
|
||||
| **Feature breadth** (checklist) | **81.1%** of 517 rows (P0 98.0%, P1 95.1%, P2 46.9%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount |
|
||||
| **Feature breadth** (checklist) | **81.6%** of 532 rows (P0 98.0%, P1 95.2%, P2 50.0%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount |
|
||||
| **Feature breadth** (weighted by use) | **~77%** | estimated |
|
||||
| **Ready for real work** (full target) | **~61%** (56–66%) | estimated: weighted sum over the dimensions |
|
||||
| **Mainstream practitioner** | **~49%** (44–54%) | estimated |
|
||||
| **Essentials user** | **~64%** (59–69%) | estimated |
|
||||
| **Mainstream practitioner** | **~51%** (46–56%) | estimated |
|
||||
| **Essentials user** | **~65%** (60–70%) | estimated |
|
||||
| Remaining to **beta** | **~340–600 h** (Opus 5.5 agent hours, ~60% parallel) | estimated |
|
||||
| Remaining to **full parity** | **~1,000–1,800 h** (~70% parallel) | estimated |
|
||||
|
||||
@@ -25,15 +25,15 @@ Each audience with its own hours (Opus 5.5 agent wall-clock hours to ~95%):
|
||||
| Audience | Ready | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|
||||
|---|---:|---:|---|
|
||||
| Full target (ready for real work) | **~61%** | **1,000–1,800 h** (~70% parallelizes) | raw colour, lens and camera data; AI models; Classic output modules; localization; plug-in ecosystem |
|
||||
| Mainstream practitioner | **~49%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability |
|
||||
| Essentials user | **~64%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send |
|
||||
| Mainstream practitioner | **~51%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability |
|
||||
| Essentials user | **~65%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send |
|
||||
|
||||
The gap between breadth and readiness is quality and coverage, not missing buttons: camera colour is
|
||||
estimated rather than measured, CR3 decodes on four verified Canon bodies, compressed ORF and HE NEF
|
||||
estimated rather than measured, CR3 decodes on 26 verified Canon bodies (rarer CRX variants still open as previews), compressed ORF and HE NEF
|
||||
open as previews, rendering is tuned by eye, AI masks are heuristics, there is no lens database, and
|
||||
tests run in CI on FreeBSD only. **Why alpha:** core workflows run end to end, but readiness is below
|
||||
the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (Canon, ~30% of
|
||||
raw users, decodes CR3 on four verified bodies). It passes the alpha gate: all six core workflows
|
||||
the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (compressed ORF and HE NEF open as previews; Canon,
|
||||
~30% of raw users, decodes CR3 on 26 verified bodies). It passes the alpha gate: all six core workflows
|
||||
(import, cull, develop, save and reopen, batch looks, export) work end to end on macOS
|
||||
([docs/roadmap.md](docs/roadmap.md#alpha-gate)). The three readiness numbers and their weights and discounts are in
|
||||
[target-app-parity.md](docs/target-app-parity.md#ready-for-real-work-mainstream-practitioner-and-essentials-user).
|
||||
@@ -45,11 +45,11 @@ Hours are calibrated on this repo's PR history; see
|
||||
| Dimension | Ready | Breadth | Remaining | Doc |
|
||||
|---|---:|---:|---:|---|
|
||||
| Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) |
|
||||
| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
|
||||
| UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-parity.md) |
|
||||
| ↳ RAW cameras, colour, lens profiles | ~60% | 83% of 1,446 Adobe models decode, 38% verified | 255–480 h | [raw-parity](docs/raw-parity.md) |
|
||||
| UI/UX fidelity | ~70% | ~85% | 32–54 h | [ui-parity](docs/ui-parity.md) |
|
||||
| File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) |
|
||||
| Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Localization | ~52% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) |
|
||||
| Localization | ~55% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) |
|
||||
| Performance | ~70% | ~80% | 30–50 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
|
||||
| Stability | ~60% | — | 30–60 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
|
||||
| Platforms | ~60% | macOS, Windows, Linux, FreeBSD, web | 25–45 h | [target-app-parity](docs/target-app-parity.md#by-dimension) |
|
||||
@@ -61,7 +61,7 @@ Hours are calibrated on this repo's PR history; see
|
||||
| Area | Ready | Breadth | Remaining |
|
||||
|---|---:|---:|---:|
|
||||
| Import and library | 80% | 90% | 25–45 h |
|
||||
| RAW decoding and camera coverage | 77% | 81% | 120–220 h |
|
||||
| RAW decoding and camera coverage | 77% | 82% | 120–220 h |
|
||||
| Colour, camera profiles, render fidelity | 35% | 65% | 80–150 h |
|
||||
| Global develop adjustments | 70% | 93% | 30–60 h |
|
||||
| Optics, lens profiles, crop and geometry | 50% | 80% | 40–80 h |
|
||||
@@ -96,7 +96,7 @@ while lower-layer errors stay English. Detail: [localization-parity.md](docs/loc
|
||||
| Korean | ko | 0% | none (no Hangul face) |
|
||||
| Vietnamese | vi | 0% | none |
|
||||
|
||||
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%).
|
||||
Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Italian (92%), Georgian (100%).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -105,7 +105,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
|
||||
1. Camera colour of our own: measured calibration for the popular models (50–90 h)
|
||||
2. CR3 for every Canon body (25–45 h)
|
||||
3. Render-fidelity suite against Lightroom, then tuning (30–60 h)
|
||||
4. Full-resolution zoom (8–15 h)
|
||||
4. ~~Full-resolution zoom~~ (done 2026-10-12)
|
||||
5. AI masks: Subject / Sky / People (40–70 h + model decision)
|
||||
6. Compressed ORF and HE NEF (28–55 h)
|
||||
7. Lens profile database of our own (40–80 h)
|
||||
@@ -114,8 +114,26 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-14: Camera-coverage sweep verifies 30 more models on 40 CC0 files, with file and sensor
|
||||
hashes pinned (`docs/raw/coverage.py`: 515 → 545 verified). Eleven candidates excluded after rechecking
|
||||
identity, provenance and camera-image agreement. Phone and drone DNG tone differences remain tracked in #1277.
|
||||
- 2026-10-14: 97 Canon CR2, Pentax and Ricoh models verified, one CC0 raw.pixls.us file each pinned and
|
||||
checked against the camera JPEG (`crates/raw/tests/canon_pentax_corpus.rs`). 334 models verified, ~50% of
|
||||
photographers on a verified body; headline numbers unchanged.
|
||||
- 2026-10-12: Mamiya MEF (ZD) decodes from sensor data and is imported; verified on a CC0 file. 241 models
|
||||
verified, 35 unsupported; headline numbers unchanged.
|
||||
- 2026-10-12: Epson ERF (R-D1, R-D1s, R-D1x) decodes from sensor data, white balance measured from each
|
||||
file's preview; 3 bodies verified on CC0 files. 240 models verified; headline numbers unchanged.
|
||||
- 2026-10-11: Leica Q3 verified by a recorded check of 38 private DNGs (all decode and develop at full
|
||||
size; mean ΔE to the camera JPEG 6.3–13.9). 236 models verified; headline numbers unchanged.
|
||||
- 2026-10-12: 22 more Canon CR3 bodies verified (EOS R, RP, R3, R5, R6, R6 Mark II / III, R7, R10, R50,
|
||||
R50 V, 90D, 250D, 850D, M200, M50 Mark II, M6 Mark II, 1D X Mark III, PowerShot G5 X II, G7 X III, SX70,
|
||||
V1): one CC0 file each pinned and checked against the camera JPEG; all 110 CC0 CR3s on raw.pixls.us
|
||||
decode. The EOS R6 Mark III gets its as-shot white balance (`ColorData` 66). 259 models verified; RAW
|
||||
decoding 77% → 83%; mainstream ~49% → ~51%, essentials ~64% → ~65%, full ~61%.
|
||||
- 2026-10-12: 117 Nikon and Fujifilm bodies verified, one CC0 raw.pixls.us file each pinned and checked
|
||||
against the camera JPEG (`crates/raw/tests/body_corpus.rs`); the sweep found COOLPIX 5700 / 8400 / 8800
|
||||
decoding wrongly. 354 models verified, ~58% of photographers on a verified body; headline numbers unchanged.
|
||||
- 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a
|
||||
preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files
|
||||
pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models
|
||||
@@ -132,6 +150,8 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
|
||||
/ localization parity); `docs/parity.md` renamed `docs/parity-checklist.md`. Landed today: Nikon
|
||||
lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map JPEG / PQ AVIF / float TIFF export
|
||||
(#646), Lightroom-like Auto (#628), activity stack.
|
||||
- 2026-10-11: Sony ILCA-99M2 (α99 II) camera colour: bundled profile from 144 photos; on 30 withheld photos
|
||||
mean ΔE vs the camera JPEG 5.11 → 4.87, 25 of them closer.
|
||||
- 2026-10-09: Sony ILCE-7CR camera colour: bundled profile from 125 photos; on 26 withheld photos mean
|
||||
ΔE vs the camera JPEG 4.07 → 3.29. HEIC in release builds (#609), Samsung uncompressed SRW,
|
||||
contact-sheet PDF export, Ukrainian and French UI, release v0.5.0.
|
||||
@@ -163,7 +183,20 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Italian interface (`it`, #1192): 2,201 of 2,398 messages, locale, menu command and tests; headline numbers unchanged |
|
||||
| 2026-10-13 | minor | Sony ILCA-99M2 camera colour profile in the log; numbers unchanged |
|
||||
| 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled |
|
||||
| 2026-10-14 | minor | 21 Sony and Leica models verified (258 models, 18%); headline numbers unchanged |
|
||||
| 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged |
|
||||
| 2026-10-14 | minor | 17 uncompressed Olympus and Samsung models verified on one CC0 file each (254 models verified, 18%); headline numbers unchanged |
|
||||
| 2026-10-12 | minor | Mamiya MEF decoded and imported: ZD verified (241); headline numbers unchanged |
|
||||
| 2026-10-12 | minor | Epson ERF decoded: 3 more verified models (240); headline numbers unchanged |
|
||||
| 2026-10-12 | minor | Georgian (`ka`) interface language added (every catalog message) |
|
||||
| 2026-10-14 | minor | Full-resolution zoom (gap 4) closed: soft proofing gets zoom windows; headline numbers unchanged |
|
||||
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged |
|
||||
| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); headline numbers unchanged |
|
||||
| 2026-10-12 | minor | 22 more CR3 bodies verified, EOS R6 Mark III white balance: RAW decoding 77% → 83%, RAW ~56% → ~60%; mainstream 49% → 51%, essentials 64% → 65%; full unchanged at ~61% |
|
||||
| 2026-10-12 | minor | 117 Nikon and Fujifilm bodies verified (354 models, 24%); headline numbers unchanged |
|
||||
| 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged |
|
||||
| 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points |
|
||||
| 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away |
|
||||
@@ -173,3 +206,5 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
|
||||
| 2026-10-08 | minor | *Where we stand* by dimension and by kind of user |
|
||||
| 2026-10-02 | major | Parity estimate and effort table |
|
||||
| 2026-09-30 | major | Created |
|
||||
| 2026-10-14 | minor | 97 Canon CR2, Pentax and Ricoh models verified (334 models, 23%); headline numbers unchanged |
|
||||
| 2026-10-14 | minor | RAW: 82% of Adobe's models decode, 36% verified (integration batch 36's CC0 per-model checks) |
|
||||
@@ -3,9 +3,9 @@
|
||||
//! ```text
|
||||
//! lightcraft-cli run [--demo | --library DIR | --connect [ADDR]] [--import PATH]… CMD [key=value…]…
|
||||
//! lightcraft-cli mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…]
|
||||
//! lightcraft-cli render <in> -o <out> [--set control=value]… [--settings FILE.json] [--preset ID] [--size N] [--quality Q]
|
||||
//! lightcraft-cli render <in> -o <out> [--set control=value]… [--settings FILE.json|FILE.lcpreset] [--preset ID] [--size N] [--quality Q]
|
||||
//! lightcraft-cli snapshot [--library DIR | --demo] [--script FILE.jsonl] [-o OUT.png] [--size WxH] [--scale S] [FILES…]
|
||||
//! lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES…
|
||||
//! lightcraft-cli merge hdr|panorama|hdr-panorama|hdr-batch [OPTIONS] FILES…
|
||||
//! lightcraft-cli synth-merge hdr|panorama -o DIR
|
||||
//! lightcraft-cli denoise-serve [--model ID] [--models DIR] [--listen ADDR] [--token-env VAR] [--run-on auto|gpu|cpu]
|
||||
//! lightcraft-cli commands [--json]
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
mod alloc_release;
|
||||
|
||||
use std::io::{BufReader, Write};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
@@ -55,7 +55,8 @@ USAGE:
|
||||
Develop one file and export it (.jpg, .png, .tif, .webp, .avif, .jxl, .psd or .dng by extension) with the
|
||||
same encoder as the app's Export dialog. Options:
|
||||
--set CONTROL=VALUE set a develop slider, repeatable (e.g. --set light.exposure=0.5)
|
||||
--settings FILE merge a partial develop-settings JSON file
|
||||
--settings FILE merge a partial develop-settings JSON file, or apply the one preset of a
|
||||
.lcpreset (preset.export) or XMP preset
|
||||
--preset ID apply a preset (see `commands`/presets.list)
|
||||
--size N long edge in pixels (default: full size, cropped)
|
||||
--quality Q JPEG/AVIF/JPEG XL quality 1..100 (default 92)
|
||||
@@ -79,9 +80,10 @@ USAGE:
|
||||
-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)
|
||||
lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES…
|
||||
lightcraft-cli merge hdr|panorama|hdr-panorama|hdr-batch [OPTIONS] FILES…
|
||||
Photo Merge: writes <first>-HDR.dng / -Pano.dng / -HDR-Pano.dng next to the first file and
|
||||
prints the result as JSON. Options:
|
||||
prints the result as JSON. hdr-batch merges each bracket set among the files (photos taken
|
||||
less than a second apart at different exposures), e.g. a whole folder. Options:
|
||||
--deghost none|low|medium|high --no-align --bracket N (HDR panorama)
|
||||
--projection auto|spherical|cylindrical|perspective --boundary-warp 0..100
|
||||
--auto-crop --fill-edges --no-auto-settings
|
||||
@@ -364,8 +366,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), std::io::stdout()).map_err(|e| e.to_string())
|
||||
server.serve_stdio(std::io::stdout()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn merge(args: &[String]) -> Result<(), String> {
|
||||
@@ -373,7 +374,8 @@ fn merge(args: &[String]) -> Result<(), String> {
|
||||
Some("hdr") => "merge.hdr",
|
||||
Some("panorama" | "pano") => "merge.panorama",
|
||||
Some("hdr-panorama") => "merge.hdrPanorama",
|
||||
_ => return Err("merge: expected hdr, panorama or hdr-panorama".into()),
|
||||
Some("hdr-batch") => "merge.hdrBatch",
|
||||
_ => return Err("merge: expected hdr, panorama, hdr-panorama or hdr-batch".into()),
|
||||
};
|
||||
let mut p = serde_json::Map::new();
|
||||
let mut files = Vec::new();
|
||||
@@ -635,11 +637,41 @@ fn connect_port(addr: &str) -> &str {
|
||||
addr.rsplit_once(':').map_or(addr, |(_, port)| port)
|
||||
}
|
||||
|
||||
/// What `render --settings FILE` applies: a partial develop-settings object, or the one preset of
|
||||
/// a preset file (`.lcpreset` from `preset.export`, XMP), applied as the app applies it. Merging a
|
||||
/// `.lcpreset` as settings dropped its wrapper and applied nothing (#1730).
|
||||
enum SettingsFile {
|
||||
Partial(Value),
|
||||
Preset(lightcraft_engine::develop::Preset),
|
||||
}
|
||||
|
||||
fn read_settings(p: &str) -> Result<SettingsFile, String> {
|
||||
use lightcraft_engine::presets::{LCPRESET_EXT, parse_preset_file};
|
||||
let bytes = std::fs::read(p).map_err(|e| format!("{p}: {e}"))?;
|
||||
let ext = Path::new(p).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
let json = serde_json::from_slice::<Value>(&bytes);
|
||||
// a settings object never has a top-level `presets` or `settings` key; a preset file always does
|
||||
let preset_file =
|
||||
ext == LCPRESET_EXT || ext == "xmp" || json.as_ref().is_ok_and(|v| v.is_array() || v.get("presets").is_some() || v.get("settings").is_some());
|
||||
if preset_file {
|
||||
let mut list = parse_preset_file(p, &bytes).map_err(|e| format!("{p}: {e}"))?;
|
||||
return match (list.pop(), list.len()) {
|
||||
(Some(preset), 0) => Ok(SettingsFile::Preset(preset)),
|
||||
(None, _) => Err(format!("{p}: no presets in this file")),
|
||||
(Some(_), n) => Err(format!("{p}: {} presets in this file; --settings applies one, export it on its own", n + 1)),
|
||||
};
|
||||
}
|
||||
match json.map_err(|e| format!("{p}: {e}"))? {
|
||||
v @ Value::Object(_) => Ok(SettingsFile::Partial(v)),
|
||||
_ => Err(format!("{p}: not a develop-settings object or a preset file")),
|
||||
}
|
||||
}
|
||||
|
||||
fn render(args: &[String]) -> Result<(), String> {
|
||||
let mut input = None;
|
||||
let mut output = None;
|
||||
let mut values = serde_json::Map::new();
|
||||
let mut settings: Option<Value> = None;
|
||||
let mut settings = None;
|
||||
let mut preset = None;
|
||||
let mut size: Option<u64> = None;
|
||||
let mut quality = 92u8;
|
||||
@@ -654,11 +686,7 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
let v: f64 = v.trim().parse().map_err(|_| format!("--set {k}: `{v}` is not a number"))?;
|
||||
values.insert(k.trim().to_string(), json!(v));
|
||||
}
|
||||
"--settings" => {
|
||||
let p = take_value(args, &mut i, "--settings")?;
|
||||
let text = std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}"))?;
|
||||
settings = Some(serde_json::from_str(&text).map_err(|e| format!("{p}: {e}"))?);
|
||||
}
|
||||
"--settings" => settings = Some(read_settings(take_value(args, &mut i, "--settings")?)?),
|
||||
"--preset" => preset = Some(take_value(args, &mut i, "--preset")?.to_string()),
|
||||
"--size" => size = Some(take_value(args, &mut i, "--size")?.parse().map_err(|_| "--size expects a number")?),
|
||||
"--quality" => quality = take_value(args, &mut i, "--quality")?.parse().map_err(|_| "--quality expects 1..100")?,
|
||||
@@ -684,8 +712,14 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
if let Some(p) = preset {
|
||||
run(&mut s, "preset.apply", json!({"id": p}))?;
|
||||
}
|
||||
if let Some(st) = settings {
|
||||
run(&mut s, "develop.merge", json!({"settings": st}))?;
|
||||
match settings {
|
||||
Some(SettingsFile::Partial(st)) => run(&mut s, "develop.merge", json!({"settings": st}))?,
|
||||
Some(SettingsFile::Preset(mut p)) => {
|
||||
p.id = "cli.settings".into();
|
||||
s.presets.push(p);
|
||||
run(&mut s, "preset.apply", json!({"id": "cli.settings"}))?;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if !values.is_empty() {
|
||||
run(&mut s, "develop.set", json!({"values": values}))?;
|
||||
@@ -763,12 +797,15 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
let t0 = Instant::now();
|
||||
let mut session = match &library {
|
||||
Some(dir) => {
|
||||
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models();
|
||||
let mut s =
|
||||
Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates();
|
||||
s.open_library(dir, false).map_err(|e| library_error(dir, e))?;
|
||||
s
|
||||
}
|
||||
None if files.is_empty() => Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(),
|
||||
None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(),
|
||||
None if files.is_empty() => {
|
||||
Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates()
|
||||
}
|
||||
None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates(),
|
||||
};
|
||||
// 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())?;
|
||||
@@ -791,6 +828,8 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
// 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;
|
||||
// as the desktop app opens it: an empty library on the grid, where Import is offered
|
||||
lightcraft_ui_egui::panels::empty_library::open_on_empty_library(&mut app);
|
||||
let mut h = Headless::new(app, size, scale);
|
||||
let timeout = Duration::from_secs(60);
|
||||
let mut shots = 0usize;
|
||||
@@ -832,7 +871,17 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
match next_path(&mut shots) {
|
||||
Some(p) => {
|
||||
wrote_output = true;
|
||||
params["path"] = json!(p);
|
||||
// `params` is untrusted script JSON: `params["path"] = …` panics on
|
||||
// an array or a string. Only an object (or null, like an absent
|
||||
// `params`) may take the path.
|
||||
if params.is_null() {
|
||||
params = json!({});
|
||||
}
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.insert("path".into(), json!(p));
|
||||
} else {
|
||||
return Err(format!("{script}:{}: ui.screenshot: `params` must be a JSON object", n + 1));
|
||||
}
|
||||
}
|
||||
None => return Err(format!("{script}:{}: ui.screenshot needs a `path` (or give -o)", n + 1)),
|
||||
}
|
||||
|
||||
@@ -47,6 +47,48 @@ fn contact_sheet_exports_pdf_and_protects_originals() {
|
||||
assert_eq!(std::fs::read(&out).unwrap(), prior, "failed export changed prior output");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn print_commands_chain_into_a_picture_package_pdf() {
|
||||
let input = tmp("print-input.png");
|
||||
gradient_png(&input);
|
||||
let out = tmp("print.pdf");
|
||||
let (ok, lines, err) = run_cli(
|
||||
&[
|
||||
"--import",
|
||||
input.to_str().unwrap(),
|
||||
"print.set",
|
||||
r#"layout={"style":"picturePackage","page":{"paper":"4x6","orientation":"landscape"}}"#,
|
||||
r#"image={"zoomToFill":true,"borderEnabled":true,"borderWidth":0.05}"#,
|
||||
"job={\"ppi\":72}",
|
||||
"print.cell.add",
|
||||
"width=1.5",
|
||||
"height=1",
|
||||
"print.cell.add",
|
||||
"width=1.5",
|
||||
"height=1",
|
||||
"print.layout.autoLayout",
|
||||
"print.exportPdf",
|
||||
&format!("path={}", out.display()),
|
||||
],
|
||||
None,
|
||||
);
|
||||
assert!(ok, "{err}: {lines:?}");
|
||||
let export = lines.last().unwrap();
|
||||
assert_eq!((export["result"]["pages"].as_u64(), export["result"]["photos"].as_u64()), (Some(1), Some(1)));
|
||||
let bytes = std::fs::read(&out).unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF-"));
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
let at = text.find("/MediaBox [").unwrap() + "/MediaBox [".len();
|
||||
let nums: Vec<f64> = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect();
|
||||
assert_eq!((nums[2], nums[3]), (432.0, 288.0), "6 x 4 in");
|
||||
// a bad setting fails the run and writes nothing new
|
||||
let before = std::fs::read(&out).unwrap();
|
||||
let (ok, _, _) =
|
||||
run_cli(&["--import", input.to_str().unwrap(), "print.set", "job={\"ppi\":5}", "print.exportPdf", &format!("path={}", out.display())], None);
|
||||
assert!(!ok);
|
||||
assert_eq!(std::fs::read(&out).unwrap(), before);
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lightcraft-cli-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
@@ -139,6 +181,51 @@ fn render_subcommand() {
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown control"));
|
||||
}
|
||||
|
||||
/// Issue #1730: `render --settings` took a `.lcpreset` from `preset.export`, ignored its wrapper
|
||||
/// and rendered the photo unedited.
|
||||
#[test]
|
||||
fn render_settings_applies_an_lcpreset_and_refuses_unknown_files() {
|
||||
let input = tmp("lcpreset-in.png");
|
||||
gradient_png(&input);
|
||||
let preset = tmp("bright.lcpreset");
|
||||
let (ok, lines, err) = run_cli(
|
||||
&[
|
||||
"--import",
|
||||
input.to_str().unwrap(),
|
||||
"develop.set",
|
||||
"control=light.exposure",
|
||||
"value=1",
|
||||
"preset.create",
|
||||
"name=Bright",
|
||||
"preset.export",
|
||||
&format!("path={}", preset.display()),
|
||||
],
|
||||
None,
|
||||
);
|
||||
assert!(ok, "{err}: {lines:?}");
|
||||
let plain = tmp("lcpreset-plain.png");
|
||||
let st = Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", plain.to_str().unwrap()]).status().unwrap();
|
||||
assert!(st.success());
|
||||
let bright = tmp("lcpreset-bright.png");
|
||||
let o = Command::new(BIN)
|
||||
.args(["render", input.to_str().unwrap(), "-o", bright.to_str().unwrap(), "--settings", preset.to_str().unwrap()])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(mean(&bright) > mean(&plain) + 10.0, "the preset's exposure was not applied");
|
||||
|
||||
let other = tmp("other.lcpreset");
|
||||
std::fs::write(&other, r#"{"format": "other", "version": 1, "presets": []}"#).unwrap();
|
||||
let out = tmp("lcpreset-other.png");
|
||||
let o = Command::new(BIN)
|
||||
.args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--settings", other.to_str().unwrap()])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!o.status.success(), "an unreadable settings file was accepted");
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("other.lcpreset"), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
assert!(!out.exists(), "nothing is written when the settings can't be read");
|
||||
}
|
||||
|
||||
/// Issue #93: `render IMG -o IMG` replaced its own input with the render.
|
||||
#[test]
|
||||
fn render_refuses_to_overwrite_its_input() {
|
||||
@@ -273,6 +360,24 @@ fn snapshot_script_failure_exits_non_zero() {
|
||||
assert_eq!((d.width, d.height), (320, 200));
|
||||
}
|
||||
|
||||
/// A script line whose `params` is not an object must be an error, never a panic: the
|
||||
/// `ui.screenshot`-without-a-path branch used to index (`params["path"] = …`) an array and abort
|
||||
/// the whole script.
|
||||
#[test]
|
||||
fn snapshot_script_with_non_object_params_is_an_error() {
|
||||
let script = tmp("snap-bad-params.jsonl");
|
||||
let out = tmp("snap-bad-params.png");
|
||||
std::fs::write(&script, format!("{}\n", json!({"method": "ui.screenshot", "params": [1]}))).unwrap();
|
||||
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(), "{stderr}");
|
||||
assert!(!stderr.contains("panicked"), "a bad `params` must be an error, not a panic: {stderr}");
|
||||
assert!(stderr.contains("`params` must be a JSON object"), "{stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_ui_zoom_keeps_requested_pixel_dimensions() {
|
||||
let script = tmp("snap-ui-zoom.jsonl");
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
//! A broken protocol output must not require the client to close its input pipe.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn broken_stdout_exits_with_stdin_still_open() {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_lightcraft-cli"))
|
||||
.arg("mcp")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
drop(child.stdout.take());
|
||||
input.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n{\"jsonrpc\":").unwrap();
|
||||
input.flush().unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let status = loop {
|
||||
if let Some(status) = child.try_wait().unwrap() {
|
||||
break status;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
child.kill().unwrap();
|
||||
child.wait().unwrap();
|
||||
panic!("MCP did not exit after stdout failed while stdin stayed open");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
};
|
||||
let mut stderr = String::new();
|
||||
child.stderr.take().unwrap().read_to_string(&mut stderr).unwrap();
|
||||
assert!(stderr.contains("serving MCP on stdio"), "CLI must reach the MCP transport: {stderr}");
|
||||
assert!(!status.success(), "broken protocol output must be reported: {stderr}");
|
||||
assert!(stderr.contains("lightcraft-cli:"), "CLI must report the transport error: {stderr}");
|
||||
drop(input);
|
||||
}
|
||||
@@ -219,6 +219,8 @@ fn services(originals: Originals, backend: Option<Backend>, files: Files, frozen
|
||||
save_keyword_list: None,
|
||||
pick_curve_preset_files: None,
|
||||
save_curve_preset_file: None,
|
||||
pick_slideshow_templates: None,
|
||||
save_slideshow_template: None,
|
||||
write: Some(Box::new(download)),
|
||||
// downloads happen on the main thread: exports run in the foreground on the web
|
||||
write_shared: None,
|
||||
|
||||
@@ -41,6 +41,9 @@ pub struct WireJob {
|
||||
pub as_shot_temp: f64,
|
||||
pub as_shot_tint: f64,
|
||||
pub lens: Option<EmbeddedLens>,
|
||||
/// The original's long edge (`SourceInfo::native_long`).
|
||||
#[serde(default)]
|
||||
pub native_long: Option<usize>,
|
||||
pub settings: Arc<DevelopSettings>,
|
||||
pub max_w: usize,
|
||||
pub max_h: usize,
|
||||
@@ -80,6 +83,7 @@ impl WireJob {
|
||||
as_shot_temp: job.info.as_shot_temp,
|
||||
as_shot_tint: job.info.as_shot_tint,
|
||||
lens: job.info.lens,
|
||||
native_long: job.info.native_long,
|
||||
settings: job.settings.clone(),
|
||||
max_w: job.request.max_w,
|
||||
max_h: job.request.max_h,
|
||||
@@ -101,6 +105,7 @@ impl WireJob {
|
||||
lens: self.lens,
|
||||
relative_wb: self.relative_wb,
|
||||
legacy_relative_wb: self.legacy_relative_wb,
|
||||
native_long: self.native_long,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -469,6 +474,7 @@ mod tests {
|
||||
as_shot_temp: 6500.0,
|
||||
as_shot_tint: 0.0,
|
||||
lens: None,
|
||||
native_long: None,
|
||||
settings: Arc::new(DevelopSettings::default()),
|
||||
max_w: 32,
|
||||
max_h: 32,
|
||||
|
||||
@@ -161,6 +161,8 @@ struct Busy {
|
||||
level: SourceLevel,
|
||||
key: u64,
|
||||
cache: Option<(Arc<PreviewCache>, Hash128)>,
|
||||
/// A slideshow slide: the worker renders the photo, it is composed here when it comes back.
|
||||
slide: Option<Arc<lightcraft_engine::slide::SlideCompose>>,
|
||||
}
|
||||
|
||||
struct W {
|
||||
@@ -370,6 +372,13 @@ impl Workers {
|
||||
});
|
||||
}
|
||||
g.remote_done += 1;
|
||||
let rendered = match (rendered, &b.slide) {
|
||||
(Ok(mut r), Some(slide)) => {
|
||||
r.image = slide.compose(&r.image);
|
||||
Ok(r)
|
||||
}
|
||||
(r, _) => r,
|
||||
};
|
||||
g.done.push((
|
||||
b.slot,
|
||||
RenderResult {
|
||||
@@ -493,6 +502,7 @@ impl RenderOffload for Workers {
|
||||
level: job.level,
|
||||
key: job.key,
|
||||
cache: job.cache,
|
||||
slide: job.slide,
|
||||
});
|
||||
None
|
||||
}
|
||||
|
||||
@@ -48,3 +48,7 @@ muda = { workspace = true }
|
||||
objc2 = { workspace = true }
|
||||
objc2-app-kit = { workspace = true }
|
||||
objc2-foundation = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# tests record why they skip (`cargo xtask ci` lists the skips)
|
||||
lightcraft-testkit = { workspace = true }
|
||||
@@ -51,7 +51,7 @@ fn decode(code: u32) -> Option<egui::Theme> {
|
||||
/// absolute path, and is never repeated.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn gtk_theme() -> Option<egui::Theme> {
|
||||
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
|
||||
let exe = gsettings_exe(std::env::var_os("PATH").as_deref())?;
|
||||
let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
@@ -59,6 +59,15 @@ fn gtk_theme() -> Option<egui::Theme> {
|
||||
parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?)
|
||||
}
|
||||
|
||||
/// `gsettings` by absolute path: the absolute `PATH` entries first (NixOS has no /usr/bin/gsettings),
|
||||
/// then the usual places for a sparse `PATH`. Relative entries are skipped, so nothing runs from
|
||||
/// the working folder.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn gsettings_exe(path: Option<&std::ffi::OsStr>) -> Option<std::path::PathBuf> {
|
||||
let listed = path.map(std::env::split_paths).into_iter().flatten().filter(|dir| dir.is_absolute());
|
||||
listed.chain(["/usr/bin", "/bin", "/usr/local/bin"].map(std::path::PathBuf::from)).map(|dir| dir.join("gsettings")).find(|exe| exe.is_file())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
|
||||
match s.trim().trim_matches('\'') {
|
||||
@@ -174,6 +183,24 @@ mod tests {
|
||||
assert_eq!(calls.load(Ordering::Relaxed), after_settle);
|
||||
}
|
||||
|
||||
/// NixOS keeps `gsettings` in /run/current-system/sw/bin or the per-user profile: it is found
|
||||
/// on `PATH`, but never through a relative entry, even one that leads to a real file.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn gsettings_is_found_on_absolute_path_entries_only() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-gsettings-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("gsettings"), "").unwrap();
|
||||
let path = std::env::join_paths([std::path::Path::new("relative/bin"), &dir]).unwrap();
|
||||
assert_eq!(super::gsettings_exe(Some(&path)), Some(dir.join("gsettings")));
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
let relative = std::path::PathBuf::from("../".repeat(cwd.components().count() - 1)).join(dir.strip_prefix("/").unwrap());
|
||||
assert!(relative.join("gsettings").is_file());
|
||||
let found = super::gsettings_exe(Some(relative.as_os_str()));
|
||||
assert!(found.as_ref().is_none_or(|p| p.is_absolute()), "{found:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn portal_values_and_gsettings_output_decode() {
|
||||
|
||||
+294
-8
@@ -76,10 +76,13 @@ impl eframe::App for App {
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
self.0.perf.host_frame_ms = frame.info().cpu_usage.map(|s| f64::from(s) * 1000.0);
|
||||
self.0.ui(ui);
|
||||
}
|
||||
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||
// (a size the window was just given is remembered too)
|
||||
self.0.ui.window = self.1.window_at_exit(self.0.ui.window);
|
||||
if let Err(e) = self.1.save(&self.0) {
|
||||
log::error!("{e}");
|
||||
}
|
||||
@@ -162,6 +165,46 @@ fn gpu_crash_notice(what: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// Written when the window stopped drawing after the GPU device was lost (issue #1337): the next
|
||||
/// launch renders on the CPU and says why.
|
||||
fn gpu_lost_marker_path() -> Option<std::path::PathBuf> {
|
||||
gpu_marker_path().map(|p| p.with_file_name("gpu-lost.marker"))
|
||||
}
|
||||
|
||||
/// Why the GPU was lost, when the GPU pipeline stopped after a device failure in this process (a
|
||||
/// driver reset, a hang, the Windows watchdog): `None` for other reasons it is off, or when it
|
||||
/// never failed.
|
||||
fn gpu_lost_reason() -> Option<String> {
|
||||
lightcraft_engine::gpu::unavailable_reason().and_then(|r| r.strip_prefix("stopped after a GPU failure: ").map(str::to_string))
|
||||
}
|
||||
|
||||
/// Record that LightCraft stopped after the GPU was lost, for the next launch.
|
||||
fn record_gpu_lost(marker: Option<&std::path::Path>, reason: &str) {
|
||||
let Some(m) = marker else { return };
|
||||
if let Some(d) = m.parent() {
|
||||
let _ = std::fs::create_dir_all(d);
|
||||
}
|
||||
if let Err(e) = std::fs::write(m, format!("{reason}\n")) {
|
||||
log::warn!("could not write {}: {e}", m.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// A GPU loss the last launch stopped on: its notice, once (the marker is removed, except by a
|
||||
/// `--memory` session, which leaves the disk as it found it).
|
||||
fn gpu_lost_check_at(marker: Option<&std::path::Path>, in_memory: bool) -> Option<String> {
|
||||
let m = marker?;
|
||||
let reason = std::fs::read_to_string(m).ok()?;
|
||||
if !in_memory {
|
||||
let _ = std::fs::remove_file(m);
|
||||
}
|
||||
Some(format!(
|
||||
"LightCraft stopped last time after the graphics driver reset the GPU ({}) while photos rendered on it, 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; \
|
||||
an up-to-date graphics driver may help.",
|
||||
reason.trim()
|
||||
))
|
||||
}
|
||||
|
||||
/// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's
|
||||
/// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's.
|
||||
fn display_profile_dir() -> Option<std::path::PathBuf> {
|
||||
@@ -183,6 +226,16 @@ fn config_dir() -> Option<std::path::PathBuf> {
|
||||
lightcraft_engine::config::config_dir()
|
||||
}
|
||||
|
||||
/// The window at the size it was last used at, zoomed if it was (`window`), or at the usual size
|
||||
/// when nothing is remembered. A zoomed window is given the size it goes back to. Where it
|
||||
/// opens is the system's to say.
|
||||
fn as_remembered(builder: egui::ViewportBuilder, window: Option<lightcraft_ui_egui::window_place::WindowPlace>) -> egui::ViewportBuilder {
|
||||
let builder = builder.with_inner_size(window.map_or(lightcraft_ui_egui::window_place::DEFAULT_SIZE, |w| w.size));
|
||||
// (only ever asked to zoom: asked not to, macOS un-zooms a window whose size fills the screen
|
||||
// to a size of its own choosing)
|
||||
if window.is_some_and(|w| w.zoomed) { builder.with_maximized(true) } else { builder }
|
||||
}
|
||||
|
||||
/// `<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> {
|
||||
@@ -301,6 +354,11 @@ struct PrefsWriter {
|
||||
failing: bool,
|
||||
/// `ui.json` couldn't be read at launch: never overwrite it this session.
|
||||
keep_file: bool,
|
||||
/// Follows the window, to remember the size it rests at (`UiState::window`).
|
||||
window: lightcraft_ui_egui::window_place::WindowWatch,
|
||||
/// When the window was first looked at: its watch goes by the clock from then on, not by
|
||||
/// the interface's time, which stands still while the window is hidden.
|
||||
window_since: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
impl PrefsWriter {
|
||||
@@ -331,8 +389,33 @@ impl PrefsWriter {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What to remember of the window as the app quits ([`WindowWatch::at_exit`]).
|
||||
///
|
||||
/// [`WindowWatch::at_exit`]: lightcraft_ui_egui::window_place::WindowWatch::at_exit
|
||||
fn window_at_exit(
|
||||
&mut self,
|
||||
remembered: Option<lightcraft_ui_egui::window_place::WindowPlace>,
|
||||
) -> Option<lightcraft_ui_egui::window_place::WindowPlace> {
|
||||
match self.window_since {
|
||||
Some(since) => self.window.at_exit(remembered, since.elapsed().as_secs_f64()),
|
||||
None => remembered,
|
||||
}
|
||||
}
|
||||
|
||||
fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
// (asked for before the input is read: the context is not asked from inside its own lock)
|
||||
let zoom = ctx.zoom_factor();
|
||||
let (now, seen) = ctx.input(|i| (i.time, lightcraft_ui_egui::window_place::Seen::of(i.viewport(), i.content_rect(), zoom)));
|
||||
// how the window is, to open like that next time (written with the settings below). What
|
||||
// the settings hold is checked first: a script may have set it (`ui.set`)
|
||||
let remembered = app.ui.window.and_then(lightcraft_ui_egui::window_place::WindowPlace::checked);
|
||||
let at = self.window_since.get_or_insert_with(std::time::Instant::now).elapsed().as_secs_f64();
|
||||
app.ui.window = self.window.note(remembered, &seen, at);
|
||||
if let Some(wait) = self.window.waits(at) {
|
||||
// (a size is only remembered once the window has rested at it: look again then, though
|
||||
// nothing else asks for a frame)
|
||||
ctx.request_repaint_after(wait);
|
||||
}
|
||||
let moved = app.ui.settings.library_path != self.library;
|
||||
if !moved && now - self.checked < 3.0 {
|
||||
return;
|
||||
@@ -603,6 +686,24 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_slideshow_templates: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Import Slideshow Templates"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"])
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})),
|
||||
save_slideshow_template: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(lightcraft_ui_egui::i18n::tr("Export Slideshow Template"))
|
||||
.add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"])
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
// atomic (temp file + sync + rename): a failed write never leaves a truncated file
|
||||
write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)),
|
||||
write: Some(Box::new(lightcraft_engine::export::write_file)),
|
||||
@@ -616,10 +717,12 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
|
||||
}
|
||||
}
|
||||
|
||||
/// The library session with the folder face models are kept in (`<config>/models`).
|
||||
/// The library session with the folders face models (`<config>/models`) and slideshow templates
|
||||
/// (`<config>/slideshow-templates`) are kept in.
|
||||
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.slide_templates_dir = lightcraft_engine::config::config_dir().map(|d| d.join(lightcraft_engine::slide_template::DIR_NAME));
|
||||
// Control requests are answered on the UI thread: Add to Apple Photos must not wait there
|
||||
s.apple_photos_background = true;
|
||||
(s, problem)
|
||||
@@ -808,7 +911,7 @@ fn main() -> eframe::Result {
|
||||
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_crash = gpu_crash_check(in_memory).or_else(|| gpu_lost_check_at(gpu_lost_marker_path().as_deref(), in_memory));
|
||||
let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none();
|
||||
lightcraft_engine::gpu::set_enabled(gpu_on);
|
||||
// the window's renderer: probed (Windows) and with a fallback chain, so a driver that can't
|
||||
@@ -817,12 +920,15 @@ fn main() -> eframe::Result {
|
||||
let window_gpu::Chosen { renderer, wgpu_options, which: window_renderer, notice: window_notice } = window_gpu::choose(&win_files);
|
||||
win_files.arm(window_renderer);
|
||||
let win_files_app = win_files.clone();
|
||||
// the window came up: a failure after this is LightCraft stopping, not a start that failed
|
||||
let came_up = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let came_up_app = came_up.clone();
|
||||
// how the window was when the app was last used (checked: the settings file is a file)
|
||||
let opens_as = prefs.as_ref().and_then(|ui| ui.window).and_then(lightcraft_ui_egui::window_place::WindowPlace::checked);
|
||||
let options = eframe::NativeOptions {
|
||||
renderer,
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("LightCraft")
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([900.0, 560.0])
|
||||
viewport: as_remembered(egui::ViewportBuilder::default().with_title("LightCraft"), opens_as)
|
||||
.with_min_inner_size(lightcraft_ui_egui::window_place::MIN_SIZE)
|
||||
.with_drag_and_drop(true)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
@@ -854,6 +960,7 @@ fn main() -> eframe::Result {
|
||||
Box::new(move |cc| {
|
||||
// the window's renderer is up (eframe calls this after creating it)
|
||||
win_files_app.started(window_renderer);
|
||||
came_up_app.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
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)
|
||||
@@ -871,6 +978,11 @@ fn main() -> eframe::Result {
|
||||
app.notices.extend(launch_notices);
|
||||
app.notices.extend(window_notice);
|
||||
app.gpu_notice = true;
|
||||
// an empty library opens where Import is offered (a grid, with the sidebar); before
|
||||
// the snapshot below, so this alone isn't written to the settings file
|
||||
if problem.is_none() && files.is_empty() {
|
||||
lightcraft_ui_egui::panels::empty_library::open_on_empty_library(&mut app);
|
||||
}
|
||||
// what's on disk now: only changes are written
|
||||
let writer = PrefsWriter {
|
||||
path: if in_memory { None } else { prefs_path() },
|
||||
@@ -915,6 +1027,31 @@ fn main() -> eframe::Result {
|
||||
}),
|
||||
)
|
||||
}));
|
||||
if came_up.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
// the window was up: a panic that escaped it now is LightCraft stopping, typically the
|
||||
// window's renderer after the graphics driver reset the GPU (issue #1337: egui-wgpu panics
|
||||
// on the lost device). Not a failed start: no other renderer is tried, and the message
|
||||
// says what happened.
|
||||
let stopped = match started {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err(eframe::Error::AppCreation("its window stopped drawing (the renderer panicked)".into())),
|
||||
};
|
||||
if let Err(e) = &stopped {
|
||||
let lost = gpu_lost_reason();
|
||||
if let Some(reason) = &lost
|
||||
&& !in_memory
|
||||
{
|
||||
record_gpu_lost(gpu_lost_marker_path().as_deref(), reason);
|
||||
}
|
||||
// (not "app creation error: …": the app had been created)
|
||||
let what = match e {
|
||||
eframe::Error::AppCreation(m) => m.to_string(),
|
||||
e => e.to_string(),
|
||||
};
|
||||
stopped_while_running(&stopped_text(&what, lost.as_deref()), log_file.as_deref());
|
||||
}
|
||||
return stopped;
|
||||
}
|
||||
// a panic that still escaped the renderer's start (the probe and error handler should
|
||||
// prevent it): an error like any other failed start
|
||||
let started = started.unwrap_or_else(|_| Err(eframe::Error::AppCreation("the window's renderer panicked while starting".into())));
|
||||
@@ -929,6 +1066,34 @@ fn main() -> eframe::Result {
|
||||
started
|
||||
}
|
||||
|
||||
/// What to tell the user when LightCraft stopped while running (`error`), and why when the GPU
|
||||
/// was lost (`lost`): GPU rendering is off from the next start.
|
||||
fn stopped_text(error: &str, lost: Option<&str>) -> String {
|
||||
match lost {
|
||||
Some(reason) => format!(
|
||||
"{error}. The graphics driver reset the GPU ({reason}). LightCraft renders on the CPU from its next start; \
|
||||
Settings ▸ Performance ▸ Use the GPU for rendering turns the GPU back on"
|
||||
),
|
||||
None => error.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// LightCraft stopped while its window was up: say so in the log and in a native message box
|
||||
/// naming the log file (as [`startup_failed`] does for a start that failed).
|
||||
fn stopped_while_running(text: &str, log_file: Option<&std::path::Path>) {
|
||||
log::error!("LightCraft stopped: {text}");
|
||||
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::stopped_message(text, 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();
|
||||
}
|
||||
|
||||
/// 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).
|
||||
@@ -1131,6 +1296,37 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Issue #1337: the window stopped drawing after the graphics driver reset the GPU during an
|
||||
/// export, and the dialog said the window "could not open". The stop is recorded; the next
|
||||
/// launch says why once and renders on the CPU (a `--memory` session leaves the record).
|
||||
#[test]
|
||||
fn a_gpu_lost_while_running_turns_the_gpu_off_next_time() {
|
||||
let d = dir("gpu-lost");
|
||||
let m = d.join("gpu-lost.marker");
|
||||
assert_eq!(gpu_lost_check_at(Some(&m), false), None, "nothing recorded");
|
||||
assert_eq!(gpu_lost_check_at(None, false), None, "no settings folder");
|
||||
record_gpu_lost(Some(&m), "device lost (Unknown): Device is lost");
|
||||
let notice = gpu_lost_check_at(Some(&m), true).unwrap();
|
||||
assert!(
|
||||
notice.contains("(device lost (Unknown): Device is lost)")
|
||||
&& notice.contains("GPU rendering is now off")
|
||||
&& notice.contains("Use the GPU for rendering"),
|
||||
"{notice}"
|
||||
);
|
||||
assert!(m.exists(), "a --memory session leaves it");
|
||||
assert!(gpu_lost_check_at(Some(&m), false).is_some());
|
||||
assert!(!m.exists());
|
||||
assert_eq!(gpu_lost_check_at(Some(&m), false), None, "said once");
|
||||
// the message box: LightCraft stopped (not "could not open its window"), and why
|
||||
let text = stopped_text("its window stopped drawing (the renderer panicked)", Some("device lost (Unknown): Device is lost"));
|
||||
assert!(text.contains("reset the GPU (device lost (Unknown)") && text.contains("renders on the CPU from its next start"), "{text}");
|
||||
assert_eq!(stopped_text("its window stopped drawing", None), "its window stopped drawing");
|
||||
let (title, body) = lightcraft_ui_egui::i18n::stopped_message(&text, Some("/logs/lightcraft.log"));
|
||||
assert_eq!(title, "LightCraft stopped");
|
||||
assert!(body.starts_with("LightCraft stopped unexpectedly: its window stopped drawing") && body.ends_with("/logs/lightcraft.log"), "{body}");
|
||||
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() {
|
||||
@@ -1237,6 +1433,96 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Given what the settings remember of the window, when the app's window is built, then it
|
||||
/// opens at that size, zoomed if it was, where the system puts it; with nothing remembered it
|
||||
/// opens at the usual size.
|
||||
#[test]
|
||||
fn the_window_opens_as_it_was() {
|
||||
use lightcraft_ui_egui::window_place::{DEFAULT_SIZE, WindowPlace};
|
||||
let fresh = as_remembered(egui::ViewportBuilder::default(), None);
|
||||
assert_eq!((fresh.inner_size, fresh.position, fresh.maximized), (Some(egui::Vec2::from(DEFAULT_SIZE)), None, None));
|
||||
let b = as_remembered(egui::ViewportBuilder::default(), Some(WindowPlace { size: [1300.0, 800.0], zoomed: true }));
|
||||
assert_eq!((b.inner_size, b.position, b.maximized), (Some(egui::vec2(1300.0, 800.0)), None, Some(true)));
|
||||
// (not asked to un-zoom: macOS would then resize a window that fills its screen)
|
||||
let b = as_remembered(egui::ViewportBuilder::default(), Some(WindowPlace { size: [1300.0, 800.0], zoomed: false }));
|
||||
assert_eq!((b.inner_size, b.position, b.maximized), (Some(egui::vec2(1300.0, 800.0)), None, None));
|
||||
}
|
||||
|
||||
/// Given a window that rests at a size, when the settings are written and read again, then
|
||||
/// they hold how it was. A window in the file that is not one (a hand edit, another version's)
|
||||
/// reads as nothing remembered and costs no other setting; and a window resized just before
|
||||
/// the app quits is remembered at that size.
|
||||
#[test]
|
||||
fn the_settings_keep_how_the_window_was() {
|
||||
use lightcraft_ui_egui::window_place::{Seen, WindowPlace};
|
||||
let d = dir("window");
|
||||
let path = d.join("ui.json");
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
|
||||
app.ui.settings.library_path = "/pictures/library".into();
|
||||
let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() };
|
||||
writer.save(&app).unwrap();
|
||||
assert!(!std::fs::read_to_string(&path).unwrap().contains("\"window\""), "nothing to remember yet");
|
||||
let window = WindowPlace { size: [1300.0, 800.0], zoomed: true };
|
||||
app.ui.window = Some(window);
|
||||
writer.save(&app).unwrap();
|
||||
let (restored, warning, _) = load_prefs_at(&path, false);
|
||||
assert_eq!((restored.unwrap().window, warning), (Some(window), None));
|
||||
|
||||
// a window that is no window: the file is read all the same, with its other settings
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
for odd in [r#""window": {}"#, r#""window": "wide""#, r#""window": {"size": [1, 2, 3]}"#, r#""window": {"size": [null, 800.0]}"#] {
|
||||
let start = text.find("\"window\"").unwrap();
|
||||
let end = start + text[start..].find('}').unwrap() + 1;
|
||||
std::fs::write(&path, format!("{}{odd}{}", &text[..start], &text[end..])).unwrap();
|
||||
let (restored, warning, keep) = load_prefs_at(&path, true);
|
||||
let restored = restored.unwrap_or_else(|| panic!("{odd}: {warning:?}"));
|
||||
assert_eq!((restored.window, restored.settings.library_path.as_str(), warning, keep), (None, "/pictures/library", None, false), "{odd}");
|
||||
}
|
||||
|
||||
// resized, and the app quits a moment later: the new size is what is written
|
||||
let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() };
|
||||
let rest = Some(WindowPlace { size: [1300.0, 800.0], zoomed: false });
|
||||
writer.window.note(rest, &Seen { size: Some([1500.0, 900.0]), ..Default::default() }, 0.0);
|
||||
writer.window_since = std::time::Instant::now().checked_sub(std::time::Duration::from_millis(400));
|
||||
assert!(writer.window_since.is_some());
|
||||
assert_eq!(writer.window_at_exit(rest), Some(WindowPlace { size: [1500.0, 900.0], zoomed: false }));
|
||||
assert_eq!(PrefsWriter::default().window_at_exit(rest), rest, "a window that was never seen");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// Given the app's window at a size, when the settings writer looks at it frame after frame,
|
||||
/// then it asks to be woken while the size has still to rest, remembers the size once it has,
|
||||
/// and asks for nothing more. A window in the settings that is not one (a script set it) is
|
||||
/// put right before it is gone by.
|
||||
#[test]
|
||||
fn the_writer_follows_the_window_from_frame_to_frame() {
|
||||
use lightcraft_ui_egui::window_place::WindowPlace;
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());
|
||||
let mut writer = PrefsWriter::default();
|
||||
let ctx = egui::Context::default();
|
||||
let pass = |look: &mut dyn FnMut(&egui::Context)| {
|
||||
let raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1300.0, 800.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| look(ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.repaint_delay)
|
||||
};
|
||||
// (egui's own first frames ask for another: let them pass)
|
||||
for _ in 0..3 {
|
||||
pass(&mut |_| {});
|
||||
}
|
||||
app.ui.window = Some(WindowPlace { size: [f32::NAN, 800.0], zoomed: true });
|
||||
let asked = pass(&mut |ctx| writer.tick(&mut app, ctx));
|
||||
assert_eq!(app.ui.window, None, "no window, and not yet at rest");
|
||||
assert!(asked.is_some_and(|wait| wait <= std::time::Duration::from_secs(1)), "{asked:?}");
|
||||
// a second later
|
||||
writer.window_since = writer.window_since.and_then(|since| since.checked_sub(std::time::Duration::from_secs(1)));
|
||||
pass(&mut |ctx| writer.tick(&mut app, ctx));
|
||||
assert_eq!(app.ui.window, Some(WindowPlace { size: [1300.0, 800.0], zoomed: false }));
|
||||
let asked = pass(&mut |ctx| writer.tick(&mut app, ctx));
|
||||
assert!(asked.is_none_or(|wait| wait > std::time::Duration::from_secs(3600)), "at rest and remembered: {asked:?}");
|
||||
}
|
||||
|
||||
/// #1331: on macOS the process ends inside `on_exit`, before eframe would tear the window down.
|
||||
/// The check runs in a child (this test binary again), since on macOS it ends the process.
|
||||
#[test]
|
||||
|
||||
@@ -26,8 +26,9 @@ const QUIT: &str = "app.quit";
|
||||
const SETTINGS: &str = "app.settings";
|
||||
const SETTINGS_KEY: &str = "Cmd+,";
|
||||
/// Keys whose meaning depends on the panel (X = reject or swap crop aspect, ⌫ = delete the photo
|
||||
/// or the active mask): left to the egui handler, which knows the context.
|
||||
const CONTEXTUAL: &[&str] = &["X", "Delete"];
|
||||
/// or the active mask; ⌘↑/⌘↓ flag status follows Compare/Survey's active photo): left to the egui
|
||||
/// handler, which knows the context.
|
||||
const CONTEXTUAL: &[&str] = &["X", "Delete", "Cmd+Up", "Cmd+Down"];
|
||||
/// Shortcuts text fields need while they have focus.
|
||||
const TEXT_EDIT: &[&str] = &["Cmd+A", "Cmd+C", "Cmd+V", "Cmd+X", "Cmd+Z", "Cmd+Shift+Z"];
|
||||
|
||||
@@ -126,6 +127,7 @@ fn accelerator(sc: &str) -> Option<Accelerator> {
|
||||
"=" => Code::Equal,
|
||||
"-" => Code::Minus,
|
||||
"'" => Code::Quote,
|
||||
"`" => Code::Backquote,
|
||||
"," => Code::Comma,
|
||||
"Delete" => Code::Backspace,
|
||||
// with modifiers only: a bare Enter belongs to text fields and tools
|
||||
|
||||
@@ -278,11 +278,11 @@ mod tests {
|
||||
..wgpu::InstanceDescriptor::new_without_display_handle()
|
||||
});
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else {
|
||||
eprintln!("no GPU adapter: skipped");
|
||||
lightcraft_testkit::skip!(Gpu, "no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else {
|
||||
eprintln!("no GPU device: skipped");
|
||||
lightcraft_testkit::skip!(Gpu, "no GPU device");
|
||||
return;
|
||||
};
|
||||
install_error_handler(&device);
|
||||
|
||||
@@ -12,20 +12,28 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` |
|
||||
| `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none |
|
||||
| `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
|
||||
| `assets/camera-profiles/ILCA-99M2.json` | Sony ILCA-99M2 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @PiMinusMax | original work: fitted by `lightcraft-cli calibrate` to 144 usable ILCA-99M2 ARWs (of 150, seven shooting days, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-11 | none |
|
||||
| `assets/camera-profiles/ILCE-7CR.json` | Sony ILCE-7CR camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @AlexChambers | original work: fitted by `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131, September 2025 to September 2026, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-09 | none |
|
||||
| `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none |
|
||||
| `assets/camera-profiles/NIKON_Z_6_2.json` | Nikon Z 6II camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 296 usable Z 6II NEFs of my own (of 299, 2024 to 2026, Standard and Auto picture controls; NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) 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-13 | none |
|
||||
| `assets/camera-profiles/NIKON_Z_8.json` | Nikon Z 8 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 261 usable Z 8 NEFs of my own (of 278, 2023 and 2026, nearly all Standard picture control, lossless 14-bit; NIKKOR Z 24-120mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) 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-13 | none |
|
||||
| `assets/camera-profiles/NIKON_D5300.json` | Nikon D5300 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 264 usable D5300 NEFs of my own (of 273, 2014 to 2017, mostly Standard picture control, lossy 12-bit, mostly the AF-S DX 35mm f/1.8G) 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-13 | none |
|
||||
| `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix |
|
||||
| `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none |
|
||||
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `crates/ui-egui/src/panels/person.rs` | Up arrow on the People page bulk-confirm control, drawn in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-10-13 | 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 |
|
||||
| `docs/images/*-migrant-mother.jpg`, `docs/images/grid-pd.jpg` | "Migrant Mother" | Dorothea Lange (1936), Farm Security Administration / Library of Congress | https://commons.wikimedia.org/wiki/File:Lange-MigrantMother02.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*-earthrise.jpg`, `docs/images/grid-pd.jpg` | "Earthrise" (AS08-14-2383) | NASA / Bill Anders, Apollo 8 (1968) | https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
|
||||
| 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 |
|
||||
| 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, Noto Sans Arabic Regular, Noto Sans Thai Regular | The Noto Project Authors (Arabic and Thai); 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 Noto Sans Thai Regular and ships Noto Sans CJK SC Regular as a separate font file |
|
||||
| `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders |
|
||||
| `crates/ui-egui/locales/cs.json`, `crates/ui-egui/locales/cs-formats.json` | Czech interface catalogs | @Losminek with AI assistance (Codex) | original work: translations of English source phrases, reviewed against the app UI conventions; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-11 | catalog coverage, Czech dates and count-neutral wording; independent scope/terminology review; native-speaker proofreading pending |
|
||||
| `crates/ui-egui/locales/it.json`, `crates/ui-egui/locales/it-formats.json` | Italian interface catalogs | @GabrielePecchioli with Claude Code; PhotoCraft, VectorCraft, PdfCraft and WordCraft translation contributors for reused labels | original translations from English source; 410 initial English-key matches reviewed from [PhotoCraft da3da09](https://github.com/storytold/photocraft/blob/da3da0942c14c1159f40c652afcf33584332c733/crates/ui-egui/src/i18n/it.tsv), [VectorCraft 5432821](https://github.com/storytold/vectorcraft/blob/543282148007bcb5633c493a5374cd26b0339c8d/crates/ui-egui/src/i18n/it.tsv), [PdfCraft e8e275c](https://github.com/storytold/pdfcraft/blob/e8e275cbd40ccd0dc6b56f82b16a575571043702/crates/ui-egui/src/i18n/it.tsv) and the Italian WordCraft catalog ([storytold/wordcraft#598](https://github.com/storytold/wordcraft/pull/598)); 46 of them retranslated where the sibling's meaning didn't fit a photo app | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-12 | adapted to LightCraft photo-development context, count-neutral labels for Italian plural agreement, reviewed placeholders |
|
||||
| `crates/ui-egui/locales/ka.json`, `crates/ui-egui/locales/ka-formats.json` | Georgian interface catalogs | Davit Matchakhelidze with Claude; WordCraft, GridCraft, PhotoCraft, FilmCraft and VectorCraft Georgian catalogs for reused labels | original translations from the English source (meaning-based, no vendor localization used); 450 English-key matches reused from the sibling Craft apps' Georgian catalogs | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-12 | adapted to LightCraft photo-library and raw-development context; count-neutral numerals; placeholders checked |
|
||||
|
||||
Notes
|
||||
- Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -25,9 +25,23 @@ sync_github_name = false
|
||||
[people.rock-meister]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Th4nat0s]
|
||||
display_name = "Thanat0s"
|
||||
|
||||
[people.sanyamsingh001]
|
||||
display_name = "sanyamsingh001"
|
||||
|
||||
[people.ArekatlaNishanthchowdary]
|
||||
real_name = "Arekatla Nishanth Chowdary"
|
||||
sync_github_name = true
|
||||
|
||||
[people.Losminek]
|
||||
sync_github_name = true
|
||||
|
||||
[people.FooVoo]
|
||||
real_name = "Aleksandr Batyrev"
|
||||
sync_github_name = true
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
@@ -0,0 +1,8 @@
|
||||
# Seeds for failure cases proptest has generated in the past. It is
|
||||
# automatically read and these particular cases re-run before any
|
||||
# novel cases are generated.
|
||||
#
|
||||
# It is recommended to check this file in to source control so that
|
||||
# everyone who runs the test benefits from these saved cases.
|
||||
cc b91df6f4f23ec90cae3c0fb934b04b806181b61570f00ba25826e2232b7e1932 # shrinks to steps = [(3, 0, 0, false)]
|
||||
cc b11415bdf5e4133b65e00645d4051129c71bf927747d6384aa50107440e92e07 # shrinks to steps = [(0, 10, 166, false), (7, 1, 0, false), (1, 14, 0, false)]
|
||||
@@ -49,7 +49,9 @@ pub fn shift_iso(iso: &str, secs: i64) -> Option<String> {
|
||||
if !(first..=last).contains(&t) {
|
||||
return None;
|
||||
}
|
||||
let tail = iso.trim().get(19..).unwrap_or("");
|
||||
let iso = iso.trim();
|
||||
// the seconds are optional (`10:00+02:00`): without them the tail starts right after the minutes
|
||||
let tail = iso.get(if iso.get(16..17) == Some(":") { 19 } else { 16 }..).unwrap_or("");
|
||||
Some(format!("{}{tail}", civil(t)))
|
||||
}
|
||||
|
||||
@@ -222,6 +224,15 @@ mod tests {
|
||||
assert_eq!(normalize_iso("2026-02-30"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_keeps_the_zone_of_a_time_without_seconds() {
|
||||
assert_eq!(shift_iso("2026-01-01T10:00+02:00", 3600).as_deref(), Some("2026-01-01T11:00:00+02:00"));
|
||||
assert_eq!(shift_iso("2026-01-01T10:00Z", 60).as_deref(), Some("2026-01-01T10:01:00Z"));
|
||||
assert_eq!(shift_iso("2026-01-01T10:00", 3600).as_deref(), Some("2026-01-01T11:00:00"));
|
||||
assert_eq!(shift_iso("2026-01-01T10:00:00.5-05:00", 3600).as_deref(), Some("2026-01-01T11:00:00.5-05:00"));
|
||||
assert_eq!(iso_seconds("2026-01-01T10:00+02:00"), iso_seconds("2026-01-01T10:00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_by_day_month_year_keep_stacks_together() {
|
||||
let mut c = Catalog::new();
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Lookups derived from the catalog's own data, so questions asked once per photo of a big library
|
||||
//! don't scan a list each time: which stack a photo is in, whether a regular album holds it.
|
||||
//!
|
||||
//! They are never saved (the on-disk format is unchanged). Each is built on first use from what
|
||||
//! the catalog holds, so a catalog read from a snapshot, replayed from the journal or cloned starts
|
||||
//! without them and builds them when asked. Once built, [`crate::Catalog::apply`] keeps them up to
|
||||
//! date: the ops that change stacks or album photos (`AddStack`, `RemoveStack`, `SetStack`,
|
||||
//! `AddAlbum`, `RemoveAlbum`, `SetAlbumPhotos`) update them as they apply, inverses and the
|
||||
//! rollback of a failed batch included, as those are ops too.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::{AlbumId, Catalog, PhotoId, StackId};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Indexes {
|
||||
/// Photo → the stack it is in; `None` when the catalog was read with a photo in two stacks
|
||||
/// (only a hand-edited file can hold that: ops refuse it), where a scan says which one counts.
|
||||
stacks: OnceLock<Option<HashMap<PhotoId, StackId>>>,
|
||||
/// Album → its photos (regular albums; a smart album's list is empty).
|
||||
albums: OnceLock<HashMap<AlbumId, HashSet<PhotoId>>>,
|
||||
}
|
||||
|
||||
/// A copy builds its own when asked (copies are made to be saved, not queried).
|
||||
impl Clone for Indexes {
|
||||
fn clone(&self) -> Self {
|
||||
Indexes::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Derived from the rest of the catalog: two catalogs with the same data are equal whatever was
|
||||
/// built.
|
||||
impl PartialEq for Indexes {
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Indexes {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let stacks = self.stacks.get().map(|m| m.as_ref().map(HashMap::len));
|
||||
f.debug_struct("Indexes").field("stacks", &stacks).field("albums", &self.albums.get().map(HashMap::len)).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Indexes {
|
||||
/// Stack `id` held `old` and holds `new` now (empty: it was added, or is gone).
|
||||
pub(crate) fn stack_changed(&mut self, id: StackId, old: &[PhotoId], new: &[PhotoId]) {
|
||||
let Some(Some(m)) = self.stacks.get_mut() else { return };
|
||||
for p in old {
|
||||
if m.get(p) == Some(&id) {
|
||||
m.remove(p);
|
||||
}
|
||||
}
|
||||
for p in new {
|
||||
m.insert(*p, id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Album `id` holds `photos` now (`None`: it is gone).
|
||||
pub(crate) fn album_changed(&mut self, id: AlbumId, photos: Option<&[PhotoId]>) {
|
||||
let Some(m) = self.albums.get_mut() else { return };
|
||||
match photos {
|
||||
Some(photos) => {
|
||||
m.insert(id, photos.iter().copied().collect());
|
||||
}
|
||||
None => {
|
||||
m.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// The stack photo `photo` is in, by the index (built on first use).
|
||||
pub(crate) fn stack_id_of(&self, photo: PhotoId) -> Option<StackId> {
|
||||
let m = self.index.stacks.get_or_init(|| {
|
||||
let mut m = HashMap::new();
|
||||
for s in self.stacks.values() {
|
||||
for p in &s.photos {
|
||||
if m.insert(*p, s.id).is_some_and(|other| other != s.id) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(m)
|
||||
});
|
||||
match m {
|
||||
Some(m) => m.get(&photo).copied(),
|
||||
// in id order, the first stack listing the photo has it
|
||||
None => self.stacks.values().find(|s| s.photos.contains(&photo)).map(|s| s.id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether album `album`'s own list holds `photo`, by the index (built on first use).
|
||||
pub(crate) fn album_lists(&self, album: AlbumId, photo: PhotoId) -> bool {
|
||||
let m = self.index.albums.get_or_init(|| self.albums.values().map(|a| (a.id, a.photos.iter().copied().collect())).collect());
|
||||
m.get(&album).is_some_and(|s| s.contains(&photo))
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,7 @@
|
||||
//! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` |
|
||||
//! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` |
|
||||
//! | 8 | History cursor: Go to History Step adds no step | `Op::SetHistoryCursor`, `Photo.history_cursor` |
|
||||
//! | 9 | preview-only recheck | `Photo.preview_checked`, `Op::SetContent.preview_checked` |
|
||||
//!
|
||||
//! Rules:
|
||||
//! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a
|
||||
@@ -75,7 +76,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 = 8;
|
||||
pub const VERSION: u32 = 9;
|
||||
|
||||
/// When [`Journal::wants_snapshot`] says it's time to compact the log.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
|
||||
+202
-9
@@ -15,6 +15,7 @@
|
||||
|
||||
pub mod dates;
|
||||
pub mod folders;
|
||||
mod index;
|
||||
pub mod journal;
|
||||
pub mod keywords;
|
||||
pub mod local;
|
||||
@@ -25,6 +26,7 @@ pub mod rules;
|
||||
pub mod safe_file;
|
||||
pub mod stacks;
|
||||
pub mod store;
|
||||
pub mod tally;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
@@ -214,6 +216,10 @@ pub enum Op {
|
||||
/// `None` = its raw data decodes (or it isn't a raw).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
preview_only: Option<String>,
|
||||
/// The app version that decided `preview_only` (see [`Photo::preview_checked`]); empty
|
||||
/// when it is `None`.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
preview_checked: String,
|
||||
},
|
||||
/// The lens data a photo's file carries (what Reload finds when it was read after import).
|
||||
SetEmbeddedLens {
|
||||
@@ -281,6 +287,74 @@ pub struct Catalog {
|
||||
/// Increments on every applied op.
|
||||
#[serde(skip)]
|
||||
pub revision: u64,
|
||||
/// Which photos the latest ops touched ([`Catalog::touched_since`]).
|
||||
#[serde(skip)]
|
||||
changes: Changes,
|
||||
/// Lookups derived from the above (never saved), see [`index`].
|
||||
#[serde(skip)]
|
||||
index: index::Indexes,
|
||||
}
|
||||
|
||||
/// What of a photo an op changed: enough to tell which tallies of the library an edit leaves as they were (a
|
||||
/// rating changes no folder's count; a slider changes none at all).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Aspects(u16);
|
||||
|
||||
impl Aspects {
|
||||
pub const NONE: Aspects = Aspects(0);
|
||||
pub const RATING: Aspects = Aspects(1);
|
||||
pub const FLAG: Aspects = Aspects(1 << 1);
|
||||
pub const LABEL: Aspects = Aspects(1 << 2);
|
||||
/// Develop settings, versions and history.
|
||||
pub const DEVELOP: Aspects = Aspects(1 << 3);
|
||||
/// Title, caption, keywords, people, place and the rest of the metadata.
|
||||
pub const META: Aspects = Aspects(1 << 4);
|
||||
pub const CAPTURED: Aspects = Aspects(1 << 5);
|
||||
/// Where its file is.
|
||||
pub const FILE: Aspects = Aspects(1 << 6);
|
||||
pub const ANALYSIS: Aspects = Aspects(1 << 7);
|
||||
/// Whether it is in the library at all: added, removed, deleted, restored, taken in from Local.
|
||||
pub const PRESENCE: Aspects = Aspects(1 << 8);
|
||||
/// Everything: the photo may count differently for any tally.
|
||||
pub const ALL: Aspects = Aspects(u16::MAX);
|
||||
|
||||
pub const fn with(self, other: Aspects) -> Aspects {
|
||||
Aspects(self.0 | other.0)
|
||||
}
|
||||
|
||||
/// Whether any of `other` is among these.
|
||||
pub const fn any_of(self, other: Aspects) -> bool {
|
||||
self.0 & other.0 != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// The most photos remembered as touched; older changes are then answered with "anything may have changed".
|
||||
const MAX_TOUCHED: usize = 8192;
|
||||
|
||||
/// A short memory of what the latest ops changed, so that what is derived from the catalog (the grid's list) can
|
||||
/// follow a small edit without being worked out again from every photo.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Changes {
|
||||
/// The photos ops changed, each with the revision the op made and what of the photo it changed. Oldest first.
|
||||
touched: std::collections::VecDeque<(u64, PhotoId, Aspects)>,
|
||||
/// What the op being applied changes of its photo.
|
||||
now: Aspects,
|
||||
/// The latest revision made by an op that changed more than photos of its own (an album, a stack, a keyword of
|
||||
/// the list…): what it changed for which photos is not known.
|
||||
broad: u64,
|
||||
/// Changes up to this revision are no longer all in `touched`.
|
||||
floor: u64,
|
||||
/// The revision the last op recorded here made. When the catalog's revision is another, it was set from
|
||||
/// outside (a library just loaded takes a range of its own, so that nothing kept from another library is
|
||||
/// taken for this one's): nothing is known of how the catalog got to where it is.
|
||||
seen: u64,
|
||||
}
|
||||
|
||||
impl PartialEq for Changes {
|
||||
/// Two catalogs are the same catalog whatever each remembers of how it got there.
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
@@ -365,13 +439,18 @@ impl Catalog {
|
||||
/// no smart album).
|
||||
pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool {
|
||||
match self.albums.get(&id) {
|
||||
// guarded: a smart album testing smart albums can't loop or recurse without end
|
||||
Some(Album { smart: Some(rules), .. }) => !p.deleted && rules::smart_album_holds(id, p.id, || rules.matches(p, self)),
|
||||
Some(a) => a.photos.contains(&p.id),
|
||||
Some(Album { smart: Some(rules), .. }) => self.smart_album_has(id, rules, &rules.prepare_one(), p),
|
||||
Some(a) => self.album_lists(a.id, p.id),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether smart album `id` with `rules` (as [`Filter::prepare`]d) holds `p`.
|
||||
fn smart_album_has(&self, id: AlbumId, rules: &Filter, prepared: &query::Prepared, p: &Photo) -> bool {
|
||||
// guarded: a smart album testing smart albums can't loop or recurse without end
|
||||
!p.deleted && rules::smart_album_holds(id, p.id, || rules.matches_in(p, self, prepared))
|
||||
}
|
||||
|
||||
/// Whether smart album `from` tests `to`, directly or through the smart albums it tests (the
|
||||
/// rules of `from` would then change when those of `to` do). Loops in saved rules end the
|
||||
/// search, they don't repeat it.
|
||||
@@ -419,7 +498,10 @@ impl Catalog {
|
||||
/// The photos of an album: the stored list, or a smart album's current matches (id order).
|
||||
pub fn album_photos(&self, id: AlbumId) -> Vec<PhotoId> {
|
||||
match self.albums.get(&id) {
|
||||
Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).map(|p| p.id).collect(),
|
||||
Some(Album { smart: Some(rules), .. }) => {
|
||||
let prepared = rules.prepare();
|
||||
self.photos.values().filter(|p| self.smart_album_has(id, rules, &prepared, p)).map(|p| p.id).collect()
|
||||
}
|
||||
Some(a) => a.photos.clone(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
@@ -429,7 +511,10 @@ impl Catalog {
|
||||
pub fn album_count(&self, id: AlbumId) -> usize {
|
||||
match self.albums.get(&id) {
|
||||
// counted in place: no id list is built just for its length
|
||||
Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).count(),
|
||||
Some(Album { smart: Some(rules), .. }) => {
|
||||
let prepared = rules.prepare();
|
||||
self.photos.values().filter(|p| self.smart_album_has(id, rules, &prepared, p)).count()
|
||||
}
|
||||
Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(),
|
||||
None => 0,
|
||||
}
|
||||
@@ -479,20 +564,110 @@ impl Catalog {
|
||||
// ---- writes
|
||||
|
||||
fn photo_mut(&mut self, id: PhotoId) -> Result<&mut Photo> {
|
||||
self.touch(id);
|
||||
self.photos.get_mut(&id).map(Arc::make_mut).ok_or(CatalogError::NoPhoto(id))
|
||||
}
|
||||
|
||||
/// The op being applied changes photo `id` (or adds or removes it).
|
||||
fn touch(&mut self, id: PhotoId) {
|
||||
// (the revision the op will make: `apply` counts up once it is done)
|
||||
self.changes.touched.push_back((self.revision.saturating_add(1), id, self.changes.now));
|
||||
while self.changes.touched.len() > MAX_TOUCHED {
|
||||
if let Some((revision, _, _)) = self.changes.touched.pop_front() {
|
||||
self.changes.floor = revision;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What of its photos the ops since `revision` changed, taken together ([`Aspects::NONE`] when there were
|
||||
/// none). `None` when that is not known ([`Catalog::touched_since`]). A tally of the library that reads only
|
||||
/// other things of a photo is still right.
|
||||
pub fn aspects_since(&self, revision: u64) -> Option<Aspects> {
|
||||
if !self.remembers(revision) {
|
||||
return None;
|
||||
}
|
||||
Some(self.changes.touched.iter().filter(|(r, _, _)| *r > revision).fold(Aspects::NONE, |all, (_, _, a)| all.with(*a)))
|
||||
}
|
||||
|
||||
/// Whether every change since the catalog was at `revision` is remembered photo by photo.
|
||||
fn remembers(&self, revision: u64) -> bool {
|
||||
self.changes.seen == self.revision && revision <= self.revision && revision >= self.changes.floor && self.changes.broad <= revision
|
||||
}
|
||||
|
||||
/// The photos changed, added or removed since the catalog was at `revision`, each once. `None` when that is not
|
||||
/// known: an op since then changed more than photos of its own, or it was too long ago. Then anything may
|
||||
/// have changed.
|
||||
pub fn touched_since(&self, revision: u64) -> Option<Vec<PhotoId>> {
|
||||
if !self.remembers(revision) {
|
||||
return None;
|
||||
}
|
||||
let mut ids: Vec<PhotoId> = self.changes.touched.iter().filter(|(r, _, _)| *r > revision).map(|(_, id, _)| *id).collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
Some(ids)
|
||||
}
|
||||
fn album_mut(&mut self, id: AlbumId) -> Result<&mut Album> {
|
||||
self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id))
|
||||
}
|
||||
|
||||
/// Apply an op; returns its inverse. On error nothing changes.
|
||||
pub fn apply(&mut self, op: Op) -> Result<Op> {
|
||||
if self.changes.seen != self.revision {
|
||||
// the revision was set from outside: what is remembered is of another past
|
||||
self.changes = Changes { floor: self.revision, seen: self.revision, ..Default::default() };
|
||||
}
|
||||
let inv = self.apply_inner(op)?;
|
||||
self.revision += 1;
|
||||
self.changes.seen = self.revision;
|
||||
Ok(inv)
|
||||
}
|
||||
|
||||
fn apply_inner(&mut self, op: Op) -> Result<Op> {
|
||||
// An op that changes one photo says which (`photo_mut`, and adding or removing it below). Every other kind
|
||||
// can change what any photo matches or where it sorts: an album's photos or rules, a stack, a keyword's
|
||||
// name. Listed kind by kind, so a new one has to be put on one side.
|
||||
match &op {
|
||||
// (a photo that comes, goes, or changes what it is to the library counts for every tally; so do the
|
||||
// rare kinds whose reach nobody has to think about)
|
||||
Op::AddPhoto { photo } => {
|
||||
self.changes.now = Aspects::ALL;
|
||||
self.touch(photo.id);
|
||||
}
|
||||
Op::RemovePhoto { id } => {
|
||||
self.changes.now = Aspects::ALL;
|
||||
self.touch(*id);
|
||||
}
|
||||
Op::SetDeleted { .. } | Op::SetLocal { .. } | Op::SetContent { .. } | Op::SetEmbeddedLens { .. } | Op::SetAsShotWb { .. } => {
|
||||
self.changes.now = Aspects::ALL
|
||||
}
|
||||
Op::SetRating { .. } => self.changes.now = Aspects::RATING,
|
||||
Op::SetFlag { .. } => self.changes.now = Aspects::FLAG,
|
||||
Op::SetLabel { .. } => self.changes.now = Aspects::LABEL,
|
||||
Op::SetMeta { .. } => self.changes.now = Aspects::META,
|
||||
Op::SetCaptured { .. } => self.changes.now = Aspects::CAPTURED,
|
||||
Op::SetFile { .. } | Op::Relink { .. } => self.changes.now = Aspects::FILE,
|
||||
Op::SetAnalysis { .. } => self.changes.now = Aspects::ANALYSIS,
|
||||
Op::SetDevelop { .. } | Op::SetVersions { .. } | Op::SetHistory { .. } | Op::PushHistory { .. } | Op::SetHistoryCursor { .. } => {
|
||||
self.changes.now = Aspects::DEVELOP
|
||||
}
|
||||
// (its ops say for themselves, one by one)
|
||||
Op::Batch { .. } => {}
|
||||
Op::AddAlbum { .. }
|
||||
| Op::RemoveAlbum { .. }
|
||||
| Op::RenameAlbum { .. }
|
||||
| Op::MoveAlbum { .. }
|
||||
| Op::SetAlbumOrder { .. }
|
||||
| Op::SetAlbumPhotos { .. }
|
||||
| Op::SetAlbumCover { .. }
|
||||
| Op::SetAlbumRules { .. }
|
||||
| Op::AddStack { .. }
|
||||
| Op::RemoveStack { .. }
|
||||
| Op::SetStack { .. }
|
||||
| Op::SetLabelName { .. }
|
||||
| Op::SetBrowsed { .. }
|
||||
| Op::SetKeyword { .. }
|
||||
| Op::SetFolderRecord { .. } => self.changes.broad = self.revision.saturating_add(1),
|
||||
}
|
||||
Ok(match op {
|
||||
Op::AddPhoto { photo } => {
|
||||
if self.photos.contains_key(&photo.id) {
|
||||
@@ -590,6 +765,7 @@ impl Catalog {
|
||||
}
|
||||
let id = album.id;
|
||||
self.next_album = self.next_album.max(id.0.saturating_add(1));
|
||||
self.index.album_changed(id, Some(&album.photos));
|
||||
self.albums.insert(id, album);
|
||||
Op::RemoveAlbum { id }
|
||||
}
|
||||
@@ -598,6 +774,7 @@ impl Catalog {
|
||||
return Err(CatalogError::Invalid("folder is not empty".into()));
|
||||
}
|
||||
let a = self.albums.remove(&id).ok_or(CatalogError::NoAlbum(id))?;
|
||||
self.index.album_changed(id, None);
|
||||
Op::AddAlbum { album: a }
|
||||
}
|
||||
Op::RenameAlbum { id, name } => {
|
||||
@@ -637,13 +814,16 @@ impl Catalog {
|
||||
Op::SetAlbumOrder { id, order: std::mem::replace(&mut a.order, order) }
|
||||
}
|
||||
Op::SetAlbumPhotos { id, photos } => {
|
||||
let a = self.album_mut(id)?;
|
||||
// the field, not `album_mut`: the index is updated while the album is borrowed
|
||||
let a = self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id))?;
|
||||
if (a.folder || a.smart.is_some()) && !photos.is_empty() {
|
||||
return Err(CatalogError::Invalid(
|
||||
if a.folder { "folders can't hold photos" } else { "smart albums update automatically" }.into(),
|
||||
));
|
||||
}
|
||||
Op::SetAlbumPhotos { id, photos: std::mem::replace(&mut a.photos, photos) }
|
||||
let old = std::mem::replace(&mut a.photos, photos);
|
||||
self.index.album_changed(id, Some(&a.photos));
|
||||
Op::SetAlbumPhotos { id, photos: old }
|
||||
}
|
||||
Op::SetAlbumCover { id, cover } => {
|
||||
let a = self.album_mut(id)?;
|
||||
@@ -664,11 +844,13 @@ impl Catalog {
|
||||
self.validate_stack(stack.id, &stack.photos)?;
|
||||
let id = stack.id;
|
||||
self.next_stack = self.next_stack.max(id.0.saturating_add(1));
|
||||
self.index.stack_changed(id, &[], &stack.photos);
|
||||
self.stacks.insert(id, stack);
|
||||
Op::RemoveStack { id }
|
||||
}
|
||||
Op::RemoveStack { id } => {
|
||||
let s = self.stacks.remove(&id).ok_or(CatalogError::NoStack(id))?;
|
||||
self.index.stack_changed(id, &s.photos, &[]);
|
||||
Op::AddStack { stack: s }
|
||||
}
|
||||
Op::SetStack { id, photos, collapsed } => {
|
||||
@@ -677,7 +859,9 @@ impl Catalog {
|
||||
}
|
||||
self.validate_stack(id, &photos)?;
|
||||
let s = self.stacks.get_mut(&id).ok_or(CatalogError::NoStack(id))?;
|
||||
let old = Op::SetStack { id, photos: std::mem::replace(&mut s.photos, photos), collapsed: s.collapsed };
|
||||
let old_photos = std::mem::replace(&mut s.photos, photos);
|
||||
self.index.stack_changed(id, &old_photos, &s.photos);
|
||||
let old = Op::SetStack { id, photos: old_photos, collapsed: s.collapsed };
|
||||
s.collapsed = collapsed;
|
||||
old
|
||||
}
|
||||
@@ -700,7 +884,7 @@ impl Catalog {
|
||||
let old_format = format.map(|f| std::mem::replace(&mut p.format, f));
|
||||
Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source), format: old_format }
|
||||
}
|
||||
Op::SetContent { id, width, height, file_size, content_hash, preview_only } => {
|
||||
Op::SetContent { id, width, height, file_size, content_hash, preview_only, preview_checked } => {
|
||||
let p = self.photo_mut(id)?;
|
||||
Op::SetContent {
|
||||
id,
|
||||
@@ -709,6 +893,7 @@ impl Catalog {
|
||||
file_size: std::mem::replace(&mut p.file_size, file_size),
|
||||
content_hash: std::mem::replace(&mut p.content_hash, content_hash),
|
||||
preview_only: std::mem::replace(&mut p.preview_only, preview_only),
|
||||
preview_checked: std::mem::replace(&mut p.preview_checked, preview_checked),
|
||||
}
|
||||
}
|
||||
Op::SetEmbeddedLens { id, lens } => {
|
||||
@@ -914,6 +1099,10 @@ impl Catalog {
|
||||
self.next_album = self.next_album.max(album);
|
||||
self.next_stack = self.next_stack.max(stack);
|
||||
}
|
||||
if !changed.is_empty() {
|
||||
// stacks and albums changed outside `apply`: the lookups are built again when asked
|
||||
self.index = index::Indexes::default();
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
@@ -963,6 +1152,8 @@ mod tests_folders;
|
||||
#[cfg(test)]
|
||||
mod tests_format_version;
|
||||
#[cfg(test)]
|
||||
mod tests_index;
|
||||
#[cfg(test)]
|
||||
mod tests_journal;
|
||||
#[cfg(test)]
|
||||
mod tests_local;
|
||||
@@ -971,4 +1162,6 @@ mod tests_lock;
|
||||
#[cfg(test)]
|
||||
mod tests_repair;
|
||||
#[cfg(test)]
|
||||
mod tests_scale;
|
||||
#[cfg(test)]
|
||||
mod tests_torn_append;
|
||||
@@ -183,15 +183,7 @@ impl Catalog {
|
||||
let mut plan = ForgetPlan { now: now.to_string(), ..Default::default() };
|
||||
let now_s = crate::stacks::iso_seconds(now);
|
||||
let max_age = i64::from(days) * 86_400;
|
||||
let mut referenced: HashSet<PhotoId> = HashSet::new();
|
||||
for a in self.albums.values() {
|
||||
referenced.extend(a.photos.iter().copied());
|
||||
referenced.extend(a.cover);
|
||||
}
|
||||
for s in self.stacks.values() {
|
||||
referenced.extend(s.photos.iter().copied());
|
||||
}
|
||||
referenced.extend(self.photos.values().filter_map(|p| p.copy_of));
|
||||
let referenced = self.referenced();
|
||||
// the fingerprints are the costly part: work them out for the candidates in parallel
|
||||
let candidates: Vec<&Photo> = self
|
||||
.photos
|
||||
@@ -237,6 +229,30 @@ impl Catalog {
|
||||
plan.unstamp = self.browsed.keys().filter(|f| !remaining.contains(*f)).cloned().collect();
|
||||
plan
|
||||
}
|
||||
|
||||
/// Of `ids`, the untouched Local records nothing refers to (no album, stack or virtual copy):
|
||||
/// the ones [`Catalog::forget_local_plan`] could forget, whatever their folder's time.
|
||||
pub fn forgettable_local(&self, ids: &[PhotoId]) -> Vec<PhotoId> {
|
||||
let referenced = self.referenced();
|
||||
let candidates: Vec<&Photo> =
|
||||
ids.iter().filter(|id| !referenced.contains(id)).filter_map(|id| self.photos.get(id)).filter(|p| p.local).map(|p| &**p).collect();
|
||||
let untouched = untouched_of(&candidates);
|
||||
ids.iter().copied().filter(|id| untouched.contains(id)).collect()
|
||||
}
|
||||
|
||||
/// Photos an album (as a member or its cover), a stack or a virtual copy refers to.
|
||||
fn referenced(&self) -> HashSet<PhotoId> {
|
||||
let mut referenced: HashSet<PhotoId> = HashSet::new();
|
||||
for a in self.albums.values() {
|
||||
referenced.extend(a.photos.iter().copied());
|
||||
referenced.extend(a.cover);
|
||||
}
|
||||
for s in self.stacks.values() {
|
||||
referenced.extend(s.photos.iter().copied());
|
||||
}
|
||||
referenced.extend(self.photos.values().filter_map(|p| p.copy_of));
|
||||
referenced
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder browsed at `at` is past the period (never when `days` is 0 or a time is unreadable).
|
||||
|
||||
@@ -199,6 +199,11 @@ pub struct Meta {
|
||||
/// 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>,
|
||||
/// Where the camera focused (read from the file's maker notes at import or Reload from Disk; Sony
|
||||
/// bodies whose files have been checked), on the upright (EXIF-oriented) photo. Read-only: never
|
||||
/// written to XMP. Left out of the catalog JSON when unknown, so older catalogs read unchanged.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub focus: Option<lightcraft_meta::FocusPoint>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -293,6 +298,12 @@ pub struct Photo {
|
||||
/// picture style baked in — so it is treated as a rendered (non-raw) source.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub preview_only: Option<String>,
|
||||
/// The app version (`CARGO_PKG_VERSION`) whose decoder set [`Photo::preview_only`]; empty when
|
||||
/// it isn't set, and in catalogs from before format v9. A preview-only photo whose stamp
|
||||
/// differs from the running version is rechecked when its library opens (issue #662): newer
|
||||
/// builds may decode the file, older ones may decode less.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub preview_checked: String,
|
||||
/// A Local record: the fingerprint of its state when the browse catalogued it, to tell
|
||||
/// whether the user changed anything since (see [`crate::local`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -318,7 +329,7 @@ pub struct Analysis {
|
||||
/// file's white balance is edited relative to its as-shot look or in Kelvin depends on the camera:
|
||||
/// [`relative_wb_camera`].
|
||||
pub fn relative_wb_format(format: &str) -> bool {
|
||||
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF"].iter().any(|f| format.eq_ignore_ascii_case(f))
|
||||
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF", "ERF"].iter().any(|f| format.eq_ignore_ascii_case(f))
|
||||
}
|
||||
|
||||
/// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed
|
||||
@@ -332,6 +343,16 @@ pub fn relative_wb_camera(format: &str, camera: &str) -> bool {
|
||||
relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none() && !lightcraft_raw::kelvin_presets::covers_camera(camera)
|
||||
}
|
||||
|
||||
/// This build's version: what a photo's [`Photo::preview_checked`] stamp is compared with. Every
|
||||
/// workspace crate shares one version, so it is the app's.
|
||||
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// The [`Photo::preview_checked`] stamp for a `preview_only` decided by this build: its version
|
||||
/// when the photo is preview-only, empty otherwise.
|
||||
pub fn preview_stamp(preview_only: &Option<String>) -> String {
|
||||
if preview_only.is_some() { APP_VERSION.to_string() } else { String::new() }
|
||||
}
|
||||
|
||||
impl Photo {
|
||||
/// The History step the settings are at (the cursor, clamped to the list; `None` = no history).
|
||||
pub fn history_index(&self) -> Option<usize> {
|
||||
@@ -371,9 +392,20 @@ impl Photo {
|
||||
import_look: None,
|
||||
analysis: None,
|
||||
preview_only: None,
|
||||
preview_checked: String::new(),
|
||||
local_baseline: None,
|
||||
}
|
||||
}
|
||||
/// Sets [`Photo::preview_only`] as this build decided it, with its stamp.
|
||||
pub fn set_preview_only(&mut self, preview_only: Option<String>) {
|
||||
self.preview_checked = preview_stamp(&preview_only);
|
||||
self.preview_only = preview_only;
|
||||
}
|
||||
/// A preview-only photo whose flag was set by another app version (or before stamps existed),
|
||||
/// so this build should probe its file again (issue #662).
|
||||
pub fn preview_only_stale(&self) -> bool {
|
||||
self.preview_only.is_some() && self.preview_checked != APP_VERSION
|
||||
}
|
||||
/// A raw file developed from its sensor data: not a rendered image, and not a raw shown from
|
||||
/// its embedded preview ([`Photo::preview_only`]).
|
||||
pub fn develops_raw(&self) -> bool {
|
||||
@@ -577,6 +609,7 @@ mod edited_tests {
|
||||
("a.cr2", "CR2", true),
|
||||
("a.pef", "pef", true),
|
||||
("a.orf", "ORF", true),
|
||||
("a.erf", "ERF", 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");
|
||||
@@ -609,6 +642,8 @@ mod edited_tests {
|
||||
("SONY ILCE-7RM4A", "ARW", false),
|
||||
("NIKON CORPORATION NIKON D850", "NEF", false),
|
||||
("Canon Canon EOS 5D Mark III", "CR2", false),
|
||||
("Canon Canon EOS R6m2", "CR3", false),
|
||||
("Canon Canon EOS R7", "CR3", true),
|
||||
("FUJIFILM X-T4", "RAF", false),
|
||||
("ILCE-7M3", "ARW", false),
|
||||
("SONY ILCE-7M2", "ARW", true),
|
||||
@@ -624,7 +659,11 @@ mod edited_tests {
|
||||
p.as_shot_wb = Some((4986.0, -2.0));
|
||||
assert_eq!(p.relative_wb(), relative, "{camera} {format}");
|
||||
assert_eq!(relative_wb_camera(format, camera), relative, "{camera} {format}");
|
||||
assert_eq!(p.fitted_look(), format == "ARW" || format == "NEF" || format == "CR2" || format == "RAF", "{camera} {format}");
|
||||
assert_eq!(
|
||||
p.fitted_look(),
|
||||
format == "ARW" || format == "NEF" || format == "CR2" || format == "CR3" || format == "RAF",
|
||||
"{camera} {format}"
|
||||
);
|
||||
let wb = p.camera_defaults().wb;
|
||||
assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (4986.0, -2.0) }, "{camera} {format}");
|
||||
assert_eq!(wb.scale, if relative { WbScale::Legacy } else { WbScale::Kelvin }, "{camera} {format}");
|
||||
|
||||
+106
-33
@@ -94,6 +94,27 @@ pub struct Sort {
|
||||
pub seed: u64,
|
||||
}
|
||||
|
||||
impl Sort {
|
||||
/// Which of two photos comes first in this order (never `Equal` for two photos: the id decides last).
|
||||
pub fn order(&self, a: &Photo, b: &Photo) -> std::cmp::Ordering {
|
||||
let o = match self.key {
|
||||
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 => cmp_name(&a.file_name, &b.file_name),
|
||||
SortKey::Rating => a.rating.cmp(&b.rating),
|
||||
SortKey::FileSize => a.file_size.cmp(&b.file_size),
|
||||
SortKey::Random => shuffle_rank(self.seed, a.id).cmp(&shuffle_rank(self.seed, b.id)),
|
||||
}
|
||||
// Ties (one import batch, a burst within a second, equal ratings) sort by file name, the
|
||||
// order cameras number frames, so the order doesn't depend on how ids were allocated;
|
||||
// the id only keeps it stable.
|
||||
.then_with(|| if self.key == SortKey::FileName { Equal } else { cmp_name(&a.file_name, &b.file_name) })
|
||||
.then(a.id.cmp(&b.id));
|
||||
if self.ascending { o } else { o.reverse() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Sort {
|
||||
fn default() -> Self {
|
||||
Sort { key: SortKey::CaptureDate, ascending: false, group: crate::GroupBy::Auto, seed: 0 }
|
||||
@@ -114,6 +135,12 @@ fn shuffle_rank(seed: u64, id: PhotoId) -> u64 {
|
||||
mix64(mix64(seed) ^ id.0)
|
||||
}
|
||||
|
||||
/// What [`Filter::prepare`] works out once for a pass over many photos.
|
||||
pub(crate) struct Prepared {
|
||||
root: Option<String>,
|
||||
only: Option<std::collections::HashSet<PhotoId>>,
|
||||
}
|
||||
|
||||
fn token_matches(p: &Photo, tok: &str) -> bool {
|
||||
let t = tok.to_lowercase();
|
||||
if let Some((field, val)) = t.split_once(':') {
|
||||
@@ -146,11 +173,18 @@ fn token_matches(p: &Photo, tok: &str) -> bool {
|
||||
"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,
|
||||
// Not a known field: the colon is part of a plain word ("10:30", "Re:").
|
||||
_ => text_matches(p, &t),
|
||||
};
|
||||
}
|
||||
text_matches(p, &t)
|
||||
}
|
||||
|
||||
/// Whether the lower-cased word `t` is in the file name, title, caption, camera, lens, location,
|
||||
/// format or a keyword of `p`.
|
||||
fn text_matches(p: &Photo, t: &str) -> bool {
|
||||
let hay = [&p.file_name, &p.meta.title, &p.meta.caption, &p.meta.camera, &p.meta.lens, &p.meta.location, &p.format];
|
||||
hay.iter().any(|h| h.to_lowercase().contains(&t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&t))
|
||||
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).
|
||||
@@ -236,7 +270,18 @@ impl Filter {
|
||||
}
|
||||
|
||||
pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool {
|
||||
self.matches_in(p, cat, self.library_root().as_deref())
|
||||
self.matches_in(p, cat, &self.prepare_one())
|
||||
}
|
||||
|
||||
/// [`Filter::prepare`] for testing one photo (the `only` ids are looked through, not hashed).
|
||||
pub(crate) fn prepare_one(&self) -> Prepared {
|
||||
Prepared { root: self.library_root(), only: None }
|
||||
}
|
||||
|
||||
/// What [`Filter::matches_in`] needs worked out once for a pass over many photos rather than
|
||||
/// once per photo: the library folder's identity and the `only` ids as a set.
|
||||
pub(crate) fn prepare(&self) -> Prepared {
|
||||
Prepared { root: self.library_root(), only: (!self.only.is_empty()).then(|| self.only.iter().copied().collect()) }
|
||||
}
|
||||
|
||||
/// [`Filter::library_folder`] as a folder identity ([`folder_key`]); `None` without a choice.
|
||||
@@ -246,9 +291,9 @@ impl Filter {
|
||||
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 {
|
||||
/// [`Filter::matches`] with what [`Filter::prepare`] works out done already, so a query over a
|
||||
/// big library does that once, not once per photo.
|
||||
pub(crate) fn matches_in(&self, p: &Photo, cat: &Catalog, prepared: &Prepared) -> bool {
|
||||
if p.deleted != self.deleted {
|
||||
return false;
|
||||
}
|
||||
@@ -287,7 +332,7 @@ impl Filter {
|
||||
if !self.labels.is_empty() && !p.label.is_some_and(|l| self.labels.contains(&l)) {
|
||||
return false;
|
||||
}
|
||||
if !self.only.is_empty() && !self.only.contains(&p.id) {
|
||||
if !self.only.is_empty() && !prepared.only.as_ref().map_or_else(|| self.only.contains(&p.id), |only| only.contains(&p.id)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(want) = &self.merged {
|
||||
@@ -327,7 +372,7 @@ impl Filter {
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(root) = root
|
||||
if let Some(root) = prepared.root.as_deref()
|
||||
&& !photo_in_root(p, root)
|
||||
{
|
||||
return false;
|
||||
@@ -369,38 +414,66 @@ pub fn cmp_name(a: &str, b: &str) -> std::cmp::Ordering {
|
||||
impl Catalog {
|
||||
/// Photos matching `filter`, in `sort` order (ties broken by file name, then id).
|
||||
pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec<PhotoId> {
|
||||
let root = filter.library_root();
|
||||
let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect();
|
||||
v.sort_by(|a, b| {
|
||||
let o = match sort.key {
|
||||
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 => cmp_name(&a.file_name, &b.file_name),
|
||||
SortKey::Rating => a.rating.cmp(&b.rating),
|
||||
SortKey::FileSize => a.file_size.cmp(&b.file_size),
|
||||
SortKey::Random => shuffle_rank(sort.seed, a.id).cmp(&shuffle_rank(sort.seed, b.id)),
|
||||
}
|
||||
// Ties (one import batch, a burst within a second, equal ratings) sort by file name, the
|
||||
// order cameras number frames, so the order doesn't depend on how ids were allocated;
|
||||
// the id only keeps it stable.
|
||||
.then_with(|| if sort.key == SortKey::FileName { Equal } else { cmp_name(&a.file_name, &b.file_name) })
|
||||
.then(a.id.cmp(&b.id));
|
||||
if sort.ascending { o } else { o.reverse() }
|
||||
});
|
||||
self.query_keeping(filter, sort, &std::collections::HashSet::new(), filter)
|
||||
}
|
||||
|
||||
/// [`Catalog::query`], and with them the photos of `keep` that still match `within`, each at its place in `sort`
|
||||
/// order: a view that holds the photos it showed when an edit makes them stop matching `filter` (a smart
|
||||
/// album's rules, the filter bar), as long as they are still where the view looks (`within`: in the library,
|
||||
/// the album, the folder; not deleted).
|
||||
pub fn query_keeping(&self, filter: &Filter, sort: &Sort, keep: &std::collections::HashSet<PhotoId>, within: &Filter) -> Vec<PhotoId> {
|
||||
let prepared = filter.prepare();
|
||||
// `within` only decides for the few kept photos: worked out per photo, not hashed
|
||||
let within_prepared = within.prepare_one();
|
||||
let mut v: Vec<&Photo> = self
|
||||
.photos()
|
||||
.map(|p| p.as_ref())
|
||||
.filter(|p| filter.matches_in(p, self, &prepared) || (keep.contains(&p.id) && within.matches_in(p, self, &within_prepared)))
|
||||
.collect();
|
||||
v.sort_by(|a, b| sort.order(a, b));
|
||||
v.into_iter().map(|p| p.id).collect()
|
||||
}
|
||||
|
||||
/// The list `was` (a [`Catalog::query`] of `filter` in `sort` order, or a [`Catalog::query_keeping`]), after ops
|
||||
/// that touched only the photos `touched` (sorted by id, each once: [`Catalog::touched_since`]): those photos
|
||||
/// taken out, and the ones that match `filter` now put back at their place in the order. With `within`, a
|
||||
/// touched photo that was in the list and still matches `within` stays, as `query_keeping` would keep it. What
|
||||
/// the query would give, without asking every photo again.
|
||||
pub fn follow(&self, was: &[PhotoId], touched: &[PhotoId], filter: &Filter, sort: &Sort, within: Option<&Filter>) -> Vec<PhotoId> {
|
||||
let listed: std::collections::HashSet<PhotoId> = was.iter().copied().filter(|id| touched.binary_search(id).is_ok()).collect();
|
||||
let mut ids: Vec<PhotoId> = was.iter().copied().filter(|id| !listed.contains(id)).collect();
|
||||
let (prepared, within_prepared) = (filter.prepare_one(), within.map(Filter::prepare_one));
|
||||
for id in touched {
|
||||
let Some(p) = self.photo(*id) else { continue };
|
||||
let kept = within.zip(within_prepared.as_ref()).is_some_and(|(w, wp)| listed.contains(id) && w.matches_in(p, self, wp));
|
||||
if filter.matches_in(p, self, &prepared) || kept {
|
||||
// (the list is in order without the touched photos, and stays so as each is put back)
|
||||
let at = ids.partition_point(|other| self.photo(*other).is_some_and(|o| sort.order(o, p).is_lt()));
|
||||
ids.insert(at, *id);
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// How many photos match `filter`: [`Catalog::query`]'s length without its sorting.
|
||||
pub fn count(&self, filter: &Filter) -> usize {
|
||||
let prepared = filter.prepare();
|
||||
self.photos().filter(|p| filter.matches_in(p, self, &prepared)).count()
|
||||
}
|
||||
|
||||
/// Year → month → day counts for the "By Date" section (newest first).
|
||||
pub fn date_groups(&self) -> Vec<DateGroup> {
|
||||
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 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;
|
||||
*days.entry(&d[..10]).or_default() += 1;
|
||||
// `get`, not `[..]`: a corrupt or foreign `captured` (a Lightroom catalog's
|
||||
// captureTime is copied verbatim) may not be ASCII, and slicing it on a byte index
|
||||
// would panic — the same rule as `dates::group_label`.
|
||||
if let (Some(year), Some(month), Some(day)) = (d.get(..4), d.get(..7), d.get(..10)) {
|
||||
let (months, days) = years.entry(year).or_default();
|
||||
*months.entry(month).or_default() += 1;
|
||||
*days.entry(day).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let newest_first = |m: BTreeMap<&str, usize>| m.into_iter().rev().map(|(k, n)| (k.to_string(), n)).collect::<Vec<_>>();
|
||||
@@ -441,8 +514,8 @@ impl Catalog {
|
||||
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 prepared = filter.prepare();
|
||||
for p in self.photos().filter(|p| filter.matches_in(p, self, &prepared)) {
|
||||
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 };
|
||||
|
||||
@@ -598,6 +598,35 @@ pub fn now() -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// A flag as rules name it (its variant, lower case), without building a string per photo.
|
||||
fn flag_name(f: crate::Flag) -> &'static str {
|
||||
match f {
|
||||
crate::Flag::None => "none",
|
||||
crate::Flag::Pick => "pick",
|
||||
crate::Flag::Reject => "reject",
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour label's colour as rules name it (lower case).
|
||||
fn label_colour(l: crate::ColorLabel) -> &'static str {
|
||||
match l {
|
||||
crate::ColorLabel::Red => "red",
|
||||
crate::ColorLabel::Yellow => "yellow",
|
||||
crate::ColorLabel::Green => "green",
|
||||
crate::ColorLabel::Blue => "blue",
|
||||
crate::ColorLabel::Purple => "purple",
|
||||
}
|
||||
}
|
||||
|
||||
/// A media kind as rules name it (lower case).
|
||||
fn kind_name(k: crate::MediaKind) -> &'static str {
|
||||
match k {
|
||||
crate::MediaKind::Image => "image",
|
||||
crate::MediaKind::Raw => "raw",
|
||||
crate::MediaKind::Video => "video",
|
||||
}
|
||||
}
|
||||
|
||||
fn lower(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.trim().to_lowercase(),
|
||||
@@ -778,16 +807,16 @@ impl Rule {
|
||||
match field {
|
||||
"rating" => num_op(op, Some(p.rating as f64), value),
|
||||
"flag" => {
|
||||
let f = format!("{:?}", p.flag).to_lowercase();
|
||||
let f = flag_name(p.flag);
|
||||
(f == want || (want == "picked" && f == "pick") || (want == "rejected" && f == "reject")) == (op == "is")
|
||||
}
|
||||
"label" => {
|
||||
let l = p.label.map(|l| format!("{l:?}").to_lowercase()).unwrap_or_else(|| "none".into());
|
||||
let l = p.label.map_or("none", label_colour);
|
||||
// custom label names count too
|
||||
let named = p.label.is_some_and(|l| cat.label_name(l).to_lowercase() == want);
|
||||
let named = p.label.is_some_and(|l| cat.custom_label_name(l).map_or(label_colour(l) == want, |n| n.to_lowercase() == want));
|
||||
(l == want || named) == (op == "is")
|
||||
}
|
||||
"kind" => (format!("{:?}", p.kind).to_lowercase() == want) == (op == "is"),
|
||||
"kind" => (kind_name(p.kind) == want) == (op == "is"),
|
||||
"edited" => yes(p.is_edited()),
|
||||
"hasGps" => yes(m.gps.is_some()),
|
||||
"virtualCopy" => yes(p.copy_of.is_some()),
|
||||
|
||||
@@ -21,7 +21,7 @@ impl Catalog {
|
||||
}
|
||||
/// The stack a photo belongs to.
|
||||
pub fn stack_of(&self, photo: PhotoId) -> Option<&Stack> {
|
||||
self.stacks.values().find(|s| s.photos.contains(&photo))
|
||||
self.stack_id_of(photo).and_then(|id| self.stacks.get(&id))
|
||||
}
|
||||
/// Photo → (stack, position in the stack), for every stacked photo.
|
||||
pub fn stack_index(&self) -> HashMap<PhotoId, (StackId, usize)> {
|
||||
@@ -41,7 +41,7 @@ impl Catalog {
|
||||
return Err(CatalogError::NoPhoto(*p));
|
||||
}
|
||||
}
|
||||
if self.stacks.values().any(|s| s.id != id && s.photos.iter().any(|p| seen.contains(p))) {
|
||||
if photos.iter().any(|p| self.stack_id_of(*p).is_some_and(|s| s != id)) {
|
||||
return Err(CatalogError::Invalid("a photo can be in only one stack".into()));
|
||||
}
|
||||
Ok(())
|
||||
@@ -231,6 +231,60 @@ impl Catalog {
|
||||
}
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// The bracket sets among `ids`, an HDR merge each: every stack one of them is in (its photos, less earlier merge
|
||||
/// results), then the unstacked ones in capture order, a set running while each starts within about a second of the
|
||||
/// previous one's end (its shutter time; capture times are whole seconds). A run is a set when its exposures span at
|
||||
/// least ½ EV: a burst at one exposure is no bracket. Photos without a capture time or shutter speed are left out.
|
||||
pub fn bracket_sets(&self, ids: &[PhotoId]) -> Vec<Vec<PhotoId>> {
|
||||
let merged = |p: &crate::Photo| crate::query::merged_kind(&p.file_name).is_some();
|
||||
let mut sets: Vec<Vec<PhotoId>> = Vec::new();
|
||||
let mut stacks = HashSet::new();
|
||||
let mut loose = Vec::new();
|
||||
for id in ids {
|
||||
match self.stack_of(*id) {
|
||||
Some(s) if stacks.insert(s.id) => {
|
||||
let set: Vec<PhotoId> = s.photos.iter().copied().filter(|p| self.photos.get(p).is_some_and(|p| !merged(p))).collect();
|
||||
if set.len() >= 2 {
|
||||
sets.push(set);
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
None => loose.push(*id),
|
||||
}
|
||||
}
|
||||
// (capture second, file name, id, shutter seconds, EV)
|
||||
let mut timed: Vec<(i64, &str, PhotoId, f64, f64)> = loose
|
||||
.iter()
|
||||
.filter_map(|id| self.photos.get(id))
|
||||
.filter(|p| !p.deleted && !merged(p))
|
||||
.filter_map(|p| {
|
||||
let t = iso_seconds(p.captured.as_deref()?)?;
|
||||
let s = crate::model::parse_shutter_seconds(&p.meta.shutter)?;
|
||||
let n = p.meta.aperture.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0) as f64;
|
||||
let iso = p.meta.iso.filter(|i| *i > 0).unwrap_or(100) as f64;
|
||||
Some((t, p.file_name.as_str(), p.id, s, (s * iso / (n * n)).log2()))
|
||||
})
|
||||
.collect();
|
||||
timed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| cmp_name(a.1, b.1)).then(a.2.cmp(&b.2)));
|
||||
timed.dedup_by_key(|x| x.2);
|
||||
let mut runs: Vec<Vec<(i64, &str, PhotoId, f64, f64)>> = Vec::new();
|
||||
for x in timed {
|
||||
match runs.last_mut() {
|
||||
Some(r) if r.last().is_some_and(|l| (x.0 - l.0) as f64 <= l.3.ceil() + 1.0) => r.push(x),
|
||||
_ => runs.push(vec![x]),
|
||||
}
|
||||
}
|
||||
for r in runs {
|
||||
let (lo, hi) = r.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| (lo.min(x.4), hi.max(x.4)));
|
||||
if r.len() >= 2 && hi - lo >= 0.5 {
|
||||
sets.push(r.iter().map(|x| x.2).collect());
|
||||
}
|
||||
}
|
||||
sets
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds since 1970-01-01 for an ISO 8601 local time (`2026-04-01T10:00:00`, `2026-04-01 10:00`,
|
||||
/// `2026-04-01`; fractions and zone suffixes are ignored). `None` if it doesn't parse.
|
||||
pub fn iso_seconds(s: &str) -> Option<i64> {
|
||||
@@ -240,7 +294,8 @@ pub fn iso_seconds(s: &str) -> Option<i64> {
|
||||
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
|
||||
return None;
|
||||
}
|
||||
let (hh, mm, ss) = if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, num(17, 19).unwrap_or(0)) } else { (0, 0, 0) };
|
||||
let (hh, mm, ss) =
|
||||
if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, if s.get(16..17) == Some(":") { num(17, 19).unwrap_or(0) } else { 0 }) } else { (0, 0, 0) };
|
||||
// days from civil (proleptic Gregorian)
|
||||
let y2 = if m <= 2 { y - 1 } else { y };
|
||||
let era = y2.div_euclid(400);
|
||||
@@ -358,6 +413,46 @@ mod tests {
|
||||
assert_eq!(back.clone().alloc_stack_id(), c.clone().alloc_stack_id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bracket_sets_by_time_and_exposure() {
|
||||
// (time, shutter, ISO, file name)
|
||||
let shots = [
|
||||
("2026-08-01T09:20:36", "1/125", 640, "A1.ARW"), // a handheld bracket
|
||||
("2026-08-01T09:20:36", "1/125", 200, "A2.ARW"),
|
||||
("2026-08-01T09:20:37", "1/125", 2000, "A3.ARW"),
|
||||
("2026-08-01T09:20:37", "", 0, "A3-HDR.dng"), // an earlier merge of it
|
||||
("2026-08-01T09:20:44", "1/125", 800, "B1.ARW"), // 7 s later: the next bracket
|
||||
("2026-08-01T09:20:44", "1/125", 250, "B2.ARW"),
|
||||
("2026-08-01T09:20:50", "1/1000", 160, "C1.ARW"), // a burst at one exposure
|
||||
("2026-08-01T09:20:50", "1/1000", 160, "C2.ARW"),
|
||||
("2026-06-27T23:34:59", "2", 320, "N1.ARW"), // 2 s night frames, 2.1 s apart
|
||||
("2026-06-27T23:35:01", "2", 100, "N2.ARW"),
|
||||
("2026-06-27T23:35:04", "2", 800, "N3.ARW"),
|
||||
("2026-06-27T23:36:00", "1/40", 250, "S1.ARW"), // stacked by hand: merged whatever its times
|
||||
("2026-06-27T23:39:00", "1/40", 250, "S2.ARW"),
|
||||
];
|
||||
let mut c = Catalog::new();
|
||||
let mut ids = Vec::new();
|
||||
for (t, sh, iso, name) in shots {
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 60, 40, "2026-01-01T00:00:00");
|
||||
p.captured = Some(t.into());
|
||||
p.meta.shutter = sh.into();
|
||||
p.meta.iso = Some(iso);
|
||||
p.meta.aperture = Some(5.6);
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
let op = c.group_ops(ids[11], &[ids[12]], true).unwrap();
|
||||
c.apply(op).unwrap();
|
||||
let sets = c.bracket_sets(&ids);
|
||||
assert_eq!(sets, vec![vec![ids[11], ids[12]], vec![ids[8], ids[9], ids[10]], vec![ids[0], ids[1], ids[2]], vec![ids[4], ids[5]]]);
|
||||
// a stack's merge result on top is left out; a stack selected twice is merged once
|
||||
let op = c.stack_with_ops(ids[3], &[ids[0], ids[1], ids[2]]).unwrap();
|
||||
c.apply(op).unwrap();
|
||||
assert_eq!(c.bracket_sets(&[ids[3], ids[0]]), vec![vec![ids[0], ids[1], ids[2]]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_stack_by_capture_time() {
|
||||
let times = ["2026-04-01T10:00:00", "2026-04-01T10:00:02", "2026-04-01T10:00:09", "2026-04-01T11:00:00", "2026-04-01T11:00:01"];
|
||||
|
||||
@@ -73,6 +73,16 @@ impl FsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// A new staging file at `tmp`: whatever is already there (a leftover from a crashed save, or a link) is removed first
|
||||
/// (a link itself, never what it points to), then the file is created exclusively, so a write never follows a link
|
||||
/// planted at that name into another file (issue #1502).
|
||||
fn create_staging(tmp: &std::path::Path) -> io::Result<std::fs::File> {
|
||||
if std::fs::symlink_metadata(tmp).is_ok() {
|
||||
std::fs::remove_file(tmp)?;
|
||||
}
|
||||
std::fs::OpenOptions::new().write(true).create_new(true).open(tmp)
|
||||
}
|
||||
|
||||
impl Store for FsStore {
|
||||
fn read(&mut self, name: &str) -> io::Result<Option<Vec<u8>>> {
|
||||
match std::fs::read(self.path(name)) {
|
||||
@@ -89,7 +99,7 @@ impl Store for FsStore {
|
||||
}
|
||||
let tmp = self.path(&format!("{name}.tmp"));
|
||||
{
|
||||
let mut f = std::fs::File::create(&tmp)?;
|
||||
let mut f = create_staging(&tmp)?;
|
||||
f.write_all(data)?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
@@ -105,7 +115,7 @@ impl Store for FsStore {
|
||||
}
|
||||
let tmp = self.path(&format!("{name}.tmp"));
|
||||
let written = (|| {
|
||||
let mut w = Counter { inner: io::BufWriter::with_capacity(1 << 20, std::fs::File::create(&tmp)?), n: 0 };
|
||||
let mut w = Counter { inner: io::BufWriter::with_capacity(1 << 20, create_staging(&tmp)?), n: 0 };
|
||||
fill(&mut w)?;
|
||||
w.flush()?;
|
||||
let f = w.inner.into_inner().map_err(|e| e.into_error())?;
|
||||
@@ -231,3 +241,29 @@ impl Store for MemStore {
|
||||
self.background.then(|| Box::new(self.clone()) as Box<dyn Store>)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_staging {
|
||||
use super::*;
|
||||
|
||||
/// A link planted at a staging name (`<name>.tmp`) is removed, never followed: the file it points to keeps its
|
||||
/// bytes and the save still lands (issue #1502).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_link_at_the_staging_name_is_never_written_through() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-staging-link-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let mut store = FsStore::open(&dir).unwrap();
|
||||
let victim = dir.join("victim.txt");
|
||||
std::fs::write(&victim, b"keep").unwrap();
|
||||
std::os::unix::fs::symlink(&victim, dir.join("catalog.snap.tmp")).unwrap();
|
||||
store.write_atomic("catalog.snap", b"{}").unwrap();
|
||||
assert_eq!(std::fs::read(&victim).unwrap(), b"keep");
|
||||
assert_eq!(std::fs::read(dir.join("catalog.snap")).unwrap(), b"{}");
|
||||
std::os::unix::fs::symlink(&victim, dir.join("catalog.log.tmp")).unwrap();
|
||||
store.write_atomic_with("catalog.log", &mut |w| w.write_all(b"[1]")).unwrap();
|
||||
assert_eq!(std::fs::read(&victim).unwrap(), b"keep");
|
||||
assert_eq!(std::fs::read(dir.join("catalog.log")).unwrap(), b"[1]");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! How many photos each album shows, kept up to date from what the latest ops touched.
|
||||
//!
|
||||
//! A smart album's count asks every photo whether its rules hold. Asked again after every edit, that is a pass over
|
||||
//! the library for each smart album each time a keyword is given. [`AlbumTally`] remembers which photos each smart
|
||||
//! album holds, and after a small edit asks only the photos the edit touched ([`Catalog::touched_since`]). An op
|
||||
//! that changes more than photos of its own (an album's rules or photos, a stack, the keyword list) has it counted
|
||||
//! whole again, as before.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::{AlbumId, Aspects, Catalog, PhotoId};
|
||||
|
||||
/// The most photos an edit may touch for the counts to follow it photo by photo.
|
||||
const MAX_FOLLOWED: usize = 256;
|
||||
/// Bits are kept while they cost no more than this many words a photo held, and these to start with. Past that
|
||||
/// (photo ids far apart: a library whose ids don't count up from 1) a set is kept instead, so what an album
|
||||
/// remembers is always in proportion to the photos it holds, never to the largest id in the library.
|
||||
const WORDS_A_MEMBER: usize = 4;
|
||||
const WORDS_TO_START: usize = 1024;
|
||||
|
||||
/// The photos a smart album holds.
|
||||
#[derive(Clone, Debug)]
|
||||
enum Members {
|
||||
/// One bit a photo id, and how many are set.
|
||||
Bits {
|
||||
words: Vec<u64>,
|
||||
len: usize,
|
||||
},
|
||||
Set(HashSet<PhotoId>),
|
||||
}
|
||||
|
||||
impl Members {
|
||||
fn none() -> Members {
|
||||
Members::Bits { words: Vec::new(), len: 0 }
|
||||
}
|
||||
|
||||
fn len(&self) -> usize {
|
||||
match self {
|
||||
Members::Bits { len, .. } => *len,
|
||||
Members::Set(set) => set.len(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn holds(&self, id: PhotoId) -> bool {
|
||||
match self {
|
||||
Members::Bits { words, .. } => {
|
||||
usize::try_from(id.0 / 64).ok().and_then(|at| words.get(at)).is_some_and(|word| word & (1u64 << (id.0 % 64)) != 0)
|
||||
}
|
||||
Members::Set(set) => set.contains(&id),
|
||||
}
|
||||
}
|
||||
|
||||
/// Put `id` in or take it out.
|
||||
fn set(&mut self, id: PhotoId, held: bool) {
|
||||
if let Members::Bits { words, len } = self {
|
||||
let bit = 1u64 << (id.0 % 64);
|
||||
match usize::try_from(id.0 / 64) {
|
||||
Ok(at) if at < words.len() => {
|
||||
if let Some(word) = words.get_mut(at) {
|
||||
match (*word & bit != 0, held) {
|
||||
(false, true) => {
|
||||
*word |= bit;
|
||||
*len = len.saturating_add(1);
|
||||
}
|
||||
(true, false) => {
|
||||
*word &= !bit;
|
||||
*len = len.saturating_sub(1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// (past the end: not held, so there is nothing to take out)
|
||||
_ if !held => return,
|
||||
// room for it, as long as the bits stay in proportion to the photos held
|
||||
Ok(at) if at < len.saturating_mul(WORDS_A_MEMBER).saturating_add(WORDS_TO_START) => {
|
||||
words.resize(at + 1, 0);
|
||||
if let Some(word) = words.get_mut(at) {
|
||||
*word |= bit;
|
||||
*len = len.saturating_add(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ids too far apart for bits: a set from here on
|
||||
_ => {
|
||||
let ids = words
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(at, word)| (0..64u64).filter(move |b| word & (1u64 << b) != 0).map(move |b| PhotoId(at as u64 * 64 + b)));
|
||||
*self = Members::Set(ids.collect());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Members::Set(set) = self {
|
||||
if held {
|
||||
set.insert(id);
|
||||
} else {
|
||||
set.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Catalog::album_count`] of every album, as of one revision of the catalog.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AlbumTally {
|
||||
revision: u64,
|
||||
counts: HashMap<AlbumId, usize>,
|
||||
/// The smart albums' photos.
|
||||
members: HashMap<AlbumId, Members>,
|
||||
}
|
||||
|
||||
impl AlbumTally {
|
||||
/// Counted from every photo.
|
||||
pub fn of(cat: &Catalog) -> AlbumTally {
|
||||
let mut counts = HashMap::new();
|
||||
let mut members = HashMap::new();
|
||||
for album in cat.albums() {
|
||||
if album.is_smart() {
|
||||
let mut held = Members::none();
|
||||
for p in cat.photos().filter(|p| cat.album_contains(album.id, p)) {
|
||||
held.set(p.id, true);
|
||||
}
|
||||
counts.insert(album.id, held.len());
|
||||
members.insert(album.id, held);
|
||||
} else {
|
||||
counts.insert(album.id, cat.album_count(album.id));
|
||||
}
|
||||
}
|
||||
AlbumTally { revision: cat.revision, counts, members }
|
||||
}
|
||||
|
||||
/// The count of each album.
|
||||
pub fn counts(&self) -> &HashMap<AlbumId, usize> {
|
||||
&self.counts
|
||||
}
|
||||
|
||||
/// The catalog's revision the counts are of.
|
||||
pub fn revision(&self) -> u64 {
|
||||
self.revision
|
||||
}
|
||||
|
||||
/// Bring the counts up to `cat` as it is now. `true` when that was done from the photos the ops since touched
|
||||
/// (or nothing had changed), `false` when every photo was asked again.
|
||||
///
|
||||
/// `cat` has to be the catalog the counts were made of, further along. Another catalog is told apart only by
|
||||
/// its revisions: a library that is loaded takes a range of its own (and then everything is counted again), but
|
||||
/// two catalogs both counting from 0 look like one.
|
||||
pub fn update(&mut self, cat: &Catalog) -> bool {
|
||||
if self.revision == cat.revision {
|
||||
return true;
|
||||
}
|
||||
let followed = match (cat.touched_since(self.revision), cat.aspects_since(self.revision)) {
|
||||
(Some(touched), Some(changed)) if touched.len() <= MAX_FOLLOWED => {
|
||||
self.follow(cat, &touched, changed);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if !followed {
|
||||
*self = AlbumTally::of(cat);
|
||||
}
|
||||
followed
|
||||
}
|
||||
|
||||
/// The counts after ops that touched only `touched`: each smart album asks those photos again; an album of
|
||||
/// chosen photos counts again only when a photo came, went or was deleted (who is in it did not change: that
|
||||
/// is an op on the album, and not followed).
|
||||
fn follow(&mut self, cat: &Catalog, touched: &[PhotoId], changed: Aspects) {
|
||||
for (album, held) in &mut self.members {
|
||||
for id in touched {
|
||||
held.set(*id, cat.photo(*id).is_some_and(|p| cat.album_contains(*album, p)));
|
||||
}
|
||||
self.counts.insert(*album, held.len());
|
||||
}
|
||||
if changed.any_of(Aspects::PRESENCE) {
|
||||
for album in cat.albums().filter(|a| !a.is_smart()) {
|
||||
self.counts.insert(album.id, cat.album_count(album.id));
|
||||
}
|
||||
}
|
||||
self.revision = cat.revision;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ids(m: &Members, of: &[u64]) -> Vec<u64> {
|
||||
of.iter().copied().filter(|id| m.holds(PhotoId(*id))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_are_kept_as_bits_at_the_edges_of_a_word_and_past_the_end() {
|
||||
let mut m = Members::none();
|
||||
let probe = [0, 1, 63, 64, 65, 127, 128, 5000];
|
||||
assert!(ids(&m, &probe).is_empty() && m.len() == 0);
|
||||
for id in [0, 63, 64, 5000] {
|
||||
m.set(PhotoId(id), true);
|
||||
}
|
||||
assert_eq!((ids(&m, &probe), m.len()), (vec![0, 63, 64, 5000], 4));
|
||||
// put in twice, taken out twice, taken out where nothing is, far past the end: counted once each way
|
||||
m.set(PhotoId(63), true);
|
||||
m.set(PhotoId(64), false);
|
||||
m.set(PhotoId(64), false);
|
||||
m.set(PhotoId(1), false);
|
||||
m.set(PhotoId(900_000), false);
|
||||
assert_eq!((ids(&m, &probe), m.len()), (vec![0, 63, 5000], 3));
|
||||
assert!(matches!(m, Members::Bits { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photo_ids_far_apart_are_kept_as_a_set_in_proportion_to_the_photos_held() {
|
||||
// one photo with an id in the thousands of millions: no bit for every id up to it
|
||||
let mut m = Members::none();
|
||||
for id in [3, 70, 9_000_000_000] {
|
||||
m.set(PhotoId(id), true);
|
||||
}
|
||||
assert!(matches!(m, Members::Set(_)), "a set once the ids are too far apart for bits");
|
||||
assert_eq!((ids(&m, &[3, 70, 71, 9_000_000_000, u64::MAX]), m.len()), (vec![3, 70, 9_000_000_000], 3));
|
||||
m.set(PhotoId(u64::MAX), true);
|
||||
m.set(PhotoId(3), false);
|
||||
assert_eq!((ids(&m, &[3, 70, 9_000_000_000, u64::MAX]), m.len()), (vec![70, 9_000_000_000, u64::MAX], 3));
|
||||
// an album of many photos with ids counting up stays bits, however many
|
||||
let mut dense = Members::none();
|
||||
for id in 1..=200_000u64 {
|
||||
dense.set(PhotoId(id), true);
|
||||
}
|
||||
assert!(matches!(&dense, Members::Bits { words, .. } if words.len() <= 200_000 / 64 + 1));
|
||||
assert_eq!(dense.len(), 200_000);
|
||||
}
|
||||
}
|
||||
+157
-2
@@ -123,6 +123,44 @@ fn filter_search_sort() {
|
||||
assert_eq!(c.keywords(), vec![("ocean".to_string(), 1), ("summer".to_string(), 1)]);
|
||||
}
|
||||
|
||||
/// A search word with a colon whose left part is not a known field ("10:30", "Re:") is plain
|
||||
/// text, not a fielded token that matches nothing.
|
||||
#[test]
|
||||
fn free_text_with_a_colon_searches_the_text_fields() {
|
||||
let mut c = Catalog::new();
|
||||
let a = photo(&mut c, "meeting.jpg", "2026-05-01T10:00:00");
|
||||
let _b = photo(&mut c, "other.jpg", "2026-04-01T10:00:00");
|
||||
let mut m = c.photo(a).unwrap().meta.clone();
|
||||
m.caption = "Meeting at 10:30 with Re: notes".into();
|
||||
m.iso = Some(1600);
|
||||
c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap();
|
||||
let q = |text: &str| c.query(&Filter { text: text.into(), ..Default::default() }, &Sort::default());
|
||||
assert_eq!(q("meeting"), vec![a]);
|
||||
assert_eq!(q("10:30"), vec![a], "a free-text token with a colon");
|
||||
assert_eq!(q("re:"), vec![a]);
|
||||
assert_eq!(q("RE: notes"), vec![a], "case-insensitive, one token per word");
|
||||
assert_eq!(q("iso:>800"), vec![a], "known fields stay fielded");
|
||||
assert_eq!(q("iso:>2000"), Vec::<PhotoId>::new());
|
||||
assert_eq!(q("11:45"), Vec::<PhotoId>::new(), "an unknown field matching no text finds nothing");
|
||||
}
|
||||
|
||||
/// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an
|
||||
/// `AddPhoto` op verbatim, and a JSON snapshot is valid UTF-8 whatever it holds) must not be
|
||||
/// sliced on a byte index in `date_groups`: grouping counts the well-formed dates and ignores
|
||||
/// the rest instead of panicking on a char boundary.
|
||||
#[test]
|
||||
fn date_groups_ignores_a_non_ascii_captured() {
|
||||
let mut c = Catalog::new();
|
||||
let ok = photo(&mut c, "ok.jpg", "2026-05-01T10:00:00");
|
||||
// `AddPhoto` takes the field as it is, like `library.importLightroom` building the op
|
||||
let bad = photo(&mut c, "bad.jpg", "日本語日本語");
|
||||
assert_ne!(ok, bad);
|
||||
let g = c.date_groups();
|
||||
assert_eq!(g.len(), 1, "the one well-formed date still groups");
|
||||
assert_eq!((g[0].year.as_str(), g[0].count), ("2026", 1));
|
||||
assert_eq!(g[0].days.iter().map(|d| d.1).sum::<usize>(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_and_log_replay() {
|
||||
let mut c = Catalog::new();
|
||||
@@ -265,13 +303,30 @@ fn preview_only_survives_serde_and_set_content_undo() {
|
||||
let old: Catalog = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(old.photo(a).unwrap().preview_only, None);
|
||||
assert!(old.photo(a).unwrap().develops_raw());
|
||||
// Reload: the file decodes now → cleared; undo (through the journal's JSON) restores it
|
||||
let op = Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None };
|
||||
// a newer build still can't decode it: the reason and the stamp of the version that decided
|
||||
// it are recorded together (#662)
|
||||
let op = Op::SetContent {
|
||||
id: a,
|
||||
width: 6000,
|
||||
height: 4000,
|
||||
file_size: 1,
|
||||
content_hash: None,
|
||||
preview_only: Some("Nikon Huffman-compressed NEF".into()),
|
||||
preview_checked: "0.6.0".into(),
|
||||
};
|
||||
c.apply(op).unwrap();
|
||||
assert_eq!(c.photo(a).unwrap().preview_checked, "0.6.0");
|
||||
// Reload: the file decodes now → cleared, with its stamp; undo (through the journal's JSON)
|
||||
// restores both
|
||||
let op =
|
||||
Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None, preview_checked: String::new() };
|
||||
let inv = c.apply(op).unwrap();
|
||||
assert!(c.photo(a).unwrap().develops_raw());
|
||||
assert_eq!(c.photo(a).unwrap().preview_checked, "");
|
||||
let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap();
|
||||
c.apply(inv).unwrap();
|
||||
assert_eq!(c.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF"));
|
||||
assert_eq!(c.photo(a).unwrap().preview_checked, "0.6.0");
|
||||
assert_eq!(c.photo(a).unwrap().width, 6000);
|
||||
}
|
||||
|
||||
@@ -391,6 +446,28 @@ fn empty_regions_are_not_serialized_and_default_when_missing() {
|
||||
assert_eq!(back, with);
|
||||
}
|
||||
|
||||
/// The camera's focus point is optional camera data: absent from the catalog JSON when unknown (so a
|
||||
/// catalog from before it existed reads with none, and writes the same), kept exactly when known.
|
||||
#[test]
|
||||
fn focus_point_is_not_serialized_when_unknown_and_round_trips() {
|
||||
let m = Meta { camera: "SONY ILCE-1".into(), ..Default::default() };
|
||||
let v = serde_json::to_value(&m).unwrap();
|
||||
assert!(v.get("focus").is_none(), "{v}");
|
||||
let back: Meta = serde_json::from_value(v).unwrap();
|
||||
assert!(back.focus.is_none());
|
||||
let mut with = m.clone();
|
||||
with.focus = Some(lightcraft_meta::FocusPoint {
|
||||
rect: lightcraft_meta::Rect { x0: 0.36, y0: 0.33, x1: 0.38, y1: 0.36 },
|
||||
mode: Some("AF-C".into()),
|
||||
area: Some("Wide".into()),
|
||||
tracking: None,
|
||||
});
|
||||
let json = serde_json::to_string(&with).unwrap();
|
||||
assert!(json.contains("\"focus\"") && !json.contains("tracking"), "{json}");
|
||||
let back: Meta = serde_json::from_str(&json).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> {
|
||||
@@ -737,3 +814,81 @@ fn cmp_name_ignores_case_without_allocating() {
|
||||
assert_eq!(cmp_name("DSC_0001", "DSC_0002"), Less);
|
||||
assert_eq!(cmp_name("a", "ab"), Less);
|
||||
}
|
||||
|
||||
/// What the catalog remembers of its latest ops: which photos each changed and what of them, as long as that can be
|
||||
/// said photo by photo.
|
||||
#[test]
|
||||
fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() {
|
||||
let mut c = Catalog::default();
|
||||
let photo = |c: &mut Catalog, name: &str| {
|
||||
let id = c.alloc_photo_id();
|
||||
c.apply(Op::AddPhoto {
|
||||
photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{name}") }, name, "JPEG", 60, 40, "2026-01-01T10:00:00")),
|
||||
})
|
||||
.unwrap();
|
||||
id
|
||||
};
|
||||
let (a, b) = (photo(&mut c, "a.jpg"), photo(&mut c, "b.jpg"));
|
||||
let start = c.revision;
|
||||
assert_eq!(c.touched_since(start), Some(vec![]));
|
||||
assert_eq!(c.aspects_since(start), Some(Aspects::NONE));
|
||||
// a rating, then a flag on another photo, as one batch
|
||||
c.apply(Op::SetRating { id: a, rating: 3 }).unwrap();
|
||||
assert_eq!(c.touched_since(start), Some(vec![a]));
|
||||
assert_eq!(c.aspects_since(start), Some(Aspects::RATING));
|
||||
let after_rating = c.revision;
|
||||
c.apply(Op::Batch { ops: vec![Op::SetFlag { id: b, flag: Flag::Pick }, Op::SetRating { id: a, rating: 4 }] }).unwrap();
|
||||
assert_eq!(c.touched_since(start), Some(vec![a, b]), "each once");
|
||||
assert_eq!(c.touched_since(after_rating), Some(vec![a, b]));
|
||||
assert_eq!(c.aspects_since(after_rating), Some(Aspects::FLAG.with(Aspects::RATING)));
|
||||
assert_eq!(c.touched_since(c.revision), Some(vec![]));
|
||||
// a photo that goes counts for everything
|
||||
let before = c.revision;
|
||||
c.apply(Op::RemovePhoto { id: b }).unwrap();
|
||||
assert_eq!(c.touched_since(before), Some(vec![b]));
|
||||
assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::PRESENCE) && x.any_of(Aspects::FILE)));
|
||||
// an op that changes more than photos of its own: nothing is said of what came before it
|
||||
let before = c.revision;
|
||||
let album = c.alloc_album_id();
|
||||
c.apply(Op::AddAlbum { album: Album::new(album, "Trip") }).unwrap();
|
||||
assert_eq!(c.touched_since(before), None);
|
||||
assert_eq!(c.aspects_since(start), None);
|
||||
assert_eq!(c.touched_since(c.revision), Some(vec![]), "from after it, it is known again");
|
||||
// a revision that is not this catalog's past
|
||||
assert_eq!(c.touched_since(c.revision + 5), None);
|
||||
// a revision set from outside (a library just loaded): nothing is known of how the catalog got there, and after
|
||||
// the next op nothing of before it
|
||||
let old = c.revision;
|
||||
c.revision = 7 << 32;
|
||||
assert_eq!(c.touched_since(old), None);
|
||||
assert_eq!(c.aspects_since(c.revision), None);
|
||||
let loaded = c.revision;
|
||||
c.apply(Op::SetRating { id: a, rating: 1 }).unwrap();
|
||||
assert_eq!(c.touched_since(loaded), Some(vec![a]));
|
||||
assert_eq!(c.touched_since(old), None);
|
||||
// an op that fails changes nothing, and what is remembered never says less than what happened
|
||||
let before = c.revision;
|
||||
assert!(c.apply(Op::SetRating { id: PhotoId(9_999), rating: 1 }).is_err());
|
||||
assert_eq!(c.revision, before);
|
||||
c.apply(Op::SetFlag { id: a, flag: Flag::Pick }).unwrap();
|
||||
assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a)));
|
||||
assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::FLAG)));
|
||||
// a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch
|
||||
// with an op that changes more than photos is not
|
||||
let before = c.revision;
|
||||
assert!(c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 5 }, Op::SetRating { id: PhotoId(9_999), rating: 1 }] }).is_err());
|
||||
assert_eq!((c.revision, c.photo(a).map(|p| p.rating)), (before, Some(1)));
|
||||
c.apply(Op::Batch { ops: vec![Op::Batch { ops: vec![Op::SetRating { id: a, rating: 2 }] }] }).unwrap();
|
||||
// (the photo the failed batch asked for and never found may be named too: more is said, never less)
|
||||
assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a)));
|
||||
let before = c.revision;
|
||||
c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 3 }, Op::RenameAlbum { id: album, name: "Journey".into() }] }).unwrap();
|
||||
assert_eq!(c.touched_since(before), None);
|
||||
// more ops than are remembered: the oldest are answered with "anything"
|
||||
let long_ago = c.revision;
|
||||
for i in 0..9000u32 {
|
||||
c.apply(Op::SetRating { id: a, rating: (i % 5) as u8 }).unwrap();
|
||||
}
|
||||
assert_eq!(c.touched_since(long_ago), None);
|
||||
assert_eq!(c.touched_since(c.revision - 10), Some(vec![a]));
|
||||
}
|
||||
@@ -74,6 +74,29 @@ fn v3_library_loads_and_is_upgraded() {
|
||||
assert_eq!(snapshot_version(&m), u64::from(VERSION));
|
||||
}
|
||||
|
||||
/// A v6 library (before preview-only photos recorded which version decided it, #662) opens with
|
||||
/// an empty stamp on its preview-only photos, so they are rechecked once, and is rewritten in the
|
||||
/// current format.
|
||||
#[test]
|
||||
fn v6_preview_only_photo_loads_unstamped_and_is_upgraded() {
|
||||
let mut c = Catalog::new();
|
||||
let id = c.alloc_photo_id();
|
||||
let mut p = Photo::new(id, Source::File { path: "/p/a.cr3".into() }, "a.cr3", "CR3", 160, 120, "2026-01-01");
|
||||
p.kind = MediaKind::Raw;
|
||||
p.preview_only = Some("Cr3 files are not decoded yet".into());
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
let snap = c.to_snapshot();
|
||||
assert!(!snap.contains("preview_checked"), "an empty stamp isn't written");
|
||||
let m = MemStore::new();
|
||||
m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":6,\"seq\":1,\"catalog\":{snap}}}\n").into_bytes());
|
||||
let (_, loaded, r) = Journal::open(Box::new(m.clone())).unwrap();
|
||||
let p = loaded.photo(id).unwrap();
|
||||
assert_eq!(p.preview_only.as_deref(), Some("Cr3 files are not decoded yet"));
|
||||
assert_eq!(p.preview_checked, "");
|
||||
assert_eq!(r.upgraded_from, Some(6));
|
||||
assert_eq!(snapshot_version(&m), u64::from(VERSION));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versionless_log_only_library_is_upgraded() {
|
||||
let mut c = Catalog::new();
|
||||
@@ -171,7 +194,8 @@ fn bad_crc_is_still_a_torn_tail() {
|
||||
|
||||
/// Guard for the version rule (see `journal` → *Format versions*): adding an [`Op`] variant breaks
|
||||
/// this match. When it does, bump `journal::VERSION`, add a row to the format table, and add the
|
||||
/// variant here under the new version.
|
||||
/// variant here under the new version. A version that only adds serialized fields raises
|
||||
/// `NEWEST_FIELDS` instead.
|
||||
#[test]
|
||||
fn op_variants_are_versioned() {
|
||||
fn since(op: &Op) -> u32 {
|
||||
@@ -214,6 +238,8 @@ fn op_variants_are_versioned() {
|
||||
Op::SetHistoryCursor { .. } => 8,
|
||||
}
|
||||
}
|
||||
/// v9: `Photo.preview_checked` and `Op::SetContent.preview_checked`.
|
||||
const NEWEST_FIELDS: u32 = 9;
|
||||
let newest = since(&Op::SetHistoryCursor { id: PhotoId(1), cursor: None });
|
||||
assert_eq!(newest, VERSION, "the newest op's version must be the current format version");
|
||||
assert_eq!(newest.max(NEWEST_FIELDS), VERSION, "the newest op's or fields' version must be the current format version");
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! The derived indexes ([`crate::index`]) answer as a scan of the catalog would, through every op,
|
||||
//! inverse, failed batch, snapshot, replay and clone; and the faster query paths give the same
|
||||
//! results as before.
|
||||
|
||||
use proptest::prelude::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
const PHOTOS: u64 = 8;
|
||||
|
||||
fn library() -> Catalog {
|
||||
let mut c = Catalog::new();
|
||||
for i in 0..PHOTOS {
|
||||
let p = Photo::new(PhotoId(i + 1), Source::Demo { scene: 1 }, &format!("p{i}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00");
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// What the indexes must say, worked out by scanning (as the catalog did before it had them).
|
||||
fn scanned(c: &Catalog) -> (Vec<Option<StackId>>, Vec<Vec<bool>>) {
|
||||
let photos: Vec<PhotoId> = (1..=PHOTOS + 1).map(PhotoId).collect();
|
||||
let stacks = photos.iter().map(|p| c.stacks().find(|s| s.photos.contains(p)).map(|s| s.id)).collect();
|
||||
let albums = (1..=4)
|
||||
.map(|a| {
|
||||
photos
|
||||
.iter()
|
||||
.map(|p| c.photo(*p).is_some() && c.album(AlbumId(a)).is_some_and(|al| al.smart.is_none() && al.photos.contains(p)))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
(stacks, albums)
|
||||
}
|
||||
|
||||
/// What the indexes say.
|
||||
fn indexed(c: &Catalog) -> (Vec<Option<StackId>>, Vec<Vec<bool>>) {
|
||||
let photos: Vec<PhotoId> = (1..=PHOTOS + 1).map(PhotoId).collect();
|
||||
let stacks = photos.iter().map(|p| c.stack_of(*p).map(|s| s.id)).collect();
|
||||
let albums = (1..=4)
|
||||
.map(|a| {
|
||||
photos
|
||||
.iter()
|
||||
.map(|p| c.album(AlbumId(a)).is_some_and(|al| al.smart.is_none()) && c.photo(*p).is_some_and(|ph| c.album_contains(AlbumId(a), ph)))
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
(stacks, albums)
|
||||
}
|
||||
|
||||
/// Photo ids from bits (the extra id names a photo that isn't there: such ops fail).
|
||||
fn ids(bits: u16) -> Vec<PhotoId> {
|
||||
(0..=PHOTOS).filter(|i| bits & (1 << i) != 0).map(|i| PhotoId(i + 1)).collect()
|
||||
}
|
||||
|
||||
fn op(c: &Catalog, kind: u8, n: u64, bits: u16, collapsed: bool) -> Op {
|
||||
match kind % 8 {
|
||||
0 => Op::AddStack { stack: Stack { id: StackId(n % 4 + 1), photos: ids(bits), collapsed } },
|
||||
1 => Op::RemoveStack { id: StackId(n % 4 + 1) },
|
||||
2 => Op::SetStack { id: StackId(n % 4 + 1), photos: ids(bits), collapsed },
|
||||
3 => {
|
||||
let mut a = Album::new(AlbumId(n % 4 + 1), "A");
|
||||
a.photos = ids(bits);
|
||||
Op::AddAlbum { album: a }
|
||||
}
|
||||
4 => Op::RemoveAlbum { id: AlbumId(n % 4 + 1) },
|
||||
5 => Op::SetAlbumPhotos { id: AlbumId(n % 4 + 1), photos: ids(bits) },
|
||||
// a batch whose last op fails half the time: what it did is rolled back
|
||||
6 => Op::Batch {
|
||||
ops: vec![
|
||||
Op::SetAlbumPhotos { id: AlbumId(n % 4 + 1), photos: ids(bits) },
|
||||
Op::SetStack { id: StackId(n % 4 + 1), photos: ids(bits.rotate_left(3)), collapsed },
|
||||
Op::AddStack { stack: Stack { id: StackId(n % 4 + 1), photos: ids(!bits), collapsed } },
|
||||
],
|
||||
},
|
||||
// as the engine deletes a photo: out of its albums and stacks too, in one batch
|
||||
_ => c.delete_permanently_ops(PhotoId(n % (PHOTOS + 1) + 1)),
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig::with_cases(256))]
|
||||
|
||||
#[test]
|
||||
fn indexes_agree_with_a_scan(steps in proptest::collection::vec((0u8..8, 0u64..16, 0u16..512, any::<bool>()), 1..40)) {
|
||||
let mut c = library();
|
||||
let start = c.to_snapshot();
|
||||
// built before the ops, so every op has to keep them up to date
|
||||
prop_assert_eq!(indexed(&c), scanned(&c));
|
||||
let mut log = String::new();
|
||||
let mut inverses = Vec::new();
|
||||
for (kind, n, bits, collapsed) in steps {
|
||||
let op = op(&c, kind, n, bits, collapsed);
|
||||
if let Ok(inv) = c.apply(op.clone()) {
|
||||
log.push_str(&Catalog::op_to_log_line(&op));
|
||||
inverses.push(inv);
|
||||
}
|
||||
prop_assert_eq!(indexed(&c), scanned(&c));
|
||||
}
|
||||
// a snapshot, a replayed journal and a clone build their own
|
||||
let snap = Catalog::from_snapshot(&c.to_snapshot()).unwrap();
|
||||
prop_assert_eq!(indexed(&snap), scanned(&c));
|
||||
let mut replayed = Catalog::from_snapshot(&start).unwrap();
|
||||
prop_assert_eq!(indexed(&replayed), scanned(&replayed));
|
||||
replayed.replay(&log).unwrap();
|
||||
prop_assert_eq!(indexed(&replayed), scanned(&c));
|
||||
prop_assert_eq!(indexed(&c.clone()), scanned(&c));
|
||||
prop_assert!(c.clone() == c, "the indexes don't take part in equality");
|
||||
// undone step by step
|
||||
for inv in inverses.into_iter().rev() {
|
||||
c.apply(inv).unwrap();
|
||||
prop_assert_eq!(indexed(&c), scanned(&c));
|
||||
}
|
||||
prop_assert!(c.stacks().next().is_none() && c.albums().next().is_none(), "all undone");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_photo_in_another_stack_is_refused_by_the_index() {
|
||||
let mut c = library();
|
||||
c.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![PhotoId(1), PhotoId(2)], collapsed: false } }).unwrap();
|
||||
let e = c.apply(Op::AddStack { stack: Stack { id: StackId(2), photos: vec![PhotoId(2), PhotoId(3)], collapsed: false } }).unwrap_err();
|
||||
assert_eq!(e, CatalogError::Invalid("a photo can be in only one stack".into()));
|
||||
// its own stack may keep it
|
||||
c.apply(Op::SetStack { id: StackId(1), photos: vec![PhotoId(2), PhotoId(1), PhotoId(3)], collapsed: true }).unwrap();
|
||||
assert_eq!(c.stack_of(PhotoId(3)).map(|s| s.id), Some(StackId(1)));
|
||||
}
|
||||
|
||||
/// The sort keys are worked out once per photo now; the order is the one the comparisons gave.
|
||||
#[test]
|
||||
fn sorted_queries_keep_their_order() {
|
||||
let mut c = Catalog::new();
|
||||
let names = ["b.jpg", "A.jpg", "a.jpg", "Été.jpg", "été.jpg", "C.JPG", "b.jpg"];
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
let mut p = Photo::new(PhotoId(i as u64 + 1), Source::Demo { scene: 1 }, n, "JPEG", 60, 40, &format!("2026-01-0{}T00:00:00", 7 - i % 3));
|
||||
p.captured = (i % 2 == 0).then(|| format!("2025-0{}-01T00:00:00", 1 + i % 4));
|
||||
p.rating = (i % 3) as u8;
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
let all = Filter::default();
|
||||
for key in [SortKey::CaptureDate, SortKey::ImportDate, SortKey::EditDate, SortKey::FileName, SortKey::Rating, SortKey::FileSize, SortKey::Random]
|
||||
{
|
||||
for ascending in [true, false] {
|
||||
let sort = Sort { key, ascending, seed: 7, ..Default::default() };
|
||||
// the comparison the query sorts by
|
||||
let mut want: Vec<&Photo> = c.photos().map(|p| p.as_ref()).collect();
|
||||
want.sort_by(|a, b| sort.order(a, b));
|
||||
let want: Vec<PhotoId> = want.iter().map(|p| p.id).collect();
|
||||
assert_eq!(c.query(&all, &sort), want, "{key:?} ascending {ascending}");
|
||||
}
|
||||
}
|
||||
// `only` as a set gives the photos it names
|
||||
let only = Filter { only: vec![PhotoId(3), PhotoId(1), PhotoId(3)], ..Default::default() };
|
||||
assert_eq!(c.query(&only, &Sort { key: SortKey::FileName, ascending: true, ..Default::default() }), vec![PhotoId(3), PhotoId(1)]);
|
||||
}
|
||||
|
||||
/// Rules name flags, labels and kinds as their variants in lower case (what `{:?}` gave).
|
||||
#[test]
|
||||
fn rule_names_are_the_variants_in_lower_case() {
|
||||
let mut c = Catalog::new();
|
||||
let mut n = 0;
|
||||
for flag in [Flag::None, Flag::Pick, Flag::Reject] {
|
||||
for label in
|
||||
[None, Some(ColorLabel::Red), Some(ColorLabel::Yellow), Some(ColorLabel::Green), Some(ColorLabel::Blue), Some(ColorLabel::Purple)]
|
||||
{
|
||||
for kind in [MediaKind::Image, MediaKind::Raw, MediaKind::Video] {
|
||||
n += 1;
|
||||
let mut p = Photo::new(PhotoId(n), Source::Demo { scene: 1 }, "x.jpg", "JPEG", 60, 40, "2026-01-01T00:00:00");
|
||||
(p.flag, p.label, p.kind) = (flag, label, kind);
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
c.apply(Op::SetLabelName { label: ColorLabel::Blue, name: Some("Sky".into()) }).unwrap();
|
||||
let count = |field: &str, value: String| {
|
||||
let rules: RuleSet =
|
||||
serde_json::from_value(serde_json::json!({"match": "all", "rules": [{"field": field, "op": "is", "value": value}]})).unwrap();
|
||||
c.query(&Filter { rule_set: Some(rules), ..Default::default() }, &Sort::default()).len()
|
||||
};
|
||||
for flag in [Flag::None, Flag::Pick, Flag::Reject] {
|
||||
assert_eq!(count("flag", format!("{flag:?}").to_lowercase()), 18, "{flag:?}");
|
||||
}
|
||||
assert_eq!(count("flag", "picked".into()), 18);
|
||||
for label in ColorLabel::ALL {
|
||||
assert_eq!(count("label", format!("{label:?}").to_uppercase()), 9, "{label:?}");
|
||||
}
|
||||
assert_eq!(count("label", "sky".into()), 9, "a custom name");
|
||||
assert_eq!(count("label", "none".into()), 9);
|
||||
for kind in [MediaKind::Image, MediaKind::Raw, MediaKind::Video] {
|
||||
assert_eq!(count("kind", format!("{kind:?}")), 18, "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A hand-edited file can put a photo in two stacks (ops refuse it): loading repairs it (the photo
|
||||
/// stays in the first) and the stack index gives the answers a scan of the stacks gives, before
|
||||
/// and after either stack goes.
|
||||
#[test]
|
||||
fn a_photo_in_two_stacks_read_from_a_file_answers_as_a_scan() {
|
||||
let mut c = library();
|
||||
c.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![PhotoId(1), PhotoId(2)], collapsed: false } }).unwrap();
|
||||
c.apply(Op::AddStack { stack: Stack { id: StackId(2), photos: vec![PhotoId(3), PhotoId(4)], collapsed: false } }).unwrap();
|
||||
let mut v: serde_json::Value = serde_json::from_str(&c.to_snapshot()).unwrap();
|
||||
v["stacks"]["2"]["photos"] = serde_json::json!([2, 3, 4]);
|
||||
let mut c = Catalog::from_snapshot(&v.to_string()).unwrap();
|
||||
assert_eq!(indexed(&c), scanned(&c));
|
||||
assert_eq!(c.stack_of(PhotoId(2)).map(|s| s.id), Some(StackId(1)));
|
||||
// the second stack can't be changed to keep it, as before
|
||||
assert!(c.apply(Op::SetStack { id: StackId(2), photos: vec![PhotoId(2), PhotoId(3)], collapsed: false }).is_err());
|
||||
c.apply(Op::RemoveStack { id: StackId(1) }).unwrap();
|
||||
assert_eq!(indexed(&c), scanned(&c));
|
||||
assert_eq!(c.stack_of(PhotoId(2)).map(|s| s.id), None, "the repair took it out of the second stack");
|
||||
}
|
||||
@@ -126,33 +126,40 @@ fn the_library_is_free_once_dropped_even_if_a_child_holds_a_copy() {
|
||||
|
||||
/// Issue #680, as it happens in practice: one thread spawns cheap children while another acquires,
|
||||
/// drops and re-acquires the lock on its own directory. Every acquire must succeed (before the fix
|
||||
/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, pass or fail.
|
||||
/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, and on a
|
||||
/// slow or loaded machine (the FreeBSD CI VM, where a spawn can take longer than that) until a few
|
||||
/// children have been spawned during them, at most a minute.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn dropping_the_lock_frees_the_library_while_another_thread_spawns_processes() {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Children spawned while the lock cycles, at least, before the test may stop.
|
||||
const CHILDREN: usize = 5;
|
||||
|
||||
let dir = temp_dir("spawn");
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let count = Arc::new(AtomicUsize::new(0));
|
||||
let spawner = {
|
||||
let stop = Arc::clone(&stop);
|
||||
let (stop, count) = (Arc::clone(&stop), Arc::clone(&count));
|
||||
std::thread::spawn(move || {
|
||||
let mut spawned = 0usize;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
if let Ok(mut c) = std::process::Command::new("true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn()
|
||||
{
|
||||
let _ = c.wait();
|
||||
spawned += 1;
|
||||
count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
spawned
|
||||
count.load(Ordering::Relaxed)
|
||||
})
|
||||
};
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
let t0 = Instant::now();
|
||||
let enough = |cycles: usize| (cycles >= 200_000 || t0.elapsed() >= Duration::from_secs(2)) && count.load(Ordering::Relaxed) >= CHILDREN;
|
||||
let mut cycles = 0usize;
|
||||
let mut refused = Vec::new();
|
||||
while cycles < 200_000 && std::time::Instant::now() < deadline {
|
||||
while !enough(cycles) && t0.elapsed() < Duration::from_secs(60) {
|
||||
match LibraryLock::acquire(&dir, "LightCraft") {
|
||||
Ok(l) => assert!(l.held()), // dropped here: the next cycle must get it back
|
||||
Err(e) => refused.push(format!("cycle {cycles}: {e:?}")),
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
//! What library queries cost at 100k photos (ignored:
|
||||
//! `cargo test --release -p lightcraft-catalog --lib scale_100k -- --ignored --nocapture`).
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 100k photos, 10k two-photo stacks, a 20k-photo album, keywords and flags.
|
||||
fn library(n: u64) -> (Catalog, AlbumId) {
|
||||
let mut c = Catalog::new();
|
||||
let mut ops: Vec<Op> = (0..n)
|
||||
.map(|i| {
|
||||
let mut p = Photo::new(
|
||||
PhotoId(i + 1),
|
||||
Source::Demo { scene: (i % 20) as u32 },
|
||||
&format!("IMG_{:06}.jpg", mix64(i) % 1_000_000),
|
||||
"JPEG",
|
||||
6000,
|
||||
4000,
|
||||
"2026-01-01T00:00:00",
|
||||
);
|
||||
p.captured = Some(format!("20{:02}-{:02}-{:02}T{:02}:00:00", 10 + i % 16, 1 + i % 12, 1 + i % 28, i % 24));
|
||||
p.rating = (i % 6) as u8;
|
||||
p.flag = if i % 7 == 0 { Flag::Pick } else { Flag::None };
|
||||
p.meta.keywords = vec![format!("kw{}", i % 500), "travel|italy".into()];
|
||||
Op::AddPhoto { photo: Box::new(p) }
|
||||
})
|
||||
.collect();
|
||||
ops.extend(
|
||||
(0..n / 10)
|
||||
.map(|s| Op::AddStack { stack: Stack { id: StackId(s + 1), photos: vec![PhotoId(s * 10 + 1), PhotoId(s * 10 + 2)], collapsed: false } }),
|
||||
);
|
||||
let album = AlbumId(1);
|
||||
let mut a = Album::new(album, "Big");
|
||||
a.photos = (0..n).step_by(5).map(|i| PhotoId(i + 1)).collect();
|
||||
ops.push(Op::AddAlbum { album: a });
|
||||
let t = Instant::now();
|
||||
c.apply(Op::Batch { ops }).unwrap();
|
||||
eprintln!("build {n} photos, {} stacks: {:.0} ms", n / 10, t.elapsed().as_secs_f64() * 1e3);
|
||||
(c, album)
|
||||
}
|
||||
|
||||
fn smart(c: &mut Catalog, id: u64, rules: serde_json::Value) -> AlbumId {
|
||||
let mut a = Album::new(AlbumId(id), format!("Smart {id}"));
|
||||
a.smart = Some(Box::new(Filter { rule_set: Some(serde_json::from_value(rules).unwrap()), ..Default::default() }));
|
||||
c.apply(Op::AddAlbum { album: a }).unwrap();
|
||||
AlbumId(id)
|
||||
}
|
||||
|
||||
/// Best of five runs (the machine may be busy with other work).
|
||||
fn time<T>(what: &str, f: impl Fn() -> T) -> T {
|
||||
let mut best = f64::MAX;
|
||||
let mut r = None;
|
||||
for _ in 0..5 {
|
||||
let t = Instant::now();
|
||||
r = Some(f());
|
||||
best = best.min(t.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
eprintln!("{what}: {best:.1} ms");
|
||||
r.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn scale_100k() {
|
||||
let (mut c, album) = library(100_000);
|
||||
let all = Filter::default();
|
||||
time("query, capture date", || c.query(&all, &Sort::default()));
|
||||
time("query, file name", || c.query(&all, &Sort { key: SortKey::FileName, ..Default::default() }));
|
||||
time("query, text", || c.query(&Filter { text: "kw42".into(), ..Default::default() }, &Sort::default()));
|
||||
time("query, album of 20k", || c.query(&Filter { album: Some(album), ..Default::default() }, &Sort::default()));
|
||||
let only: Vec<PhotoId> = (0..5000).map(|i| PhotoId(i * 7 + 1)).collect();
|
||||
time("query, only 5k ids", || c.query(&Filter { only: only.clone(), ..Default::default() }, &Sort::default()));
|
||||
let stacked = smart(&mut c, 10, json!({"match": "all", "rules": [{"field": "stacked", "op": "is", "value": true}]}));
|
||||
let flagged = smart(
|
||||
&mut c,
|
||||
11,
|
||||
json!({"match": "all", "rules": [{"field": "flag", "op": "is", "value": "pick"}, {"field": "kind", "op": "is", "value": "image"}]}),
|
||||
);
|
||||
let keyword = smart(&mut c, 12, json!({"match": "all", "rules": [{"field": "keywords", "op": "contains", "value": "kw42"}]}));
|
||||
let in_album = smart(&mut c, 13, json!({"match": "all", "rules": [{"field": "album", "op": "is", "value": album.0}]}));
|
||||
let recent = smart(&mut c, 14, json!({"match": "all", "rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "years"}}]}));
|
||||
for (what, a) in [("stacked", stacked), ("flag + kind", flagged), ("keyword", keyword), ("in album", in_album), ("in the last 2 years", recent)] {
|
||||
let n = time(&format!("smart album count, {what}"), || c.album_count(a));
|
||||
eprintln!(" = {n}");
|
||||
}
|
||||
time("albums_of", || c.albums_of(PhotoId(6)));
|
||||
time("arrange stacks", || c.arrange_stacks(&c.query(&all, &Sort::default())));
|
||||
time("stack_of × 1000", || (0..1000u64).filter(|i| c.stack_of(PhotoId(i * 97 + 1)).is_some()).count());
|
||||
}
|
||||
@@ -48,6 +48,8 @@ heif = ["dep:lightcraft-heif"]
|
||||
avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"]
|
||||
|
||||
[dev-dependencies]
|
||||
# tests record why they skip (`cargo xtask ci` lists the skips)
|
||||
lightcraft-testkit = { workspace = true }
|
||||
# JPEG XL tests read encoded samples without our colour conversion
|
||||
jxl-oxide = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
|
||||
@@ -73,6 +73,10 @@ ICC profiles are parsed with `moxcms` (v2/v4, `curv`/`para` TRCs, `chad`); sRGB,
|
||||
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.
|
||||
|
||||
Before colour conversion, positive-infinity float samples become 1.0; NaN and negative infinity
|
||||
become 0.0. This also applies to alpha. Finite samples keep their existing range: matrix/TRC paths
|
||||
preserve values above 1.0 and below 0.0, while CMS inputs and alpha remain bounded to 0..1.
|
||||
|
||||
## Dependencies and licences
|
||||
|
||||
All pure Rust, verified with `cargo info` / `cargo tree`:
|
||||
|
||||
@@ -44,11 +44,23 @@ impl Buf {
|
||||
match self {
|
||||
Buf::U8(v) => v[i] as f32 / 255.0,
|
||||
Buf::U16(v) => v[i] as f32 / 65535.0,
|
||||
Buf::F32(v) => v[i],
|
||||
Buf::F32(v) => normalize_float(v[i]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip non-finite device samples before colour conversion, preserving finite extended range.
|
||||
#[inline]
|
||||
fn normalize_float(value: f32) -> f32 {
|
||||
if value == f32::INFINITY {
|
||||
1.0
|
||||
} else if value.is_finite() {
|
||||
value
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Interleaved device samples as produced by a format decoder.
|
||||
pub(crate) struct Raw {
|
||||
pub width: usize,
|
||||
@@ -218,10 +230,7 @@ fn linearize(raw: &Raw, ch: usize, trc: &[Trc; 3]) -> Rgb32f {
|
||||
par_rows(&mut img.data, w, |y, row| {
|
||||
for (x, o) in row.iter_mut().enumerate() {
|
||||
let i = (y * w + x) * ch;
|
||||
let s = |c: usize| {
|
||||
let val = v[i + c];
|
||||
if val.is_finite() { val } else { 0.0 }
|
||||
};
|
||||
let s = |c: usize| normalize_float(v[i + c]);
|
||||
*o = if gray {
|
||||
let l = if lin { s(0) } else { trc[1].to_linear(s(0)) };
|
||||
[l, l, l]
|
||||
|
||||
+116
-15
@@ -75,14 +75,12 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option<Markers> {
|
||||
let end = (p + 2 + len).min(b.len());
|
||||
let seg = &b[start..end];
|
||||
match marker {
|
||||
0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC => {
|
||||
if seg.len() >= 6 && m.components == 0 {
|
||||
m.precision = seg[0];
|
||||
m.sof = marker;
|
||||
m.height = u16::from_be_bytes([seg[1], seg[2]]) as u32;
|
||||
m.width = u16::from_be_bytes([seg[3], seg[4]]) as u32;
|
||||
m.components = seg[5];
|
||||
}
|
||||
0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC && seg.len() >= 6 && m.components == 0 => {
|
||||
m.precision = seg[0];
|
||||
m.sof = marker;
|
||||
m.height = u16::from_be_bytes([seg[1], seg[2]]) as u32;
|
||||
m.width = u16::from_be_bytes([seg[3], seg[4]]) as u32;
|
||||
m.components = seg[5];
|
||||
}
|
||||
0xE1 => {
|
||||
if seg.starts_with(b"Exif\0\0") && m.exif.is_none() {
|
||||
@@ -182,9 +180,11 @@ pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback:
|
||||
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 >= 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).
|
||||
// 12-bit, non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling)
|
||||
// and every lossless (SOF3) file, which zune-jpeg refuses outright.
|
||||
let lossless = m.sof == 0xC3;
|
||||
let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components;
|
||||
let raw = if m.components == 4 || scale_to.is_some() || m.precision > 8 || non_interleaved {
|
||||
let raw = if m.components == 4 || scale_to.is_some() || m.precision > 8 || non_interleaved || lossless {
|
||||
decode_jpeg_decoder(bytes, &m, scale_to)?
|
||||
} else {
|
||||
decode_zune(bytes, &m)?
|
||||
@@ -221,7 +221,9 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>)
|
||||
use jpeg_decoder::PixelFormat;
|
||||
let mut d = jpeg_decoder::Decoder::new(bytes);
|
||||
d.read_info().map_err(|e| Error::Malformed(F, e.to_string()))?;
|
||||
if let Some((mw, mh)) = scale_to {
|
||||
// `scale` is the DCT size, not a lossless resample: on a lossless frame it would cut the
|
||||
// output to the top-left of each plane. Those decode at full size; `finish` scales after.
|
||||
if let Some((mw, mh)) = scale_to.filter(|_| m.sof != 0xC3) {
|
||||
// Ask for the smallest DCT scale still covering the target box.
|
||||
let (sw, sh) = scaled_request(m.width, m.height, mw, mh);
|
||||
d.scale(sw, sh).map_err(|e| Error::Malformed(F, e.to_string()))?;
|
||||
@@ -231,16 +233,25 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>)
|
||||
let (w, h) = (info.width as usize, info.height as usize);
|
||||
let (model, buf, depth) = match info.pixel_format {
|
||||
PixelFormat::L8 => (Model::Gray, Buf::U8(px), 8),
|
||||
PixelFormat::L16 => {
|
||||
let v = px.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
|
||||
(Model::Gray, Buf::U16(v), 16)
|
||||
}
|
||||
PixelFormat::L16 => (Model::Gray, Buf::U16(lossless_samples(&px, m.precision)), 16),
|
||||
// A lossless frame over 8 bits per sample still reports `RGB24` (or `CMYK32`), but its
|
||||
// buffer holds two native-endian bytes per sample like L16.
|
||||
PixelFormat::RGB24 if m.precision > 8 => (Model::Rgb, Buf::U16(lossless_samples(&px, m.precision)), 16),
|
||||
PixelFormat::RGB24 => (Model::Rgb, Buf::U8(px), 8),
|
||||
PixelFormat::CMYK32 if m.precision > 8 => (Model::Cmyk, Buf::U16(lossless_samples(&px, m.precision)), 16),
|
||||
PixelFormat::CMYK32 => (Model::Cmyk, Buf::U8(px), 8),
|
||||
};
|
||||
Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf, bit_depth: depth })
|
||||
}
|
||||
|
||||
/// jpeg-decoder's lossless samples: one native-endian `u16` per sample holding the raw
|
||||
/// `precision`-bit value. Left-justify it into the 16-bit range the rest of the pipeline reads,
|
||||
/// as every 16-bit decoder feeding [`Buf::U16`] does.
|
||||
fn lossless_samples(px: &[u8], precision: u8) -> Vec<u16> {
|
||||
let shift = 16u8.saturating_sub(precision);
|
||||
px.as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes([c[0], c[1]]) << shift).collect()
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -450,4 +461,94 @@ mod tests {
|
||||
let m = parse_markers(&[0xFF, 0xD8, 0xFF, 0xE1, 0, 200, 1, 2]).unwrap();
|
||||
assert!(m.exif.is_none());
|
||||
}
|
||||
|
||||
// --- lossless (SOF3) synthetic files: one DHT table with a single 1-bit category-0 code, so
|
||||
// every pixel is a zero difference and decodes to the predictor's initial sample. No such
|
||||
// encoder exists here; the bytes are the smallest a decoder accepts.
|
||||
|
||||
fn lossless_header(out: &mut Vec<u8>, components: u8, w: u16, precision: u8) {
|
||||
// DHT: one DC table (id 0) with a single 1-bit code for the category-0 symbol.
|
||||
out.extend_from_slice(&[0xFF, 0xC4]);
|
||||
out.extend_from_slice(&(2u16 + 1 + 16 + 1).to_be_bytes());
|
||||
out.push(0x00);
|
||||
let mut counts = [0u8; 16];
|
||||
counts[0] = 1;
|
||||
out.extend_from_slice(&counts);
|
||||
out.push(0x00);
|
||||
// SOF3
|
||||
out.extend_from_slice(&[0xFF, 0xC3]);
|
||||
out.extend_from_slice(&(8u16 + 3 * components as u16).to_be_bytes());
|
||||
out.push(precision);
|
||||
out.extend_from_slice(&1u16.to_be_bytes());
|
||||
out.extend_from_slice(&w.to_be_bytes());
|
||||
out.push(components);
|
||||
for c in 1..=components {
|
||||
out.extend_from_slice(&[c, 0x11, 0]);
|
||||
}
|
||||
// SOS: predictor Ra, Se = 0, Ah/Al = 0
|
||||
out.extend_from_slice(&[0xFF, 0xDA]);
|
||||
out.extend_from_slice(&(6u16 + 2 * components as u16).to_be_bytes());
|
||||
out.push(components);
|
||||
for c in 1..=components {
|
||||
out.extend_from_slice(&[c, 0]);
|
||||
}
|
||||
out.extend_from_slice(&[1, 0, 0]);
|
||||
}
|
||||
|
||||
/// A minimal lossless JPEG, `w` x 1, `components` samples per pixel, every sample the initial
|
||||
/// prediction `1 << (precision - 1)`.
|
||||
fn lossless_jpeg(components: u8, w: u16, precision: u8) -> Vec<u8> {
|
||||
let mut out = vec![0xFF, 0xD8];
|
||||
lossless_header(&mut out, components, w, precision);
|
||||
// entropy: one zero bit per sample (a category-0 code), padded to whole bytes
|
||||
out.extend_from_slice(&vec![0u8; (components as usize * w as usize).div_ceil(8)]);
|
||||
out.extend_from_slice(&[0xFF, 0xD9]);
|
||||
out
|
||||
}
|
||||
|
||||
/// jpeg-decoder emits lossless samples native-endian (its `to_ne_bytes`) holding the raw
|
||||
/// `precision`-bit value, so the old `from_be_bytes` swapped every sample (2048 -> 8) and the
|
||||
/// raw value was never left-justified into the 16-bit buffer.
|
||||
#[test]
|
||||
fn lossless_grayscale_samples_are_native_endian_and_left_justified() {
|
||||
let bytes = lossless_jpeg(1, 8, 12);
|
||||
let m = parse_markers(&bytes).unwrap();
|
||||
let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap();
|
||||
let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") };
|
||||
assert_eq!(v, &vec![2048u16 << 4; 8], "0x0800 native-endian, left-justified");
|
||||
assert_eq!((raw.model, raw.bit_depth), (Model::Gray, 16));
|
||||
}
|
||||
|
||||
/// A lossless frame over 8 bits per sample reports `RGB24`, but its buffer is two bytes per
|
||||
/// sample: it must become `Buf::U16`, not `Buf::U8` over double-length rows.
|
||||
#[test]
|
||||
fn lossless_high_precision_rgb_is_16_bit() {
|
||||
let bytes = lossless_jpeg(3, 2, 12);
|
||||
let m = parse_markers(&bytes).unwrap();
|
||||
let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap();
|
||||
let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") };
|
||||
assert_eq!(v, &vec![2048u16 << 4; 6]);
|
||||
assert_eq!(raw.model, Model::Rgb);
|
||||
}
|
||||
|
||||
/// The same 16-bit wrapping for four components: `CMYK32` over 8 bits is two bytes a sample.
|
||||
#[test]
|
||||
fn lossless_high_precision_cmyk_is_16_bit() {
|
||||
let bytes = lossless_jpeg(4, 2, 12);
|
||||
let m = parse_markers(&bytes).unwrap();
|
||||
let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap();
|
||||
let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") };
|
||||
assert_eq!(v, &vec![2048u16 << 4; 8]);
|
||||
assert_eq!((raw.model, raw.bit_depth), (Model::Cmyk, 16));
|
||||
}
|
||||
|
||||
/// zune-jpeg refuses SOF3 ("Lossless Huffman"), so an 8-bit lossless file must route to
|
||||
/// jpeg-decoder; `header` accepts it, so `decode` used to fail a file we listed as readable.
|
||||
#[test]
|
||||
fn eight_bit_lossless_jpeg_decodes() {
|
||||
let bytes = lossless_jpeg(1, 8, 8);
|
||||
let d = crate::decode(&bytes, DecodeOptions::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (8, 1));
|
||||
assert_eq!(d.bit_depth, 8);
|
||||
}
|
||||
}
|
||||
+285
-34
@@ -3,8 +3,11 @@
|
||||
//! The image is cut into bands of MCU rows separated by restart markers (`DRI` + `RSTn`): DC
|
||||
//! prediction restarts at every marker, so bands are entropy-coded independently on all cores and
|
||||
//! concatenated. Quantization uses the example tables of T.81 Annex K scaled by the usual 1–100
|
||||
//! quality convention; Huffman coding uses the Annex K example tables (interleaved baseline, readable
|
||||
//! by every decoder). Supports grayscale and YCbCr 4:4:4 / 4:2:0.
|
||||
//! quality convention. Huffman tables are made for each image from its own symbol counts (T.81
|
||||
//! Annex K.2): a first pass over the bands counts the symbols, a second codes them. The file stays
|
||||
//! interleaved baseline with the tables in its `DHT` segments, readable by every decoder, and is
|
||||
//! smaller than with the Annex K example tables at the same pixels. Supports grayscale and YCbCr
|
||||
//! 4:4:4 / 4:2:0.
|
||||
|
||||
use crate::encode::ChromaSubsampling;
|
||||
|
||||
@@ -61,16 +64,103 @@ impl Huff {
|
||||
let (mut c, mut k) = (0u16, 0usize);
|
||||
for (len, &n) in bits.iter().enumerate() {
|
||||
for _ in 0..n {
|
||||
code[vals[k] as usize] = (c, len as u8 + 1);
|
||||
c += 1;
|
||||
if let Some(&v) = vals.get(k) {
|
||||
code[v as usize] = (c, len as u8 + 1);
|
||||
}
|
||||
c = c.wrapping_add(1);
|
||||
k += 1;
|
||||
}
|
||||
c <<= 1;
|
||||
c = c.wrapping_shl(1);
|
||||
}
|
||||
Huff { code }
|
||||
}
|
||||
}
|
||||
|
||||
/// Symbol counts of one Huffman table: index = symbol (DC size category, or AC run/size byte).
|
||||
type Freq = [u64; 256];
|
||||
|
||||
/// The code lengths (`BITS`, counts per length 1..16) and symbols (`HUFFVAL`) of an optimal
|
||||
/// Huffman table for `freq`, limited to 16-bit codes and never assigning the all-ones code
|
||||
/// (T.81 Annex K.2, figures K.1–K.4). Symbols with a zero count get no code. At least one symbol
|
||||
/// is always present (a table nothing used gets symbol 0), so the result is a valid `DHT`.
|
||||
fn optimal_table(freq: &Freq) -> ([u8; 16], Vec<u8>) {
|
||||
// 256 is the reserved pseudo-symbol: counted once, it takes the longest code, which is
|
||||
// then dropped so no real code is all ones (K.2)
|
||||
let mut f = [0u64; 257];
|
||||
f[..256].copy_from_slice(freq);
|
||||
if f[..256].iter().all(|&c| c == 0) {
|
||||
f[0] = 1;
|
||||
}
|
||||
f[256] = 1;
|
||||
let mut size = [0usize; 257];
|
||||
let mut next: [Option<usize>; 257] = [None; 257];
|
||||
loop {
|
||||
// the two smallest non-zero counts; on equal counts the larger symbol goes first (K.1)
|
||||
let mut c1: Option<usize> = None;
|
||||
for i in 0..257 {
|
||||
if f[i] > 0 && c1.is_none_or(|c| f[i] <= f[c]) {
|
||||
c1 = Some(i);
|
||||
}
|
||||
}
|
||||
let Some(c1) = c1 else { break };
|
||||
let mut c2: Option<usize> = None;
|
||||
for i in 0..257 {
|
||||
if i != c1 && f[i] > 0 && c2.is_none_or(|c| f[i] <= f[c]) {
|
||||
c2 = Some(i);
|
||||
}
|
||||
}
|
||||
let Some(c2) = c2 else { break };
|
||||
f[c1] = f[c1].saturating_add(f[c2]);
|
||||
f[c2] = 0;
|
||||
// every symbol in both trees goes one level deeper; c2's chain is appended to c1's
|
||||
let mut k = c1;
|
||||
size[k] += 1;
|
||||
while let Some(n) = next[k] {
|
||||
k = n;
|
||||
size[k] += 1;
|
||||
}
|
||||
next[k] = Some(c2);
|
||||
let mut k = c2;
|
||||
size[k] += 1;
|
||||
while let Some(n) = next[k] {
|
||||
k = n;
|
||||
size[k] += 1;
|
||||
}
|
||||
}
|
||||
// counts per code length; 257 symbols need at most 256 levels
|
||||
let mut bits = [0usize; 257];
|
||||
for &s in &size {
|
||||
if s > 0 {
|
||||
bits[s.min(256)] += 1;
|
||||
}
|
||||
}
|
||||
// limit to 16 bits (K.3): move pairs of the longest codes up, one level at a time (the
|
||||
// deepest level of a full tree always holds an even number of codes)
|
||||
for i in (17..257).rev() {
|
||||
while bits[i] >= 2 {
|
||||
let Some(j) = (1..i - 1).rev().find(|&j| bits[j] > 0) else { break };
|
||||
bits[i] -= 2;
|
||||
bits[i - 1] += 1;
|
||||
bits[j + 1] += 2;
|
||||
bits[j] -= 1;
|
||||
}
|
||||
}
|
||||
// drop the reserved symbol's code: it is one of the longest
|
||||
if let Some(i) = (1..=16).rev().find(|&i| bits[i] > 0) {
|
||||
bits[i] -= 1;
|
||||
}
|
||||
// symbols by code length, then by value (K.4); the reserved symbol is not listed
|
||||
let mut vals = Vec::new();
|
||||
for len in 1..257 {
|
||||
vals.extend((0..256).filter(|&s| size[s] == len).map(|s| s as u8));
|
||||
}
|
||||
let mut out = [0u8; 16];
|
||||
for (o, &b) in out.iter_mut().zip(&bits[1..=16]) {
|
||||
*o = b.min(255) as u8;
|
||||
}
|
||||
(out, vals)
|
||||
}
|
||||
|
||||
fn scaled_quant(base: &[u16; 64], quality: u8) -> [u16; 64] {
|
||||
let q = quality.clamp(1, 100) as u32;
|
||||
let scale = if q < 50 { 5000 / q } else { 200 - 2 * q };
|
||||
@@ -177,13 +267,15 @@ fn magnitude(v: i32) -> (u32, u32) {
|
||||
(size, bits & ((1u32 << size) - 1))
|
||||
}
|
||||
|
||||
fn encode_block(w: &mut Bits, q: &[i16; 64], pred: &mut i32, dc: &Huff, ac: &Huff) {
|
||||
/// The Huffman symbols of one quantized block in coding order: `sym(ac, symbol, bits, n)` for the
|
||||
/// DC size category (`ac` false) and each AC run/size, ZRL and EOB (`ac` true), each followed by
|
||||
/// its `n` extra bits `bits`.
|
||||
#[inline(always)]
|
||||
fn block_symbols(q: &[i16; 64], pred: &mut i32, mut sym: impl FnMut(bool, usize, u32, u32)) {
|
||||
let d = q[0] as i32 - *pred;
|
||||
*pred = q[0] as i32;
|
||||
let (s, b) = magnitude(d);
|
||||
let (c, l) = dc.code[s as usize];
|
||||
w.put(c as u32, l as u32);
|
||||
w.put(b, s);
|
||||
sym(false, s as usize, b, s);
|
||||
let mut run = 0u32;
|
||||
for &zi in &ZIGZAG[1..] {
|
||||
let v = q[zi] as i32;
|
||||
@@ -192,22 +284,45 @@ fn encode_block(w: &mut Bits, q: &[i16; 64], pred: &mut i32, dc: &Huff, ac: &Huf
|
||||
continue;
|
||||
}
|
||||
while run >= 16 {
|
||||
let (c, l) = ac.code[0xF0];
|
||||
w.put(c as u32, l as u32);
|
||||
sym(true, 0xF0, 0, 0);
|
||||
run -= 16;
|
||||
}
|
||||
let (s, b) = magnitude(v);
|
||||
let (c, l) = ac.code[((run << 4) | s) as usize];
|
||||
w.put(c as u32, l as u32);
|
||||
w.put(b, s);
|
||||
sym(true, ((run << 4) | s) as usize, b, s);
|
||||
run = 0;
|
||||
}
|
||||
if run > 0 {
|
||||
let (c, l) = ac.code[0x00];
|
||||
w.put(c as u32, l as u32);
|
||||
sym(true, 0x00, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// One coded symbol of the entropy-coded data: bits 0–7 the symbol, 8–9 its table (0 DC luma,
|
||||
/// 1 AC luma, 2 DC chroma, 3 AC chroma), 10–13 the number of extra bits, 14–29 those bits (a
|
||||
/// baseline coefficient needs at most 11).
|
||||
type Token = u32;
|
||||
|
||||
/// Append one block's symbols to `out` and count them in `freq` (by table, as in [`Token`]).
|
||||
fn tokenize_block(q: &[i16; 64], pred: &mut i32, chroma: bool, out: &mut Vec<Token>, freq: &mut [Freq; 4]) {
|
||||
let base = if chroma { 2 } else { 0 };
|
||||
block_symbols(q, pred, |is_ac, s, bits, n| {
|
||||
let table = base + is_ac as usize;
|
||||
freq[table][s & 0xFF] += 1;
|
||||
out.push((s as u32 & 0xFF) | (table as u32) << 8 | (n & 0xF) << 10 | (bits & 0xFFFF) << 14);
|
||||
});
|
||||
}
|
||||
|
||||
/// Code a band's symbols with `tables` (indexed as in [`Token`]).
|
||||
fn write_tokens(tokens: &[Token], tables: &[Huff; 4]) -> Vec<u8> {
|
||||
let mut w = Bits::new();
|
||||
for &t in tokens {
|
||||
let (s, table, n, bits) = (t & 0xFF, (t >> 8) & 3, (t >> 10) & 0xF, t >> 14);
|
||||
let (c, l) = tables[table as usize].code[s as usize];
|
||||
w.put(c as u32, l as u32);
|
||||
w.put(bits, n);
|
||||
}
|
||||
w.finish()
|
||||
}
|
||||
|
||||
/// Planar component samples (level-shifted to −128..127) with edge replication on read.
|
||||
struct Plane {
|
||||
w: usize,
|
||||
@@ -249,7 +364,8 @@ fn dht(out: &mut Vec<u8>, class_id: u8, bits: &[u8; 16], vals: &[u8]) {
|
||||
segment(out, 0xC4, &p);
|
||||
}
|
||||
|
||||
/// Encode interleaved 8-bit samples (`channels` 1, 3 or 4; alpha is dropped).
|
||||
/// Encode interleaved 8-bit samples (`channels` 1, 3 or 4; alpha is dropped), with Huffman
|
||||
/// tables made for the image.
|
||||
pub fn encode(
|
||||
data: &[u8],
|
||||
width: usize,
|
||||
@@ -258,6 +374,21 @@ pub fn encode(
|
||||
quality: u8,
|
||||
sub: ChromaSubsampling,
|
||||
app_segments: &[(u8, Vec<u8>)],
|
||||
) -> Vec<u8> {
|
||||
encode_with(data, width, height, channels, quality, sub, app_segments, true)
|
||||
}
|
||||
|
||||
/// [`encode`], with optimized Huffman tables or (`optimize` false) the Annex K example tables.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn encode_with(
|
||||
data: &[u8],
|
||||
width: usize,
|
||||
height: usize,
|
||||
channels: usize,
|
||||
quality: u8,
|
||||
sub: ChromaSubsampling,
|
||||
app_segments: &[(u8, Vec<u8>)],
|
||||
optimize: bool,
|
||||
) -> Vec<u8> {
|
||||
let gray = channels == 1;
|
||||
let (hs, vs) = match (gray, sub) {
|
||||
@@ -331,42 +462,67 @@ pub fn encode(
|
||||
let ql = scaled_quant(&Q_LUMA, quality);
|
||||
let qc = scaled_quant(&Q_CHROMA, quality);
|
||||
let (rl, rc) = (recip_table(&ql), recip_table(&qc));
|
||||
let (dcl, acl) = (Huff::new(&DC_LUMA_BITS, &DC_VALS), Huff::new(&AC_LUMA_BITS, &AC_LUMA_VALS));
|
||||
let (dcc, acc) = (Huff::new(&DC_CHROMA_BITS, &DC_VALS), Huff::new(&AC_CHROMA_BITS, &AC_CHROMA_VALS));
|
||||
|
||||
let (mcu_w, mcu_h) = (8 * hs, 8 * vs);
|
||||
let (mx, my) = (width.div_ceil(mcu_w), height.div_ceil(mcu_h));
|
||||
// restart interval: whole MCU rows per band, ≤ 65535 MCUs, enough bands to keep all cores busy
|
||||
let rows_per_band = (my / 64).clamp(1, (65_535 / mx.max(1)).max(1));
|
||||
let bands: Vec<usize> = (0..my.div_ceil(rows_per_band)).collect();
|
||||
let encode_band = |band: &usize| -> Vec<u8> {
|
||||
let mut w = Bits::new();
|
||||
let mut pred = [0i32; 3];
|
||||
// first pass: each band's quantized blocks as symbols, with their counts per table (DC
|
||||
// prediction restarts per band, as the restart markers say)
|
||||
let tokenize_band = |band: &usize| -> (Vec<Token>, [Freq; 4]) {
|
||||
let (mut blk, mut q) = ([0f32; 64], [0i16; 64]);
|
||||
let (mut tokens, mut freq) = (Vec::new(), [[0u64; 256]; 4]);
|
||||
let mut pred = [0i32; 3];
|
||||
for mcu_y in band * rows_per_band..((band + 1) * rows_per_band).min(my) {
|
||||
for mcu_x in 0..mx {
|
||||
for vy in 0..vs {
|
||||
for hx in 0..hs {
|
||||
planes[0].block(mcu_x * hs + hx, mcu_y * vs + vy, &mut blk);
|
||||
fdct_quant(&blk, &rl, &mut q);
|
||||
encode_block(&mut w, &q, &mut pred[0], &dcl, &acl);
|
||||
tokenize_block(&q, &mut pred[0], false, &mut tokens, &mut freq);
|
||||
}
|
||||
}
|
||||
if !gray {
|
||||
for k in 1..3 {
|
||||
planes[k].block(mcu_x, mcu_y, &mut blk);
|
||||
for (plane, pred) in planes.iter().zip(pred.iter_mut()).take(3).skip(1) {
|
||||
plane.block(mcu_x, mcu_y, &mut blk);
|
||||
fdct_quant(&blk, &rc, &mut q);
|
||||
encode_block(&mut w, &q, &mut pred[k], &dcc, &acc);
|
||||
tokenize_block(&q, pred, true, &mut tokens, &mut freq);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
w.finish()
|
||||
(tokens, freq)
|
||||
};
|
||||
#[cfg(feature = "parallel")]
|
||||
let coded: Vec<Vec<u8>> = bands.par_iter().map(encode_band).collect();
|
||||
let tokenized: Vec<(Vec<Token>, [Freq; 4])> = bands.par_iter().map(tokenize_band).collect();
|
||||
#[cfg(not(feature = "parallel"))]
|
||||
let coded: Vec<Vec<u8>> = bands.iter().map(encode_band).collect();
|
||||
let tokenized: Vec<(Vec<Token>, [Freq; 4])> = bands.iter().map(tokenize_band).collect();
|
||||
drop(planes);
|
||||
// the tables: DC luma, AC luma, DC chroma, AC chroma
|
||||
let tables: [([u8; 16], Vec<u8>); 4] = if optimize {
|
||||
let mut total = [[0u64; 256]; 4];
|
||||
for (_, f) in &tokenized {
|
||||
for (t, f) in total.iter_mut().zip(f) {
|
||||
for (t, f) in t.iter_mut().zip(f) {
|
||||
*t = t.saturating_add(*f);
|
||||
}
|
||||
}
|
||||
}
|
||||
total.each_ref().map(optimal_table)
|
||||
} else {
|
||||
[
|
||||
(DC_LUMA_BITS, DC_VALS.to_vec()),
|
||||
(AC_LUMA_BITS, AC_LUMA_VALS.to_vec()),
|
||||
(DC_CHROMA_BITS, DC_VALS.to_vec()),
|
||||
(AC_CHROMA_BITS, AC_CHROMA_VALS.to_vec()),
|
||||
]
|
||||
};
|
||||
let huff = tables.each_ref().map(|(bits, vals)| Huff::new(bits, vals));
|
||||
// second pass: code the symbols
|
||||
#[cfg(feature = "parallel")]
|
||||
let coded: Vec<Vec<u8>> = tokenized.into_par_iter().map(|(t, _)| write_tokens(&t, &huff)).collect();
|
||||
#[cfg(not(feature = "parallel"))]
|
||||
let coded: Vec<Vec<u8>> = tokenized.into_iter().map(|(t, _)| write_tokens(&t, &huff)).collect();
|
||||
|
||||
// headers
|
||||
let mut out = Vec::with_capacity(coded.iter().map(Vec::len).sum::<usize>() + 4096);
|
||||
@@ -396,11 +552,12 @@ pub fn encode(
|
||||
sof.extend_from_slice(&[3, 1, ((hs as u8) << 4) | vs as u8, 0, 2, 0x11, 1, 3, 0x11, 1]);
|
||||
}
|
||||
segment(&mut out, 0xC0, &sof);
|
||||
dht(&mut out, 0x00, &DC_LUMA_BITS, &DC_VALS);
|
||||
dht(&mut out, 0x10, &AC_LUMA_BITS, &AC_LUMA_VALS);
|
||||
let [(dlb, dlv), (alb, alv), (dcb, dcv), (acb, acv)] = &tables;
|
||||
dht(&mut out, 0x00, dlb, dlv);
|
||||
dht(&mut out, 0x10, alb, alv);
|
||||
if !gray {
|
||||
dht(&mut out, 0x01, &DC_CHROMA_BITS, &DC_VALS);
|
||||
dht(&mut out, 0x11, &AC_CHROMA_BITS, &AC_CHROMA_VALS);
|
||||
dht(&mut out, 0x01, dcb, dcv);
|
||||
dht(&mut out, 0x11, acb, acv);
|
||||
}
|
||||
let interval = (rows_per_band * mx) as u16;
|
||||
if coded.len() > 1 {
|
||||
@@ -495,6 +652,100 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The code lengths of `(bits, vals)`, checked: every listed symbol once, at most 16 bits,
|
||||
/// and room left in the code space (the all-ones code is never assigned, Kraft sum < 1).
|
||||
fn check_table(bits: &[u8; 16], vals: &[u8]) {
|
||||
assert_eq!(bits.iter().map(|&b| b as usize).sum::<usize>(), vals.len());
|
||||
let mut seen = [false; 256];
|
||||
for &v in vals {
|
||||
assert!(!seen[v as usize], "symbol {v} listed twice");
|
||||
seen[v as usize] = true;
|
||||
}
|
||||
let kraft: f64 = bits.iter().enumerate().map(|(i, &n)| n as f64 / 2f64.powi(i as i32 + 1)).sum();
|
||||
assert!(kraft < 1.0, "code space full: {kraft}");
|
||||
let h = Huff::new(bits, vals);
|
||||
for &v in vals {
|
||||
let (c, l) = h.code[v as usize];
|
||||
assert!((1..=16).contains(&l));
|
||||
assert_ne!(c as u32, (1u32 << l) - 1, "symbol {v} has the all-ones code");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optimal_tables_are_valid_and_bounded() {
|
||||
// one symbol, two, a typical spread and every symbol
|
||||
let mut one = [0u64; 256];
|
||||
one[7] = 5;
|
||||
let mut two = [0u64; 256];
|
||||
two[0] = 1;
|
||||
two[0xF0] = 1_000_000;
|
||||
let typical: Freq = std::array::from_fn(|s| if s % 3 == 0 { (s as u64 * 7919) % 1000 + 1 } else { 0 });
|
||||
let all: Freq = std::array::from_fn(|s| s as u64 + 1);
|
||||
// Fibonacci counts make an unbounded Huffman tree ~40 levels deep: the 16-bit limit applies
|
||||
let mut fib = [0u64; 256];
|
||||
let (mut a, mut b) = (1u64, 1u64);
|
||||
for f in fib.iter_mut().take(40) {
|
||||
*f = a;
|
||||
(a, b) = (b, a + b);
|
||||
}
|
||||
for (name, f) in [("one", one), ("two", two), ("typical", typical), ("all", all), ("fib", fib), ("none", [0; 256])] {
|
||||
let (bits, vals) = optimal_table(&f);
|
||||
check_table(&bits, &vals);
|
||||
for s in 0..256 {
|
||||
assert_eq!(vals.contains(&(s as u8)), f[s] > 0 || (name == "none" && s == 0), "{name}: symbol {s}");
|
||||
}
|
||||
}
|
||||
// more frequent symbols never get longer codes
|
||||
let (bits, vals) = optimal_table(&typical);
|
||||
let h = Huff::new(&bits, &vals);
|
||||
for &x in &vals {
|
||||
for &y in &vals {
|
||||
if typical[x as usize] > typical[y as usize] {
|
||||
assert!(h.code[x as usize].1 <= h.code[y as usize].1, "{x} vs {y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// the Annex K example tables pass the same checks
|
||||
check_table(&AC_LUMA_BITS, &AC_LUMA_VALS);
|
||||
check_table(&DC_CHROMA_BITS, &DC_VALS);
|
||||
}
|
||||
|
||||
/// Issue #1373: tables made for the image give a smaller file with exactly the same pixels as
|
||||
/// the Annex K example tables (the quantized coefficients are the same), at every quality and
|
||||
/// subsampling, and both decoders read it.
|
||||
#[test]
|
||||
fn optimized_tables_shrink_files_without_changing_pixels() {
|
||||
let (w, h) = (517, 333);
|
||||
// gradients plus deterministic noise, like a photo's grain
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
let src: Vec<u8> = test_rgb(w, h)
|
||||
.into_iter()
|
||||
.map(|v| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
v.saturating_add((seed % 23) as u8)
|
||||
})
|
||||
.collect();
|
||||
for (q, sub) in [(100, ChromaSubsampling::S444), (92, ChromaSubsampling::S444), (75, ChromaSubsampling::S420), (30, ChromaSubsampling::S420)]
|
||||
{
|
||||
let std = encode_with(&src, w, h, 3, q, sub, &[], false);
|
||||
let opt = encode(&src, w, h, 3, q, sub, &[]);
|
||||
assert!(opt.len() < std.len(), "q{q} {sub:?}: {} ≥ {}", opt.len(), std.len());
|
||||
let a = jpeg_decoder::Decoder::new(&std[..]).decode().expect("standard tables");
|
||||
let b = jpeg_decoder::Decoder::new(&opt[..]).decode().expect("optimized tables");
|
||||
assert_eq!(a, b, "q{q} {sub:?}: pixels differ");
|
||||
let z = zune_jpeg::JpegDecoder::new(zune_core::bytestream::ZCursor::new(&opt[..])).decode().expect("zune-jpeg");
|
||||
assert_eq!(z.len(), b.len());
|
||||
}
|
||||
// grayscale too
|
||||
let gray: Vec<u8> = src.iter().step_by(3).copied().collect();
|
||||
let std = encode_with(&gray, w, h, 1, 90, ChromaSubsampling::S444, &[], false);
|
||||
let opt = encode(&gray, w, h, 1, 90, ChromaSubsampling::S444, &[]);
|
||||
assert!(opt.len() < std.len());
|
||||
assert_eq!(jpeg_decoder::Decoder::new(&std[..]).decode().unwrap(), jpeg_decoder::Decoder::new(&opt[..]).decode().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grayscale_and_quality_monotone() {
|
||||
let (w, h) = (300, 200);
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
//! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and, on native targets, AVIF (feature `avif`) and
|
||||
//! JPEG XL ([`encode_jxl`], feature `jxl`), embedding
|
||||
//! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export.
|
||||
//! - [`video`] encodes AV1 video (native, feature `avif`) and writes it as MP4 or WebM.
|
||||
//!
|
||||
//! No decoder panics on malformed input (property-tested).
|
||||
#![forbid(unsafe_code)]
|
||||
@@ -31,11 +32,13 @@ mod jxl;
|
||||
mod jxl_encode;
|
||||
mod other;
|
||||
mod png_codec;
|
||||
pub mod print;
|
||||
pub mod proof;
|
||||
mod psd;
|
||||
mod sniff;
|
||||
pub mod space;
|
||||
mod tiff_codec;
|
||||
pub mod video;
|
||||
mod webp;
|
||||
|
||||
pub use encode::{
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
//! Print colour: a composed print page (16-bit RGB encoded with the BT.2020 / BT.709 curve, as the
|
||||
//! `Rec2020` output space renders it) to the samples a print-ready file carries, in the colour space
|
||||
//! of the chosen printer / paper profile.
|
||||
//!
|
||||
//! - **Target**: a printer / paper ICC profile (RGB, CMYK or grey; matrix or LUT based) converts the
|
||||
//! page to the device's own values. When the printer manages colour instead, the page is sent in a
|
||||
//! well-known RGB space (sRGB, Adobe RGB) and that space's profile travels with it.
|
||||
//! - **Rendering intent**: perceptual or relative colorimetric, as for soft proofing ([`ProofIntent`]).
|
||||
//! - **Black point compensation** (relative colorimetric only): the darkest colour the device holds is
|
||||
//! measured by a round trip of black through the profile, and the page's linear values are scaled
|
||||
//! so its black lands there instead of every shadow below it clipping to the same ink. Linear
|
||||
//! Rec.2020 is linear in XYZ, so with source black 0 and white 1 this is the published BPC mapping
|
||||
//! `Y' = Y·(1 − Yb) + Yb` for a neutral destination black. Perceptual tables carry their own black
|
||||
//! mapping and are left alone.
|
||||
//! - **Print adjustment**: brightness and contrast (−100..100) for prints that come out darker or
|
||||
//! flatter than the screen, on the sRGB-encoded values with black and white held. It is not shown
|
||||
//! on screen (the print is what it corrects).
|
||||
//!
|
||||
//! Everything before the profile conversion is per channel, so it folds into one 65,536-entry table
|
||||
//! indexed by the 16-bit sample; the conversion itself is moxcms's.
|
||||
//!
|
||||
//! Sources: ICC.1:2022 (rendering intents, profile classes); Adobe Systems, "Adobe Black Point
|
||||
//! Compensation" (2006, public white paper) for the black point mapping. Own design otherwise.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, TransformExecutor, TransformOptions};
|
||||
|
||||
use crate::display::MAX_PROFILE_BYTES;
|
||||
use crate::proof::ProofIntent;
|
||||
use crate::space::{NamedSpace, Trc};
|
||||
|
||||
/// Where a print's colours go.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PrintTarget {
|
||||
/// The printer manages colour: the page is sent in this RGB space, with its profile embedded.
|
||||
Managed(NamedSpace),
|
||||
/// A printer / paper ICC profile: the page is converted to the device's values.
|
||||
Profile(Arc<Vec<u8>>),
|
||||
}
|
||||
|
||||
/// How the page is rendered into the target.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PrintColor {
|
||||
pub intent: ProofIntent,
|
||||
/// Map the page's black to the device's darkest (relative colorimetric only).
|
||||
pub black_point_compensation: bool,
|
||||
/// −100..100; 0 leaves the page as rendered.
|
||||
pub brightness: f32,
|
||||
/// −100..100; 0 leaves the page as rendered.
|
||||
pub contrast: f32,
|
||||
}
|
||||
|
||||
impl Default for PrintColor {
|
||||
fn default() -> Self {
|
||||
PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: true, brightness: 0.0, contrast: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixels per conversion batch: bounds the temporary float buffers whatever the page size.
|
||||
const BATCH: usize = 1 << 16;
|
||||
|
||||
/// The darkest black point compensation will lift the page's black to. A profile whose round trip
|
||||
/// says more is broken or not a print profile; leave the page alone rather than wash it out.
|
||||
const MAX_BLACK: f32 = 0.5;
|
||||
|
||||
type Executor = Arc<dyn TransformExecutor<f32> + Send + Sync>;
|
||||
|
||||
/// A page conversion, built once per print job and applied to every page.
|
||||
pub struct PrintTransform {
|
||||
forward: Executor,
|
||||
layout: Layout,
|
||||
channels: usize,
|
||||
icc: Arc<Vec<u8>>,
|
||||
/// 16-bit sample → linear Rec.2020 after the print adjustment and black point compensation.
|
||||
pre: Vec<f32>,
|
||||
black: f32,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PrintTransform {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PrintTransform")
|
||||
.field("channels", &self.channels)
|
||||
.field("black", &self.black)
|
||||
.field("description", &self.description)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
fn guarded<T>(f: impl FnOnce() -> Option<T>) -> Option<T> {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten()
|
||||
}
|
||||
|
||||
fn rendering_intent(intent: ProofIntent) -> RenderingIntent {
|
||||
match intent {
|
||||
ProofIntent::Perceptual => RenderingIntent::Perceptual,
|
||||
ProofIntent::Relative => RenderingIntent::RelativeColorimetric,
|
||||
}
|
||||
}
|
||||
|
||||
/// A slider value as a finite fraction in −1..1 (NaN and infinities count as 0).
|
||||
fn amount(v: f32) -> f32 {
|
||||
if v.is_finite() { (v / 100.0).clamp(-1.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The print adjustment on one sRGB-encoded value (0..1): brightness bends the mid-tones with a
|
||||
/// power curve, contrast steepens (or flattens) them with `e − k·sin(2πe)/2π`, which keeps black,
|
||||
/// mid grey and white where they are and stays monotonic for |k| < 1.
|
||||
pub fn print_adjust(e: f32, brightness: f32, contrast: f32) -> f32 {
|
||||
let e = if e.is_finite() { e.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let (b, c) = (amount(brightness), amount(contrast));
|
||||
let e = if b != 0.0 { e.powf(2f32.powf(-0.5 * b)) } else { e };
|
||||
let k = 0.6 * c;
|
||||
let tau = std::f32::consts::TAU;
|
||||
(e - k * (tau * e).sin() / tau).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
impl PrintTransform {
|
||||
/// Build the conversion for a target. Errors say what's wrong with the profile; nothing here
|
||||
/// panics on a hostile one.
|
||||
pub fn new(target: &PrintTarget, color: &PrintColor) -> Result<PrintTransform, String> {
|
||||
let icc = match target {
|
||||
PrintTarget::Managed(n) => Arc::new(crate::icc::write_named(*n)),
|
||||
PrintTarget::Profile(bytes) => Arc::clone(bytes),
|
||||
};
|
||||
if icc.len() > MAX_PROFILE_BYTES {
|
||||
return Err("the profile is too large".into());
|
||||
}
|
||||
let dev = guarded(|| ColorProfile::new_from_slice(&icc).ok()).ok_or("not a valid ICC profile")?;
|
||||
let (layout, channels) = match dev.color_space {
|
||||
DataColorSpace::Rgb => (Layout::Rgb, 3),
|
||||
DataColorSpace::Cmyk => (Layout::Rgba, 4),
|
||||
DataColorSpace::Gray => (Layout::Gray, 1),
|
||||
_ => return Err("only RGB, CMYK and grey printer profiles can be printed to".into()),
|
||||
};
|
||||
let lin = crate::icc::linear_rec2020_profile();
|
||||
let opts = |i| TransformOptions { rendering_intent: i, ..TransformOptions::default() };
|
||||
let forward: Executor = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(rendering_intent(color.intent))).ok())
|
||||
.ok_or("can't convert to this profile")?;
|
||||
|
||||
let black = if color.black_point_compensation && color.intent == ProofIntent::Relative {
|
||||
let back: Executor = guarded(|| dev.create_transform_f32(layout, &lin, Layout::Rgb, opts(RenderingIntent::RelativeColorimetric)).ok())
|
||||
.ok_or("can't convert from this profile")?;
|
||||
device_black(&forward, &back, channels)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let decode = Trc::Rec709.lut16();
|
||||
let (b, c) = (color.brightness, color.contrast);
|
||||
let adjust = amount(b) != 0.0 || amount(c) != 0.0;
|
||||
let pre = decode
|
||||
.iter()
|
||||
.map(|&lin| {
|
||||
let lin = if adjust {
|
||||
let e = lightcraft_color::transfer::linear_to_srgb(lin.clamp(0.0, 1.0));
|
||||
lightcraft_color::transfer::srgb_to_linear(print_adjust(e, b, c))
|
||||
} else {
|
||||
lin.clamp(0.0, 1.0)
|
||||
};
|
||||
lin * (1.0 - black) + black
|
||||
})
|
||||
.collect();
|
||||
let description = crate::icc::parse(&icc).and_then(|i| i.description);
|
||||
Ok(PrintTransform { forward, layout, channels, icc, pre, black, description })
|
||||
}
|
||||
|
||||
/// Samples per pixel of the converted page: 3 (RGB), 4 (CMYK) or 1 (grey).
|
||||
pub fn channels(&self) -> usize {
|
||||
self.channels
|
||||
}
|
||||
|
||||
/// The profile the converted samples are in: embed it with them.
|
||||
pub fn icc(&self) -> &Arc<Vec<u8>> {
|
||||
&self.icc
|
||||
}
|
||||
|
||||
/// Whether the samples are CMYK (PDF readers show CMYK images inverted unless told otherwise,
|
||||
/// so writers need to know).
|
||||
pub fn is_cmyk(&self) -> bool {
|
||||
self.layout == Layout::Rgba
|
||||
}
|
||||
|
||||
/// The linear black black point compensation lifts the page's black to (0 when off).
|
||||
pub fn black(&self) -> f32 {
|
||||
self.black
|
||||
}
|
||||
|
||||
/// The profile's description, for the job's settings and error messages.
|
||||
pub fn description(&self) -> Option<&str> {
|
||||
self.description.as_deref()
|
||||
}
|
||||
|
||||
/// Convert a page: `rgb` is interleaved 16-bit RGB in the BT.2020 / BT.709 encoding; the result
|
||||
/// is interleaved 16-bit device samples ([`PrintTransform::channels`] per pixel).
|
||||
pub fn apply(&self, rgb: &[u16]) -> Result<Vec<u16>, String> {
|
||||
if !rgb.len().is_multiple_of(3) {
|
||||
return Err("the page isn't whole RGB pixels".into());
|
||||
}
|
||||
let pixels = rgb.len() / 3;
|
||||
let ch = self.channels;
|
||||
let out_len = pixels.checked_mul(ch).ok_or("the page is too large")?;
|
||||
let mut out = vec![0u16; out_len];
|
||||
let run = |src: &[u16], dst: &mut [u16]| -> Option<()> {
|
||||
let lin: Vec<f32> = src.iter().map(|&v| self.pre.get(usize::from(v)).copied().unwrap_or(0.0)).collect();
|
||||
let mut dev = vec![0f32; dst.len()];
|
||||
self.forward.transform(&lin, &mut dev).ok()?;
|
||||
for (d, v) in dst.iter_mut().zip(dev) {
|
||||
let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
*d = (v * 65535.0 + 0.5) as u16;
|
||||
}
|
||||
Some(())
|
||||
};
|
||||
let src = rgb.chunks(BATCH * 3);
|
||||
let dst = out.chunks_mut(BATCH * ch);
|
||||
#[cfg(feature = "parallel")]
|
||||
let ok = {
|
||||
use rayon::prelude::*;
|
||||
let pairs: Vec<_> = src.zip(dst).collect();
|
||||
pairs.into_par_iter().all(|(s, d)| guarded(|| run(s, d)).is_some())
|
||||
};
|
||||
#[cfg(not(feature = "parallel"))]
|
||||
let ok = src.zip(dst).all(|(s, d)| guarded(|| run(s, d)).is_some());
|
||||
if ok { Ok(out) } else { Err("the profile's tables can't be applied".into()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The darkest the device holds, as linear Rec.2020 luminance: black there and back, relative
|
||||
/// colorimetric both ways (for a device that reaches true black, 0).
|
||||
fn device_black(forward: &Executor, back: &Executor, channels: usize) -> f32 {
|
||||
let mut device = vec![0f32; channels];
|
||||
let mut lin = [0f32; 3];
|
||||
let ok = guarded(|| {
|
||||
forward.transform(&[0.0; 3], &mut device).ok()?;
|
||||
back.transform(&device, &mut lin).ok()
|
||||
});
|
||||
if ok.is_none() {
|
||||
return 0.0;
|
||||
}
|
||||
// BT.2020 luminance weights
|
||||
let y = 0.2627 * lin[0] + 0.6780 * lin[1] + 0.0593 * lin[2];
|
||||
if y.is_finite() { y.clamp(0.0, MAX_BLACK) } else { 0.0 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use moxcms::ToneReprCurve;
|
||||
|
||||
/// A page of one 16-bit pixel per linear grey level.
|
||||
fn greys(levels: &[f32]) -> Vec<u16> {
|
||||
levels.iter().flat_map(|&l| [(Trc::Rec709.from_linear(l) * 65535.0).round() as u16; 3]).collect()
|
||||
}
|
||||
|
||||
fn convert(target: &PrintTarget, color: PrintColor, levels: &[f32]) -> Vec<u16> {
|
||||
PrintTransform::new(target, &color).unwrap().apply(&greys(levels)).unwrap()
|
||||
}
|
||||
|
||||
/// An RGB print profile whose ink can't get darker than linear 0.03: device 0 is 3 % grey.
|
||||
fn raised_black_profile() -> Arc<Vec<u8>> {
|
||||
let mut p = ColorProfile::new_from_slice(&crate::icc::write_named(NamedSpace::Srgb)).unwrap();
|
||||
let (g, e) = (2.2f32, 0.03f32);
|
||||
let a = (1.0 - e).powf(1.0 / g);
|
||||
// ICC parametric type 4: Y = (aX + b)^g + e for X >= d, Y = cX + f below
|
||||
let trc = ToneReprCurve::Parametric(vec![g, a, 0.0, 0.0, 0.0, e, e]);
|
||||
p.red_trc = Some(trc.clone());
|
||||
p.green_trc = Some(trc.clone());
|
||||
p.blue_trc = Some(trc);
|
||||
Arc::new(p.encode().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_srgb_keeps_black_grey_and_white() {
|
||||
let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::Srgb), &PrintColor::default()).unwrap();
|
||||
assert_eq!(t.channels(), 3);
|
||||
assert_eq!(crate::icc::parse(t.icc()).and_then(|i| i.named), Some(NamedSpace::Srgb));
|
||||
let out = t.apply(&greys(&[0.0, 0.18, 1.0])).unwrap();
|
||||
let mid = (lightcraft_color::transfer::linear_to_srgb(0.18) * 65535.0) as i32;
|
||||
assert!(out[..3].iter().all(|&v| v < 64), "{out:?}");
|
||||
assert!(out[3..6].iter().all(|&v| (i32::from(v) - mid).abs() < 300), "{out:?} vs {mid}");
|
||||
assert!(out[6..].iter().all(|&v| v > 65400), "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adobe_rgb_target_embeds_its_profile() {
|
||||
let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::AdobeRgb), &PrintColor::default()).unwrap();
|
||||
assert_eq!(crate::icc::parse(t.icc()).and_then(|i| i.named), Some(NamedSpace::AdobeRgb));
|
||||
// a saturated Rec.2020 green is outside Adobe RGB: it comes out at the space's edge, not wrapped
|
||||
let px = [0u16, 65535, 0];
|
||||
let out = t.apply(&px).unwrap();
|
||||
assert!(out[1] > 60000 && out[0] < 8000 && out[2] < 8000, "{out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brightness_lifts_the_mid_tones_and_holds_the_ends() {
|
||||
let target = PrintTarget::Managed(NamedSpace::Srgb);
|
||||
let levels = [0.0, 0.18, 1.0];
|
||||
let plain = convert(&target, PrintColor::default(), &levels);
|
||||
let bright = convert(&target, PrintColor { brightness: 50.0, ..PrintColor::default() }, &levels);
|
||||
let dark = convert(&target, PrintColor { brightness: -50.0, ..PrintColor::default() }, &levels);
|
||||
assert!(bright[3] > plain[3] + 2000 && dark[3] + 2000 < plain[3], "{dark:?} {plain:?} {bright:?}");
|
||||
for v in [&plain, &bright, &dark] {
|
||||
assert!(v[0] < 64 && v[6] > 65400, "{v:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contrast_spreads_shadows_and_highlights_and_stays_monotonic() {
|
||||
let target = PrintTarget::Managed(NamedSpace::Srgb);
|
||||
let levels = [0.03, 0.6];
|
||||
let plain = convert(&target, PrintColor::default(), &levels);
|
||||
let more = convert(&target, PrintColor { contrast: 60.0, ..PrintColor::default() }, &levels);
|
||||
assert!(more[0] < plain[0] && more[3] > plain[3], "{plain:?} {more:?}");
|
||||
for c in [-100.0, 100.0] {
|
||||
for b in [-100.0, 100.0] {
|
||||
let ramp: Vec<f32> = (0..=64).map(|i| i as f32 / 64.0).map(|e| print_adjust(e, b, c)).collect();
|
||||
assert!(ramp.windows(2).all(|w| w[1] >= w[0]), "b {b} c {c}: {ramp:?}");
|
||||
assert!(ramp[0] == 0.0 && (ramp[64] - 1.0).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_point_compensation_keeps_shadow_detail_above_the_inks_black() {
|
||||
let target = PrintTarget::Profile(raised_black_profile());
|
||||
let shadows = [0.0, 0.006, 0.012, 0.018, 0.024, 0.5, 1.0];
|
||||
let relative = PrintColor { intent: ProofIntent::Relative, black_point_compensation: false, ..PrintColor::default() };
|
||||
let clipped = convert(&target, relative, &shadows);
|
||||
let kept = convert(&target, PrintColor { black_point_compensation: true, ..relative }, &shadows);
|
||||
let reds = |v: &[u16]| v.chunks(3).map(|p| p[0]).collect::<Vec<_>>();
|
||||
let (clipped, kept) = (reds(&clipped), reds(&kept));
|
||||
// everything below the ink's black prints as (within rounding) the same device value without
|
||||
// compensation …
|
||||
assert!(clipped[..5].iter().all(|&v| v <= clipped[0] + 16), "{clipped:?}");
|
||||
// … and as distinct, rising values with it; white still prints as white
|
||||
assert!(kept[..5].windows(2).all(|w| w[1] > w[0]) && kept[4] > kept[0] + 1000, "{kept:?}");
|
||||
assert!(kept[6] > 65400 && clipped[6] > 65400, "{kept:?} {clipped:?}");
|
||||
let t = PrintTransform::new(&target, &PrintColor { black_point_compensation: true, ..relative }).unwrap();
|
||||
assert!((t.black() - 0.03).abs() < 0.01, "{}", t.black());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_point_compensation_is_relative_colorimetric_only() {
|
||||
let target = PrintTarget::Profile(raised_black_profile());
|
||||
let levels = [0.0, 0.01, 0.2];
|
||||
let with = convert(&target, PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: true, ..PrintColor::default() }, &levels);
|
||||
let without =
|
||||
convert(&target, PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: false, ..PrintColor::default() }, &levels);
|
||||
assert_eq!(with, without);
|
||||
let t = PrintTransform::new(&target, &PrintColor { intent: ProofIntent::Perceptual, ..PrintColor::default() }).unwrap();
|
||||
assert_eq!(t.black(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_is_an_error_not_a_panic() {
|
||||
let color = PrintColor::default();
|
||||
assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![])), &color).is_err());
|
||||
assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![0u8; 400])), &color).is_err());
|
||||
assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![0u8; MAX_PROFILE_BYTES + 1])), &color).is_err());
|
||||
let good = crate::icc::write_named(NamedSpace::DisplayP3);
|
||||
for i in (0..good.len()).step_by(7) {
|
||||
let mut b = good.clone();
|
||||
b[i] ^= 0x5a;
|
||||
if let Ok(t) = PrintTransform::new(&PrintTarget::Profile(Arc::new(b)), &PrintColor { intent: ProofIntent::Relative, ..color }) {
|
||||
let _ = t.apply(&greys(&[0.0, 0.5, 1.0]));
|
||||
}
|
||||
}
|
||||
let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::Srgb), &PrintColor { brightness: f32::NAN, contrast: f32::INFINITY, ..color })
|
||||
.unwrap();
|
||||
assert!(t.apply(&[1, 2]).is_err(), "half a pixel");
|
||||
assert_eq!(t.apply(&greys(&[0.18])).unwrap(), convert(&PrintTarget::Managed(NamedSpace::Srgb), color, &[0.18]));
|
||||
assert!(print_adjust(f32::NAN, 50.0, 50.0).is_finite());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -31,7 +31,7 @@ fn files(dir: &Path) -> Vec<PathBuf> {
|
||||
fn corpus_images_decode() {
|
||||
let dir = corpus_root().join("images");
|
||||
if !dir.is_dir() {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", dir.display());
|
||||
return;
|
||||
}
|
||||
let (mut ok, mut unsupported) = (0, 0);
|
||||
@@ -57,7 +57,7 @@ fn corpus_images_decode() {
|
||||
fn corpus_pngsuite() {
|
||||
let dir = corpus_root().join("pngsuite");
|
||||
if !dir.is_dir() {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", dir.display());
|
||||
return;
|
||||
}
|
||||
let mut n = 0;
|
||||
@@ -83,7 +83,7 @@ fn corpus_pngsuite() {
|
||||
fn corpus_raw_is_routed() {
|
||||
let dir = corpus_root().join("raw");
|
||||
if !dir.is_dir() {
|
||||
eprintln!("skip: {} absent", dir.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", dir.display());
|
||||
return;
|
||||
}
|
||||
for p in files(&dir) {
|
||||
@@ -103,7 +103,7 @@ fn system_icc_profiles() {
|
||||
use lightcraft_codecs::icc::{IccColorModel, IccKind, parse};
|
||||
let dir = Path::new("/System/Library/ColorSync/Profiles");
|
||||
if !dir.is_dir() {
|
||||
eprintln!("skip: no system ICC profiles");
|
||||
lightcraft_testkit::skip!(System, "no system ICC profiles");
|
||||
return;
|
||||
}
|
||||
let expect = [
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Non-finite samples in procedural 32-bit float TIFFs (issue #1952, item 6).
|
||||
|
||||
use lightcraft_codecs::{icc, *};
|
||||
|
||||
fn tiff(samples: &[f32], channels: u8, profile: Option<&[u8]>) -> Vec<u8> {
|
||||
encode_tiff(
|
||||
&EncodeImage::new((samples.len() / channels as usize) as u32, 1, channels, Samples::F32(samples)),
|
||||
TiffCompression::None,
|
||||
&EncodeMeta { icc: profile, ..Default::default() },
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_tiff_nonfinite_rgb_clips_without_losing_finite_hdr() {
|
||||
let samples =
|
||||
[[f32::INFINITY; 3], [f32::NAN; 3], [f32::NEG_INFINITY; 3], [-0.25; 3], [0.25, 0.5, 0.75], [2.0, 4.0, 8.0], [f32::INFINITY, 0.5, f32::NAN]];
|
||||
let bytes = tiff(samples.as_flattened(), 3, None);
|
||||
let decoded = decode(&bytes, DecodeOptions::default()).unwrap();
|
||||
assert!(decoded.float);
|
||||
assert_eq!(decoded.bit_depth, 32);
|
||||
assert_eq!(decoded.image.data, [[1.0; 3], [0.0; 3], [0.0; 3], [-0.25; 3], [0.25, 0.5, 0.75], [2.0, 4.0, 8.0], [1.0, 0.5, 0.0]]);
|
||||
let display = decoded.to_srgb8();
|
||||
assert_eq!(display.data[0], [255; 4]);
|
||||
for pixel in &display.data[1..4] {
|
||||
assert_eq!(*pixel, [0, 0, 0, 255]);
|
||||
}
|
||||
assert_eq!(display.data[6], [255, 188, 0, 255]);
|
||||
assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
|
||||
|
||||
// A box-filtered pair must average clipped white with its ordinary neighbor.
|
||||
let pair = tiff(&[f32::INFINITY, f32::INFINITY, f32::INFINITY, 0.25, 0.25, 0.25], 3, None);
|
||||
let small = decode(&pair, DecodeOptions::fit(1, 1)).unwrap();
|
||||
assert_eq!(small.image.data, [[0.625; 3]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_tiff_nonfinite_gray_and_icc_curves_match_clipped_samples() {
|
||||
let samples = [f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.25, 1.0, 2.0, -0.25];
|
||||
let clipped = [1.0, 0.0, 0.0, 0.25, 1.0, 2.0, -0.25];
|
||||
let profiles = [
|
||||
None,
|
||||
Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Linear)),
|
||||
Some(icc::write_named(NamedSpace::Srgb)),
|
||||
Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Gamma(2.0))),
|
||||
Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Table(vec![0, 16384, 65535]))),
|
||||
Some(moxcms::ColorProfile::new_gray_with_gamma(2.0).encode().unwrap()),
|
||||
Some(b"invalid ICC profile".to_vec()),
|
||||
];
|
||||
for channels in [1, 3] {
|
||||
let samples: Vec<_> = samples.iter().flat_map(|v| std::iter::repeat_n(*v, channels as usize)).collect();
|
||||
let clipped: Vec<_> = clipped.iter().flat_map(|v| std::iter::repeat_n(*v, channels as usize)).collect();
|
||||
for profile in &profiles {
|
||||
let profile = profile.as_deref();
|
||||
let decoded = decode(&tiff(&samples, channels, profile), DecodeOptions::default()).unwrap();
|
||||
let reference = decode(&tiff(&clipped, channels, profile), DecodeOptions::default()).unwrap();
|
||||
assert_eq!(decoded.grayscale, channels == 1);
|
||||
assert_eq!(decoded.image, reference.image, "channels={channels}, space={:?}", decoded.space);
|
||||
assert_eq!(decoded.to_srgb8(), reference.to_srgb8());
|
||||
assert!(decoded.image.data[5].iter().all(|v| *v > 1.0), "finite HDR survives the curve");
|
||||
assert!(decoded.image.data[6].iter().all(|v| *v < 0.0), "finite negative samples survive the curve");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_tiff_nonfinite_alpha_is_finite_and_bounded() {
|
||||
let samples = [
|
||||
[f32::INFINITY, f32::INFINITY, f32::INFINITY, f32::INFINITY],
|
||||
[f32::NAN, f32::NEG_INFINITY, 0.5, f32::NAN],
|
||||
[0.25, 0.5, 2.0, f32::NEG_INFINITY],
|
||||
[2.0, 3.0, 4.0, 0.5],
|
||||
];
|
||||
let decoded = decode(&tiff(samples.as_flattened(), 4, None), DecodeOptions::default()).unwrap();
|
||||
assert_eq!(decoded.alpha.as_ref().unwrap().data, [1.0, 0.0, 0.0, 0.5]);
|
||||
assert_eq!(decoded.image.data, [[1.0; 3], [0.0, 0.0, 0.5], [0.25, 0.5, 2.0], [2.0, 3.0, 4.0]]);
|
||||
assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
|
||||
|
||||
// Associated alpha takes the same path before unpremultiplication.
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
{
|
||||
use tiff::encoder::{TiffEncoder, colortype};
|
||||
let mut encoder = TiffEncoder::new(&mut cursor).unwrap();
|
||||
let mut image = encoder.new_image::<colortype::RGBA32Float>(2, 1).unwrap();
|
||||
image.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[1u16][..]).unwrap();
|
||||
image.write_data(&[f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.5, 1.0, 2.0, 3.0, 0.5]).unwrap();
|
||||
}
|
||||
let decoded = decode(&cursor.into_inner(), DecodeOptions::default()).unwrap();
|
||||
assert_eq!(decoded.alpha.as_ref().unwrap().data, [0.5, 0.5]);
|
||||
assert_eq!(decoded.image.data, [[2.0, 0.0, 0.0], [2.0, 4.0, 6.0]]);
|
||||
}
|
||||
|
||||
fn lut_profile() -> Vec<u8> {
|
||||
use moxcms::{ColorProfile, LutMultidimensionalType, LutWarehouse, Matrix3d, ToneReprCurve, Vector3d, Xyzd};
|
||||
let mut profile = ColorProfile::new_srgb();
|
||||
profile.cicp = None;
|
||||
profile.red_colorant = Xyzd::default();
|
||||
profile.green_colorant = Xyzd::default();
|
||||
profile.blue_colorant = Xyzd::default();
|
||||
profile.red_trc = None;
|
||||
profile.green_trc = None;
|
||||
profile.blue_trc = None;
|
||||
// A procedural mAB profile without a CLUT: linear RGB to D50 XYZ, then identity curves.
|
||||
profile.lut_a_to_b_colorimetric = Some(LutWarehouse::Multidimensional(LutMultidimensionalType {
|
||||
num_input_channels: 3,
|
||||
num_output_channels: 3,
|
||||
grid_points: [0; 16],
|
||||
clut: None,
|
||||
a_curves: vec![],
|
||||
m_curves: vec![ToneReprCurve::Parametric(vec![1.0]); 3],
|
||||
matrix: Matrix3d { v: NamedSpace::Srgb.to_xyz_d50().0.map(|r| r.map(|v| v * 32768.0 / 65535.0)) },
|
||||
b_curves: vec![ToneReprCurve::Parametric(vec![1.0]); 3],
|
||||
bias: Vector3d::default(),
|
||||
}));
|
||||
profile.encode().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn float_tiff_nonfinite_cms_samples_match_clipped_samples() {
|
||||
let profile = lut_profile();
|
||||
let samples = [f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.25, 0.5, 0.75];
|
||||
let clipped = [1.0, 0.0, 0.0, 0.25, 0.5, 0.75];
|
||||
let decoded = decode(&tiff(&samples, 3, Some(&profile)), DecodeOptions::default()).unwrap();
|
||||
let reference = decode(&tiff(&clipped, 3, Some(&profile)), DecodeOptions::default()).unwrap();
|
||||
assert_eq!(decoded.space.origin, SpaceOrigin::IccCms);
|
||||
assert_eq!(decoded.image, reference.image);
|
||||
assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite()));
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::DevelopSettings;
|
||||
|
||||
@@ -185,8 +186,7 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value {
|
||||
/// Apply a partial settings object with an amount (1.0 = 100 %). Out-of-range values are clamped
|
||||
/// through the control specs.
|
||||
pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings {
|
||||
let base = s.to_json();
|
||||
let patch = if (amount - 1.0).abs() < 1e-9 { partial.clone() } else { scale_patch(&base, partial, amount) };
|
||||
let patch = if (amount - 1.0).abs() < 1e-9 { Cow::Borrowed(partial) } else { Cow::Owned(scale_patch(&s.to_json(), partial, amount)) };
|
||||
// (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`)
|
||||
let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone());
|
||||
for c in crate::controls::CONTROLS {
|
||||
|
||||
@@ -900,11 +900,24 @@ pub struct BrushStroke {
|
||||
pub density: f64,
|
||||
pub erase: bool,
|
||||
pub auto_mask: bool,
|
||||
/// Pen pressure (0..1) at each of `points`, recorded when the brush's pressure switch is on: it scales each dab's
|
||||
/// radius and flow. Empty (the default, a mouse or the switch off) is full pressure everywhere; a point past the
|
||||
/// end of a shorter list takes the last value.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub pressure: Vec<f32>,
|
||||
}
|
||||
|
||||
impl BrushStroke {
|
||||
/// The pressure at point `i` (0..1; 1 without pressure data, a hostile value clamped, NaN as 1).
|
||||
pub fn pressure_at(&self, i: usize) -> f32 {
|
||||
let p = self.pressure.get(i).or(self.pressure.last()).copied().unwrap_or(1.0);
|
||||
if p.is_nan() { 1.0 } else { p.clamp(0.0, 1.0) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BrushStroke {
|
||||
fn default() -> Self {
|
||||
Self { points: Vec::new(), size: 0.03, feather: 50.0, flow: 100.0, density: 100.0, erase: false, auto_mask: false }
|
||||
Self { points: Vec::new(), size: 0.03, feather: 50.0, flow: 100.0, density: 100.0, erase: false, auto_mask: false, pressure: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ description = "LightCraft engine: session, command registry, history, render job
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
# Paths stored in a Lightroom catalog are followed only to local files or the catalog's share.
|
||||
craft-docpath = { workspace = true }
|
||||
lightcraft-geom = { workspace = true }
|
||||
lightcraft-color = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
@@ -29,6 +31,7 @@ lightcraft-faces = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
lightcraft-merge = { workspace = true }
|
||||
lightcraft-geocode = { workspace = true }
|
||||
lightcraft-slideshow = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -51,6 +54,8 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g
|
||||
rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"]
|
||||
|
||||
[dev-dependencies]
|
||||
# tests record why they skip (`cargo xtask ci` lists the skips)
|
||||
lightcraft-testkit = { workspace = true }
|
||||
# HDR JPEG XL tests read the file's colour encoding
|
||||
jxl-oxide = { workspace = true }
|
||||
# Lossless synthetic HEIC files (the import test; decoded when the codecs' `heif` feature is on).
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Recipe from craft-fonts' docs/integration.md; craft-fonts is never a Cargo dependency, and this
|
||||
//! script only reads a local directory (no network).
|
||||
//!
|
||||
//! wasm32 (the web build) embeds only the UI font, BIZ UDPGothic Regular (~4.5 MB), to keep the
|
||||
//! wasm32 embeds BIZ UDPGothic Regular (~4.5 MB) and Noto Sans Thai (~214 KiB), to keep the
|
||||
//! `.wasm` within the web size budget (Cloudflare serves at most 25 MiB per file); the export
|
||||
//! watermark falls back to it there. Native builds embed every font.
|
||||
use std::fmt::Write as _;
|
||||
@@ -69,6 +69,7 @@ fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> {
|
||||
let mut out = String::new();
|
||||
let mut has_japanese_ui = false;
|
||||
let mut has_chinese_ui = false;
|
||||
let mut has_thai_ui = false;
|
||||
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
|
||||
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
@@ -76,7 +77,8 @@ fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> {
|
||||
};
|
||||
has_japanese_ui |= (*family, *style) == ("BIZ UDPGothic", "Regular");
|
||||
has_chinese_ui |= (*family, *style) == ("Noto Sans CJK SC", "Regular") && scripts.split(',').any(|script| script.trim() == "Hans");
|
||||
if wasm && (*family, *style) != ("BIZ UDPGothic", "Regular") {
|
||||
has_thai_ui |= (*family, *style) == ("Noto Sans Thai", "Regular") && scripts.split(',').any(|script| script.trim() == "Thai");
|
||||
if wasm && !matches!((*family, *style), ("BIZ UDPGothic" | "Noto Sans Thai", "Regular")) {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
|
||||
@@ -89,6 +91,9 @@ fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> {
|
||||
path.display().to_string(),
|
||||
);
|
||||
}
|
||||
if required && !has_thai_ui {
|
||||
return Err("required Thai UI face Noto Sans Thai Regular (Thai) is missing from fonts/manifest.txt".into());
|
||||
}
|
||||
if required && !has_japanese_ui {
|
||||
return Err("required Japanese UI face BIZ UDPGothic Regular is missing from fonts/manifest.txt".into());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
//! Measure where a camera's own JPEG sits inside its raw sensor data, and which edge bands of that data hold no
|
||||
//! picture: `cargo xtask raw-frame [--json] FILE…` (or `cargo run --release -p lightcraft-engine --example raw_frame --
|
||||
//! [--json] FILE…`). It checks the crop any decoder applies against the camera's own framing, and measures the entries
|
||||
//! of Nikon `MEASURED_BODIES` (`crates/raw/src/vendor/nef.rs`) for bodies that record
|
||||
//! no crop. It works for any colour-filter raw that embeds a full-size camera JPEG (checked on Nikon, Sony, Canon CR2 /
|
||||
//! CR3, Pentax and Samsung files, where it finds the decoder's crop); bodies with a smaller embedded preview (most
|
||||
//! Fujifilm, Panasonic and Olympus files) get the edge bands only.
|
||||
//!
|
||||
//! **Frame.** For each raw it aligns the largest embedded JPEG with the sensor data: both are reduced to half-size
|
||||
//! luminance (the raw by summing each 2×2 colour-filter block), and every offset that keeps the JPEG inside the data is
|
||||
//! scored by the correlation of their gradient magnitudes. The best offset is refined to sub-pixel precision; a peak
|
||||
//! that is weak, ambiguous or between two pixels is flagged. Files are grouped by model and data size (bit depths
|
||||
//! pooled: both read out the same area); when 3 clean files agree, the group prints its table entries (siblings of the
|
||||
//! same readout that agree on the same frame count towards the 3; see `MIN_FILES`). Use several
|
||||
//! files with detail across the frame: a blank sky, fog or a lens cap has nothing to align on (low score). The JPEG
|
||||
//! must be the camera's full-size one, unrotated and without in-camera geometry corrections (distortion control off);
|
||||
//! a smaller preview is not aligned.
|
||||
//!
|
||||
//! **Edge bands.** From each edge inwards (at most 128 lines) it counts lines that hold no picture: *empty or dark*
|
||||
//! (mean below 1% of the range over black) or *masked* (flat, patternless and above black: even and odd samples
|
||||
//! alike within 4 standard errors or within 0.4% of white, since some filler holds a small fixed offset between the
|
||||
//! two, as on the D90 and D5000; spread no more than the line's own neighbour noise and under 0.4% of white). A
|
||||
//! band that the decoder's crop still includes is developed as picture: the coloured bars of issue #976. This is a
|
||||
//! survey aid: a dark band can also be deep shadow, so read it with the frame.
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use lightcraft_codecs::DecodeOptions;
|
||||
use lightcraft_raw::RawData;
|
||||
|
||||
/// Smallest correlation peak worth trusting, and how far it must stand above the best offset outside its 3×3
|
||||
/// neighbourhood (measured: 0.75–0.88 on detailed D5100 / D7000 frames, runners-up ≥ 0.05 lower).
|
||||
const MIN_SCORE: f64 = 0.5;
|
||||
const MIN_MARGIN: f64 = 0.02;
|
||||
/// Largest sub-pixel distance from a whole pixel accepted as a clean alignment.
|
||||
const MAX_FRACTION: f64 = 0.35;
|
||||
/// Search at most this many half-size offsets in each direction (384 px of margin around the JPEG: Canon bodies keep
|
||||
/// up to ~280 px of masked border).
|
||||
const MAX_SEARCH: usize = 192;
|
||||
/// Windows of up to this many offsets are searched exhaustively (every Nikon body); larger ones coarse to fine.
|
||||
const FULL_SEARCH: usize = 2048;
|
||||
/// Edge bands are counted up to this many lines from each edge.
|
||||
const MAX_BAND: usize = 128;
|
||||
/// Clean, agreeing files an entry needs: of one model and data size (bit depths pooled: both read out the same area),
|
||||
/// or, with at least one of its own, together with sibling models of the same readout (data and JPEG size) whose own
|
||||
/// files agree on the same frame.
|
||||
const MIN_FILES: usize = 3;
|
||||
|
||||
struct Frame {
|
||||
jpeg: (usize, usize),
|
||||
/// Sub-pixel offset of the JPEG's top left corner in sensor pixels.
|
||||
at: (f64, f64),
|
||||
score: f64,
|
||||
margin: f64,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
fn whole(&self) -> (usize, usize) {
|
||||
(self.at.0.round().max(0.0) as usize, self.at.1.round().max(0.0) as usize)
|
||||
}
|
||||
fn clean(&self) -> bool {
|
||||
let frac = |v: f64| (v - v.round()).abs();
|
||||
self.score >= MIN_SCORE && self.margin >= MIN_MARGIN && frac(self.at.0) <= MAX_FRACTION && frac(self.at.1) <= MAX_FRACTION
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines without picture at one edge: empty or dark ones, then masked ones (or the other way round), from the edge.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Band {
|
||||
dark: usize,
|
||||
masked: usize,
|
||||
}
|
||||
|
||||
impl Band {
|
||||
fn total(self) -> usize {
|
||||
self.dark + self.masked
|
||||
}
|
||||
}
|
||||
|
||||
struct Measured {
|
||||
model: String,
|
||||
size: (usize, usize),
|
||||
bits: u32,
|
||||
black: f64,
|
||||
white: f64,
|
||||
/// The crop the decoder applies, in sensor-data coordinates: x, y, width, height.
|
||||
crop: (usize, usize, usize, usize),
|
||||
/// Left, right, top, bottom.
|
||||
bands: [Band; 4],
|
||||
frame: Result<Frame, String>,
|
||||
/// With a clean frame: what the crop holds beyond it.
|
||||
beyond: Option<[Beyond; 4]>,
|
||||
}
|
||||
|
||||
impl Measured {
|
||||
/// Lines of each edge band (left, right, top, bottom) inside the decoder's crop: developed as picture.
|
||||
fn in_crop(&self) -> [usize; 4] {
|
||||
let (w, h) = self.size;
|
||||
let (x, y, cw, ch) = self.crop;
|
||||
let [l, r, t, b] = self.bands.map(Band::total);
|
||||
[l.saturating_sub(x), (x + cw).saturating_sub(w - r.min(w)), t.saturating_sub(y), (y + ch).saturating_sub(h - b.min(h))]
|
||||
}
|
||||
}
|
||||
|
||||
/// What the decoder's crop holds outside the camera's JPEG frame, per side (left, right, top, bottom): lines that are
|
||||
/// masked (flat above black: a coloured bar), dark (empty or optical black: a dark edge) or picture (trimmed by the
|
||||
/// camera, harmless).
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Beyond {
|
||||
masked: usize,
|
||||
dark: usize,
|
||||
picture: usize,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let json = args.iter().any(|a| a == "--json");
|
||||
let files: Vec<&String> = args.iter().filter(|a| *a != "--json").collect();
|
||||
if files.is_empty() {
|
||||
eprintln!("usage: raw_frame [--json] FILE… (raw files whose largest embedded JPEG is the camera's full-size one)");
|
||||
std::process::exit(2);
|
||||
}
|
||||
// model, data size, JPEG size -> (bit depth, frame) of each file
|
||||
let mut groups: BTreeMap<(String, (usize, usize), (usize, usize)), Vec<(u32, Frame)>> = BTreeMap::new();
|
||||
for f in files {
|
||||
let m = match measure(f) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
if json {
|
||||
println!("{}", serde_json::json!({ "file": f, "error": e }));
|
||||
} else {
|
||||
println!("{f}: skipped: {e}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let in_crop = m.in_crop();
|
||||
if json {
|
||||
let band = |b: Band| serde_json::json!({ "dark": b.dark, "masked": b.masked });
|
||||
let frame = match &m.frame {
|
||||
Ok(fr) => serde_json::json!({
|
||||
"jpeg": [fr.jpeg.0, fr.jpeg.1], "at": [fr.at.0, fr.at.1], "score": fr.score, "margin": fr.margin, "clean": fr.clean()
|
||||
}),
|
||||
Err(e) => serde_json::json!({ "error": e }),
|
||||
};
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"file": f, "model": m.model, "size": [m.size.0, m.size.1], "bits": m.bits, "black": m.black, "white": m.white,
|
||||
"crop": [m.crop.0, m.crop.1, m.crop.2, m.crop.3],
|
||||
"bands": { "left": band(m.bands[0]), "right": band(m.bands[1]), "top": band(m.bands[2]), "bottom": band(m.bands[3]) },
|
||||
"in_crop": { "left": in_crop[0], "right": in_crop[1], "top": in_crop[2], "bottom": in_crop[3] },
|
||||
"beyond_frame": m.beyond.map(|b| {
|
||||
let side = |b: Beyond| serde_json::json!({ "masked": b.masked, "dark": b.dark, "picture": b.picture });
|
||||
serde_json::json!({ "left": side(b[0]), "right": side(b[1]), "top": side(b[2]), "bottom": side(b[3]) })
|
||||
}),
|
||||
"frame": frame,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
let frame = match &m.frame {
|
||||
Ok(fr) => format!(
|
||||
"JPEG {}×{} at ({:.2}, {:.2}), score {:.3}, margin {:.3}{}",
|
||||
fr.jpeg.0,
|
||||
fr.jpeg.1,
|
||||
fr.at.0,
|
||||
fr.at.1,
|
||||
fr.score,
|
||||
fr.margin,
|
||||
if fr.clean() { "" } else { " (not clean: weak, ambiguous or between pixels)" }
|
||||
),
|
||||
Err(e) => format!("no frame: {e}"),
|
||||
};
|
||||
println!("{f}: {} {}×{} {}-bit, {frame}", m.model, m.size.0, m.size.1, m.bits);
|
||||
let side = |name: &str, b: Band| match (b.dark, b.masked) {
|
||||
(0, 0) => None,
|
||||
(d, 0) => Some(format!("{name} {d} dark")),
|
||||
(0, k) => Some(format!("{name} {k} masked")),
|
||||
(d, k) => Some(format!("{name} {d} dark + {k} masked")),
|
||||
};
|
||||
let bands: Vec<String> = ["left", "right", "top", "bottom"].iter().zip(m.bands).filter_map(|(n, b)| side(n, b)).collect();
|
||||
let shown: Vec<String> =
|
||||
["left", "right", "top", "bottom"].iter().zip(in_crop).filter(|(_, n)| *n > 0).map(|(s, n)| format!("{s} {n}")).collect();
|
||||
println!(
|
||||
" edge bands: {}; decoder crop ({}, {}, {}, {}){}",
|
||||
if bands.is_empty() { "none".to_string() } else { bands.join(", ") },
|
||||
m.crop.0,
|
||||
m.crop.1,
|
||||
m.crop.2,
|
||||
m.crop.3,
|
||||
match m.beyond {
|
||||
Some(b) => {
|
||||
let parts: Vec<String> = ["left", "right", "top", "bottom"]
|
||||
.iter()
|
||||
.zip(b)
|
||||
.filter(|(_, b)| b.masked + b.dark + b.picture > 0)
|
||||
.map(|(s, b)| format!("{s} {} masked / {} dark / {} picture", b.masked, b.dark, b.picture))
|
||||
.collect();
|
||||
if parts.is_empty() {
|
||||
"; nothing beyond the camera frame".to_string()
|
||||
} else {
|
||||
format!("; beyond the camera frame: {}", parts.join(", "))
|
||||
}
|
||||
}
|
||||
None if shown.is_empty() => String::new(),
|
||||
None => format!(" still includes edge bands: {} lines", shown.join(", ")),
|
||||
}
|
||||
);
|
||||
}
|
||||
if let Ok(fr) = m.frame {
|
||||
groups.entry((m.model.clone(), m.size, fr.jpeg)).or_default().push((m.bits, fr));
|
||||
}
|
||||
}
|
||||
if json {
|
||||
return;
|
||||
}
|
||||
println!();
|
||||
// each group's agreed frame: (model, data size, JPEG size) -> (offset, clean files), when its clean files agree
|
||||
let agreed: BTreeMap<&(String, (usize, usize), (usize, usize)), ((usize, usize), usize)> = groups
|
||||
.iter()
|
||||
.filter_map(|(key, frames)| {
|
||||
let offsets: Vec<(usize, usize)> = frames.iter().filter(|(_, f)| f.clean()).map(|(_, f)| f.whole()).collect();
|
||||
let first = *offsets.first()?;
|
||||
offsets.iter().all(|o| *o == first).then_some((key, (first, offsets.len())))
|
||||
})
|
||||
.collect();
|
||||
for (key, frames) in &groups {
|
||||
let (model, size, jpeg) = key;
|
||||
let clean = frames.iter().filter(|(_, f)| f.clean()).count();
|
||||
let mut depths: Vec<u32> = frames.iter().map(|(b, _)| *b).collect();
|
||||
depths.sort_unstable();
|
||||
depths.dedup();
|
||||
let label = format!("{model} {}×{}", size.0, size.1);
|
||||
let Some(&((x, y), _)) = agreed.get(key) else {
|
||||
if clean == 0 {
|
||||
println!("{label}: no clean alignment in {} files; no entry", frames.len());
|
||||
} else {
|
||||
let offsets: Vec<(usize, usize)> = frames.iter().filter(|(_, f)| f.clean()).map(|(_, f)| f.whole()).collect();
|
||||
println!("{label}: files disagree: {offsets:?}; no entry");
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let note = if clean < frames.len() { format!(", {} not clean", frames.len() - clean) } else { String::new() };
|
||||
println!("{label}: {clean} of {} files agree on ({x}, {y}){note}", frames.len());
|
||||
// siblings: other models whose sensor readout matches (same data and JPEG size) and whose own files agree on
|
||||
// the same frame
|
||||
let siblings: Vec<(&str, usize)> = agreed
|
||||
.iter()
|
||||
.filter(|((m, s, j), (o, _))| m != model && s == size && j == jpeg && *o == (x, y))
|
||||
.map(|((m, _, _), (_, n))| (m.as_str(), *n))
|
||||
.collect();
|
||||
let pooled = clean + siblings.iter().map(|(_, n)| n).sum::<usize>();
|
||||
if clean < MIN_FILES {
|
||||
if pooled < MIN_FILES {
|
||||
println!(" pending: {} more clean file(s) needed for an entry", MIN_FILES - pooled);
|
||||
continue;
|
||||
}
|
||||
let names: Vec<String> = siblings.iter().map(|(m, n)| format!("{m} ({n})")).collect();
|
||||
println!(" with siblings of the same readout and frame: {}", names.join(", "));
|
||||
}
|
||||
for bits in depths {
|
||||
println!(
|
||||
" MeasuredBody {{ model: {model:?}, size: ({}, {}), bits: {bits}, crop: ({x}, {y}, {}, {}), white: None }},",
|
||||
size.0, size.1, jpeg.0, jpeg.1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn measure(path: &str) -> Result<Measured, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
let raw = lightcraft_raw::decode(&bytes).map_err(|e| e.to_string())?;
|
||||
let RawData::U16(d) = &raw.data else { return Err("not 16-bit integer sensor data".into()) };
|
||||
if raw.cpp != 1 || raw.cfa.is_none() {
|
||||
return Err("not colour-filter sensor data".into());
|
||||
}
|
||||
let (w, h) = (raw.width, raw.height);
|
||||
if w < 4 || h < 4 || d.len() < w.saturating_mul(h) {
|
||||
return Err("sensor data shorter than its size".into());
|
||||
}
|
||||
let black = f64::from(raw.black.values.iter().sum::<f32>() / raw.black.values.len().max(1) as f32).max(0.0);
|
||||
let white = f64::from(raw.white.first().copied().unwrap_or(((1u32 << raw.bits.min(16)) - 1) as f32)).max(black + 1.0);
|
||||
let at = |x: usize, y: usize| f64::from(d.get(y * w + x).copied().unwrap_or(0));
|
||||
let bands = [
|
||||
band((0..w.min(MAX_BAND)).map(|x| line(h, |i| at(x, i))), black, white),
|
||||
band((0..w.min(MAX_BAND)).map(|k| line(h, |i| at(w - 1 - k, i))), black, white),
|
||||
band((0..h.min(MAX_BAND)).map(|y| line(w, |i| at(i, y))), black, white),
|
||||
band((0..h.min(MAX_BAND)).map(|k| line(w, |i| at(i, h - 1 - k))), black, white),
|
||||
];
|
||||
// the decoder's crop is relative to the active area: in sensor-data coordinates here
|
||||
let a = raw.active_area;
|
||||
let c = lightcraft_raw::Rect::new(a.x + raw.crop.x, a.y + raw.crop.y, raw.crop.width, raw.crop.height);
|
||||
let frame = frame(&bytes, d, w, h);
|
||||
let beyond = frame.as_ref().ok().filter(|f| f.clean()).map(|f| {
|
||||
let ((fx, fy), (fw, fh)) = (f.whole(), f.jpeg);
|
||||
let kinds = |lines: Vec<Line>| {
|
||||
lines.iter().fold(Beyond::default(), |mut b, l| {
|
||||
match kind(l, black, white) {
|
||||
Kind::Dark => b.dark += 1,
|
||||
Kind::Masked => b.masked += 1,
|
||||
Kind::Picture => b.picture += 1,
|
||||
}
|
||||
b
|
||||
})
|
||||
};
|
||||
let col = |x: usize| line(h, |i| at(x, i));
|
||||
let row = |y: usize| line(w, |i| at(i, y));
|
||||
[
|
||||
kinds((c.x..fx.min(c.x + c.width)).map(col).collect()),
|
||||
kinds(((fx + fw).max(c.x)..c.x + c.width).map(col).collect()),
|
||||
kinds((c.y..fy.min(c.y + c.height)).map(row).collect()),
|
||||
kinds(((fy + fh).max(c.y)..c.y + c.height).map(row).collect()),
|
||||
]
|
||||
});
|
||||
Ok(Measured {
|
||||
model: raw.metadata.model.clone().unwrap_or_default().trim().to_string(),
|
||||
size: (w, h),
|
||||
bits: raw.bits,
|
||||
black,
|
||||
white,
|
||||
crop: (c.x, c.y, c.width, c.height),
|
||||
bands,
|
||||
frame,
|
||||
beyond,
|
||||
})
|
||||
}
|
||||
|
||||
/// Statistics of one line of sensor samples: mean, |even − odd| mean, spread, neighbour noise (same colour, 2 apart)
|
||||
/// and the number of sample pairs.
|
||||
struct Line {
|
||||
mean: f64,
|
||||
pattern: f64,
|
||||
spread: f64,
|
||||
noise: f64,
|
||||
pairs: f64,
|
||||
}
|
||||
|
||||
fn line(n: usize, at: impl Fn(usize) -> f64) -> Line {
|
||||
let stride = ((n / 512).max(1)).div_ceil(2) * 2;
|
||||
let (mut k, mut even, mut odd, mut var, mut noise) = (0f64, 0f64, 0f64, 0f64, 0f64);
|
||||
let idx: Vec<usize> = (0..n.saturating_sub(2)).step_by(stride).collect();
|
||||
for &i in &idx {
|
||||
even += at(i);
|
||||
odd += at(i + 1);
|
||||
k += 1.0;
|
||||
}
|
||||
let (even, odd) = (even / k.max(1.0), odd / k.max(1.0));
|
||||
for &i in &idx {
|
||||
var += (at(i) - even).powi(2);
|
||||
noise += (at(i + 2) - at(i)).powi(2) / 2.0;
|
||||
}
|
||||
Line { mean: (even + odd) / 2.0, pattern: (even - odd).abs(), spread: (var / k.max(1.0)).sqrt(), noise: (noise / k.max(1.0)).sqrt(), pairs: k }
|
||||
}
|
||||
|
||||
enum Kind {
|
||||
Dark,
|
||||
Masked,
|
||||
Picture,
|
||||
}
|
||||
|
||||
fn kind(l: &Line, black: f64, white: f64) -> Kind {
|
||||
let range = white - black;
|
||||
let slack = white / 4096.0;
|
||||
if l.mean < black + range * 0.01 {
|
||||
Kind::Dark
|
||||
} else if l.mean > black + range * 0.02
|
||||
&& l.mean < white * 0.25
|
||||
// patternless within the noise, or with a small fixed offset between the CFA phases (D90 / D5000 filler)
|
||||
&& (l.pattern < 4.0 * l.noise * (2.0 / l.pairs.max(1.0)).sqrt() + slack || l.pattern < white * 0.004)
|
||||
&& l.spread < 1.5 * l.noise + slack
|
||||
&& l.spread < white * 0.004
|
||||
{
|
||||
Kind::Masked
|
||||
} else {
|
||||
Kind::Picture
|
||||
}
|
||||
}
|
||||
|
||||
fn band(lines: impl Iterator<Item = Line>, black: f64, white: f64) -> Band {
|
||||
let mut b = Band::default();
|
||||
for l in lines {
|
||||
match kind(&l, black, white) {
|
||||
Kind::Dark => b.dark += 1,
|
||||
Kind::Masked => b.masked += 1,
|
||||
Kind::Picture => break,
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
fn frame(bytes: &[u8], d: &[u16], w: usize, h: usize) -> Result<Frame, String> {
|
||||
let jpeg = lightcraft_raw::embedded_preview(bytes).ok_or("no embedded preview")?;
|
||||
let jpeg = lightcraft_codecs::decode(&jpeg, DecodeOptions::default()).map_err(|e| e.to_string())?;
|
||||
let (jw, jh) = (jpeg.image.width, jpeg.image.height);
|
||||
if jw > w || jh > h || jw * 10 < w * 9 || jh * 10 < h * 9 {
|
||||
return Err(format!("the largest preview ({jw}×{jh}) is not a full-size JPEG of the {w}×{h} data"));
|
||||
}
|
||||
// half-size luminance, square-rooted so the linear raw and the JPEG weigh their tones alike
|
||||
let (rw, rh) = (w / 2, h / 2);
|
||||
let raw_y: Vec<f64> = (0..rh * rw)
|
||||
.map(|i| {
|
||||
let (x, y) = (2 * (i % rw), 2 * (i / rw));
|
||||
let s: f64 =
|
||||
[(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)].iter().map(|&(x, y)| f64::from(d.get(y * w + x).copied().unwrap_or(0))).sum();
|
||||
s.sqrt()
|
||||
})
|
||||
.collect();
|
||||
let (pw, ph) = (jw / 2, jh / 2);
|
||||
let px = &jpeg.image.data;
|
||||
let jpg_y: Vec<f64> = (0..ph * pw)
|
||||
.map(|i| {
|
||||
let (x, y) = (2 * (i % pw), 2 * (i / pw));
|
||||
let s: f64 = [(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)]
|
||||
.iter()
|
||||
.map(|&(x, y)| px.get(y * jw + x).map_or(0.0, |p| f64::from(0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]).max(0.0)))
|
||||
.sum();
|
||||
s.sqrt()
|
||||
})
|
||||
.collect();
|
||||
let (sx, sy) = ((rw - pw).min(MAX_SEARCH), (rh - ph).min(MAX_SEARCH));
|
||||
let grad = |img: &[f64], stride: usize, x: usize, y: usize| {
|
||||
let at = |x: usize, y: usize| img.get(y * stride + x).copied().unwrap_or(0.0);
|
||||
(at(x + 1, y) - at(x, y)).hypot(at(x, y + 1) - at(x, y))
|
||||
};
|
||||
let normalise = |v: Vec<f64>| {
|
||||
let n = v.len().max(1) as f64;
|
||||
let mean = v.iter().sum::<f64>() / n;
|
||||
let sd = (v.iter().map(|a| (a - mean).powi(2)).sum::<f64>() / n).sqrt().max(1e-12);
|
||||
v.into_iter().map(|a| (a - mean) / sd).collect::<Vec<f64>>()
|
||||
};
|
||||
// gradient magnitudes on every `step`-th half-size pixel, normalised, and the correlation at one offset
|
||||
let sample = |step: usize| {
|
||||
let points: Vec<(usize, usize)> =
|
||||
(0..ph.saturating_sub(1)).step_by(step).flat_map(|y| (0..pw.saturating_sub(1)).step_by(step).map(move |x| (x, y))).collect();
|
||||
let gj = normalise(points.iter().map(|&(x, y)| grad(&jpg_y, pw, x, y)).collect());
|
||||
(points, gj)
|
||||
};
|
||||
let score = |(points, gj): &(Vec<(usize, usize)>, Vec<f64>), dx: usize, dy: usize| {
|
||||
let gr = normalise(points.iter().map(|&(x, y)| grad(&raw_y, rw, x + dx, y + dy)).collect());
|
||||
gr.iter().zip(gj).map(|(a, b)| a * b).sum::<f64>() / gj.len().max(1) as f64
|
||||
};
|
||||
let fine = sample(2);
|
||||
// every offset when the window is small (every Nikon body); else every 4th on sparser points, then each offset
|
||||
// around the best of those
|
||||
let mut scores = vec![vec![f64::NEG_INFINITY; sx + 1]; sy + 1];
|
||||
let mut coarse_best = f64::NEG_INFINITY;
|
||||
let (mut cx, mut cy) = (0, 0);
|
||||
let window = if (sx + 1) * (sy + 1) <= FULL_SEARCH {
|
||||
(0..=sx, 0..=sy)
|
||||
} else {
|
||||
let coarse = sample(8);
|
||||
for dy in (0..=sy).step_by(4) {
|
||||
for dx in (0..=sx).step_by(4) {
|
||||
let v = score(&coarse, dx, dy);
|
||||
if let Some(s) = scores.get_mut(dy).and_then(|r| r.get_mut(dx)) {
|
||||
*s = v;
|
||||
}
|
||||
if v > coarse_best {
|
||||
(coarse_best, cx, cy) = (v, dx, dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
(cx.saturating_sub(5)..=(cx + 5).min(sx), cy.saturating_sub(5)..=(cy + 5).min(sy))
|
||||
};
|
||||
for dy in window.1.clone() {
|
||||
for dx in window.0.clone() {
|
||||
if let Some(s) = scores.get_mut(dy).and_then(|r| r.get_mut(dx)) {
|
||||
*s = score(&fine, dx, dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
let at = |dx: usize, dy: usize| scores.get(dy).and_then(|r| r.get(dx)).copied().unwrap_or(f64::NEG_INFINITY);
|
||||
let (mut bx, mut by) = (0, 0);
|
||||
for dy in 0..=sy {
|
||||
for dx in 0..=sx {
|
||||
if at(dx, dy) > at(bx, by) {
|
||||
(bx, by) = (dx, dy);
|
||||
}
|
||||
}
|
||||
}
|
||||
let best = at(bx, by);
|
||||
let runner_up = (0..=sy)
|
||||
.flat_map(|dy| (0..=sx).map(move |dx| (dx, dy)))
|
||||
.filter(|&(dx, dy)| dx.abs_diff(bx) > 1 || dy.abs_diff(by) > 1)
|
||||
.map(|(dx, dy)| at(dx, dy))
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
// parabola through the peak and its neighbours (none at the window's edge)
|
||||
let refine = |a: f64, b: f64, c: f64| {
|
||||
let k = a - 2.0 * b + c;
|
||||
if a.is_finite() && c.is_finite() && k < 0.0 { (0.5 * (a - c) / k).clamp(-0.5, 0.5) } else { 0.0 }
|
||||
};
|
||||
let fx = if bx > 0 { refine(at(bx - 1, by), best, at(bx + 1, by)) } else { 0.0 };
|
||||
let fy = if by > 0 { refine(at(bx, by - 1), best, at(bx, by + 1)) } else { 0.0 };
|
||||
Ok(Frame {
|
||||
jpeg: (jw, jh),
|
||||
at: (2.0 * (bx as f64 + fx), 2.0 * (by as f64 + fy)),
|
||||
score: best,
|
||||
margin: if runner_up.is_finite() { best - runner_up } else { best },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! What a Synchronize Folder scan costs on a folder whose photos are all in the library and
|
||||
//! unchanged: the walk, and the check of every photo and its sidecar. Reads the folder; writes
|
||||
//! nothing there, and probes no file (the library is made from the folder's own listing).
|
||||
//!
|
||||
//! `cargo run --release -p lightcraft-engine --example sync_scan_bench -- <folder>`
|
||||
use std::time::Instant;
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
use lightcraft_engine::Session;
|
||||
use lightcraft_engine::sync::{SyncInput, SyncProgress, scan_with};
|
||||
|
||||
fn main() {
|
||||
let Some(folder) = std::env::args().nth(1) else {
|
||||
eprintln!("usage: sync_scan_bench <folder>");
|
||||
return;
|
||||
};
|
||||
let mut s = Session::new();
|
||||
let t = Instant::now();
|
||||
let files = lightcraft_engine::import::expand(std::slice::from_ref(&folder), None);
|
||||
println!("{} files listed in {:.1} s (the library is made of them)", files.len(), t.elapsed().as_secs_f64());
|
||||
for f in &files {
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let name = std::path::Path::new(f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let p = Photo::new(id, Source::File { path: f.clone() }, &name, "JPEG", 6, 4, "2026-01-01T00:00:00");
|
||||
if s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).is_err() {
|
||||
eprintln!("could not add {name}");
|
||||
}
|
||||
}
|
||||
for run in 0..2 {
|
||||
let input = match SyncInput::new(&mut s, &folder, true) {
|
||||
Ok(input) => input,
|
||||
Err(e) => {
|
||||
eprintln!("{e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let t = Instant::now();
|
||||
let changes = scan_with(input, &SyncProgress::default());
|
||||
println!(
|
||||
"run {run}: scan {:.1} s ({} new, {} missing, {} with sidecar news)",
|
||||
t.elapsed().as_secs_f64(),
|
||||
changes.new.len(),
|
||||
changes.missing.len(),
|
||||
changes.metadata.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ impl Unit {
|
||||
pub struct TaskInfo {
|
||||
pub id: u64,
|
||||
/// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`,
|
||||
/// `faces`, `denoise`, `findMissing`, `applePhotos`.
|
||||
/// `faces`, `denoise`, `findMissing`, `applePhotos`, `cache`, `focus`.
|
||||
pub kind: &'static str,
|
||||
/// English; the frontend translates it.
|
||||
pub label: String,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -25,8 +25,12 @@ const MAX_FILE: u64 = 4 << 20;
|
||||
/// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`):
|
||||
/// `(model, JSON)`.
|
||||
pub const BUNDLED: &[(&str, &str)] = &[
|
||||
("ILCA-99M2", include_str!("../../../assets/camera-profiles/ILCA-99M2.json")),
|
||||
("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")),
|
||||
("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")),
|
||||
("NIKON D5300", include_str!("../../../assets/camera-profiles/NIKON_D5300.json")),
|
||||
("NIKON Z 6_2", include_str!("../../../assets/camera-profiles/NIKON_Z_6_2.json")),
|
||||
("NIKON Z 8", include_str!("../../../assets/camera-profiles/NIKON_Z_8.json")),
|
||||
("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")),
|
||||
("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")),
|
||||
];
|
||||
@@ -194,10 +198,12 @@ impl Pool {
|
||||
/// NEF or RAF without colour matrices, no usable camera JPEG, too little colour).
|
||||
pub fn add(&mut self, bytes: &[u8]) -> Result<Option<String>, String> {
|
||||
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
|
||||
// colour only: geometric lens corrections would stop the sensor proxy from binning
|
||||
// colour only: geometric lens corrections would stop the sensor proxy from binning (the pairs still follow
|
||||
// the warp the camera applied to its JPEG)
|
||||
let lens = crate::camera_preview::lens_geometry(&raw.opcodes.list3);
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let Some(model) = raw.metadata.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) else { return Ok(None) };
|
||||
let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes) else { return Ok(None) };
|
||||
let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes, lens.as_ref()) else { return Ok(None) };
|
||||
let step = pairs.len().div_ceil(PAIRS_PER_FILE).max(1);
|
||||
let entry = self.models.entry(model.to_owned()).or_default();
|
||||
entry.0 += 1;
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
//! Each browse records when the folders it listed were browsed. Untouched records of folders
|
||||
//! not browsed for [`Session::forget_local_days`] are forgotten (when the library opens, or with
|
||||
//! `library.forgetLocal`); see [`lightcraft_catalog::local`] for what "untouched" means. Files
|
||||
//! and sidecars on disk are never touched, and browsing the folder again brings them back.
|
||||
//! and sidecars on disk are never touched, and browsing the folder again brings them back. A
|
||||
//! browse also forgets at once the untouched records of the folder's files that are gone from
|
||||
//! the disk (deleted or moved by another app), so the folder shows what is on disk.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId};
|
||||
use lightcraft_catalog::query::in_folder;
|
||||
use lightcraft_catalog::{Op, PhotoId, Source};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, str_param};
|
||||
@@ -42,11 +45,73 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// looking at a folder never brings its deleted photos back (importing the files does)
|
||||
let opts = ImportOptions { mode: ImportMode::Add, local: true, on_deleted: crate::import::OnDeleted::Skip, ..Default::default() };
|
||||
let report = import_with(s, &files, &opts)?;
|
||||
let forgotten = forget_gone(s, &dir_s, subfolders, &files);
|
||||
stamp_browsed(s, &dir_s, &files);
|
||||
s.browse = Some(Browse { path: dir_s.clone(), subfolders });
|
||||
s.source = LibrarySource::Folder;
|
||||
s.keep_active_in_view();
|
||||
let shown = s.visible().len();
|
||||
Ok(json!({"path": dir_s, "subfolders": subfolders, "photos": shown, "new": report.imported.len(), "failed": report.failed.len()}))
|
||||
Ok(json!({
|
||||
"path": dir_s,
|
||||
"subfolders": subfolders,
|
||||
"photos": shown,
|
||||
"new": report.imported.len(),
|
||||
"failed": report.failed.len(),
|
||||
"forgotten": forgotten,
|
||||
}))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Forget the untouched Local records of files gone from the folder `dir` (with its subfolders when
|
||||
/// `subfolders`) that a browse just listed as `files`: what `library.browse` does after reading a folder, for a
|
||||
/// UI that reads it on a worker thread instead. Returns how many went.
|
||||
pub fn forget_gone_from(&mut self, dir: &str, subfolders: bool, files: &[String]) -> usize {
|
||||
forget_gone(self, dir, subfolders, files)
|
||||
}
|
||||
}
|
||||
|
||||
/// Local records in the browsed folder whose files are no longer on disk (deleted or moved by
|
||||
/// another app) and that nothing was changed on are forgotten, as `library.forgetLocal` forgets
|
||||
/// them: the folder shows what is on disk, not photos that fail to load (and log an error) on
|
||||
/// every visit (issue #1392). Records the user changed stay, shown as missing, and so do those
|
||||
/// the session holds (selected, edited, in the undo history) or with a smart preview. Returns how
|
||||
/// many went.
|
||||
fn forget_gone(s: &mut Session, dir: &str, subfolders: bool, files: &[String]) -> usize {
|
||||
let listed: std::collections::HashSet<&str> = files.iter().map(String::as_str).collect();
|
||||
let held = held(s);
|
||||
let smart = s.media.smart_dir.clone();
|
||||
let gone: Vec<PhotoId> = s
|
||||
.catalog
|
||||
.photos()
|
||||
.filter(|p| p.local && !p.deleted && !held.contains(&p.id))
|
||||
// a smart preview may be all that is left of the picture
|
||||
.filter(|p| !smart.as_ref().is_some_and(|d| crate::smart::file_path(d, p).exists()))
|
||||
.filter(|p| match &p.source {
|
||||
// a file left out of the listing is checked on disk: another spelling of its path, or
|
||||
// a listing that missed it, keeps it
|
||||
Source::File { path } => in_folder(path, dir, subfolders) && !listed.contains(path.as_str()) && !Path::new(path).exists(),
|
||||
Source::Demo { .. } => false,
|
||||
})
|
||||
.map(|p| p.id)
|
||||
.collect();
|
||||
if gone.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let mut forgotten = 0;
|
||||
for chunk in s.catalog.forgettable_local(&gone).chunks(2000) {
|
||||
let op = Op::Batch { ops: chunk.iter().map(|id| Op::RemovePhoto { id: *id }).collect() };
|
||||
match s.catalog.apply(op.clone()) {
|
||||
Ok(_) => {
|
||||
s.pending_log.push(op);
|
||||
forgotten += chunk.len();
|
||||
}
|
||||
Err(e) => log::warn!("forget Local records of files gone from {dir}: {e}"),
|
||||
}
|
||||
}
|
||||
if forgotten > 0 {
|
||||
log::info!("forgot {forgotten} untouched Local record(s) of files gone from {dir}");
|
||||
}
|
||||
forgotten
|
||||
}
|
||||
|
||||
/// Folders whose browse time is refreshed at most this often (keeps repeated browsing from
|
||||
@@ -82,7 +147,15 @@ fn stamp_browsed(s: &mut Session, root: &str, files: &[String]) {
|
||||
/// Photos the session still needs, whatever their state: shown, selected, being edited, or
|
||||
/// referred to by an undo / redo step.
|
||||
fn in_use(s: &mut Session) -> std::collections::HashSet<PhotoId> {
|
||||
let mut keep: std::collections::HashSet<PhotoId> = s.visible().iter().copied().collect();
|
||||
let mut keep = held(s);
|
||||
keep.extend(s.visible().iter().copied());
|
||||
keep
|
||||
}
|
||||
|
||||
/// Photos the session holds whatever is shown: selected, being edited, or referred to by an undo
|
||||
/// / redo step.
|
||||
fn held(s: &Session) -> std::collections::HashSet<PhotoId> {
|
||||
let mut keep: std::collections::HashSet<PhotoId> = std::collections::HashSet::new();
|
||||
keep.extend(s.selection.ids.iter().copied());
|
||||
keep.extend(s.selection.active);
|
||||
keep.extend(s.previous_active);
|
||||
@@ -151,7 +224,7 @@ fn forget_local(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// The browsed photos among `ids` (default: the selection) become library photos.
|
||||
fn add_to_library(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect();
|
||||
let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect();
|
||||
if ids.is_empty() {
|
||||
return Ok(json!({"added": 0}));
|
||||
}
|
||||
@@ -337,7 +410,7 @@ fn move_to_folder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !dir.is_dir() {
|
||||
return Err(bad(C, format!("{folder} is not a folder (create it first with folder.create)")));
|
||||
}
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no photos to move: select some or give ids"));
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ fn targeted(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn index(p: &Value, c: &str) -> Result<usize> {
|
||||
Ok(f64_req(p, "index", c)? as usize)
|
||||
super::index_req(p, "index", c)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -35,7 +35,8 @@ pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path
|
||||
Some(r) => r(path)?,
|
||||
None => std::fs::read(path).map_err(|e| format!("{path}: {e}"))?,
|
||||
};
|
||||
let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
let mut raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
crate::camera_preview::with_starting_colour(&mut raw, &bytes);
|
||||
drop(bytes);
|
||||
let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: (!packet.is_empty()).then_some(packet), ..Default::default() })
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -58,7 +59,7 @@ pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path
|
||||
|
||||
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.convertToDng";
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let mut ops = Vec::new();
|
||||
let mut converted = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
@@ -124,7 +125,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
/// the library and stack it on top of the original. The host opens the file in the editor.
|
||||
fn edit_external(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.editExternal";
|
||||
let mut ids = s.targets(p);
|
||||
let mut ids = s.targets(p)?;
|
||||
ids.dedup();
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no active photo"));
|
||||
@@ -201,12 +202,16 @@ fn edit_copy(s: &mut Session, p: &Value, id: lightcraft_catalog::PhotoId, opts:
|
||||
|
||||
/// The op that brings a photo up to date with what its file is now (`info`, a fresh probe), or
|
||||
/// `None` when nothing changed. Covers a raw that became decodable (or stopped being: a different
|
||||
/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`].
|
||||
/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`], and a
|
||||
/// preview-only photo this version hasn't checked yet: its stamp is refreshed so it isn't probed
|
||||
/// again (see [`lightcraft_catalog::Photo::preview_checked`]).
|
||||
pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: crate::media::ProbeInfo) -> Option<Op> {
|
||||
let preview_checked = lightcraft_catalog::preview_stamp(&info.preview_only);
|
||||
let same = info.content_hash == ph.content_hash
|
||||
&& info.file_size == ph.file_size
|
||||
&& (info.width, info.height) == (ph.width, ph.height)
|
||||
&& info.preview_only == ph.preview_only;
|
||||
&& info.preview_only == ph.preview_only
|
||||
&& preview_checked == ph.preview_checked;
|
||||
(!same).then_some(Op::SetContent {
|
||||
id,
|
||||
width: info.width,
|
||||
@@ -214,6 +219,7 @@ pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalo
|
||||
file_size: info.file_size,
|
||||
content_hash: info.content_hash,
|
||||
preview_only: info.preview_only,
|
||||
preview_checked,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -234,6 +240,10 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P
|
||||
m.aperture = m.aperture.or(src.aperture);
|
||||
m.iso = m.iso.or(src.iso);
|
||||
m.gps = m.gps.or(src.gps);
|
||||
// the camera's focus point: photos imported before it was read get it here
|
||||
if m.focus.is_none() {
|
||||
m.focus = src.focus.clone();
|
||||
}
|
||||
let mut ops = Vec::new();
|
||||
if m != before {
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(m) });
|
||||
@@ -271,10 +281,41 @@ pub(crate) fn as_shot_wb_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_cat
|
||||
(ph.develops_raw() && !ph.relative_wb() && ph.as_shot_wb != Some(read)).then_some(Op::SetAsShotWb { id, wb: Some(read) })
|
||||
}
|
||||
|
||||
/// The ops that bring photo `id` and its virtual copies up to date with what their file is now
|
||||
/// (`info`, a fresh probe), appended to `ops`; the photos they change are appended to `changed`.
|
||||
/// Reload and the preview-only recheck (`library.recheckPreviewOnly`) share it.
|
||||
pub(crate) fn reload_ops(
|
||||
cat: &lightcraft_catalog::Catalog,
|
||||
id: lightcraft_catalog::PhotoId,
|
||||
info: &crate::media::ProbeInfo,
|
||||
ops: &mut Vec<Op>,
|
||||
changed: &mut Vec<lightcraft_catalog::PhotoId>,
|
||||
) {
|
||||
let Some(ph) = cat.photo(id) else { return };
|
||||
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
|
||||
// nothing already set is overwritten
|
||||
let meta_ops = fill_missing_meta(id, ph, info);
|
||||
let mut mine: Vec<Op> =
|
||||
content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, info)).chain(as_shot_wb_op(id, ph, info)).collect();
|
||||
if !mine.is_empty() {
|
||||
ops.append(&mut mine);
|
||||
changed.push(id);
|
||||
}
|
||||
// virtual copies share the file
|
||||
for c in cat.photos().filter(|c| c.copy_of == Some(id)) {
|
||||
let theirs: Vec<Op> =
|
||||
content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, info)).chain(as_shot_wb_op(c.id, c, info)).collect();
|
||||
if !theirs.is_empty() {
|
||||
ops.extend(theirs);
|
||||
changed.push(c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
|
||||
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
|
||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids
|
||||
.iter()
|
||||
.filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) {
|
||||
@@ -286,25 +327,8 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut ops = Vec::new();
|
||||
let mut reloaded = Vec::new();
|
||||
for ((id, _), info) in paths.into_iter().zip(probed) {
|
||||
let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue };
|
||||
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
|
||||
// nothing already set is overwritten
|
||||
let meta_ops = fill_missing_meta(id, ph, &info);
|
||||
let mut mine: Vec<Op> =
|
||||
content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).chain(as_shot_wb_op(id, ph, &info)).collect();
|
||||
if !mine.is_empty() {
|
||||
ops.append(&mut mine);
|
||||
reloaded.push(id);
|
||||
}
|
||||
// virtual copies share the file
|
||||
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
|
||||
let theirs: Vec<Op> =
|
||||
content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).chain(as_shot_wb_op(c.id, c, &info)).collect();
|
||||
if !theirs.is_empty() {
|
||||
ops.extend(theirs);
|
||||
reloaded.push(c.id);
|
||||
}
|
||||
}
|
||||
let Ok(info) = info else { continue };
|
||||
reload_ops(&s.catalog, id, &info, &mut ops, &mut reloaded);
|
||||
}
|
||||
for id in &reloaded {
|
||||
s.media.forget(*id);
|
||||
@@ -320,7 +344,7 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.duplicate";
|
||||
let mut made = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
for id in s.targets(p)? {
|
||||
let Some(ph) = s.catalog.photo(id).map(|p| (**p).clone()) else { continue };
|
||||
let Source::File { path } = &ph.source else { return Err(bad(C, "a generated demo photo has no file to duplicate")) };
|
||||
let src = Path::new(path);
|
||||
@@ -421,6 +445,27 @@ mod tests {
|
||||
assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty());
|
||||
}
|
||||
|
||||
/// Reload from Disk gives a photo imported before focus points were read its camera focus point,
|
||||
/// and never replaces one it has.
|
||||
#[test]
|
||||
fn reload_fills_a_missing_focus_point() {
|
||||
let fp = |x: f64| lightcraft_meta::FocusPoint {
|
||||
rect: lightcraft_meta::Rect { x0: x, y0: 0.4, x1: x + 0.02, y1: 0.42 },
|
||||
mode: Some("AF-C".into()),
|
||||
area: None,
|
||||
tracking: None,
|
||||
};
|
||||
let mut ph = Photo::new(PhotoId(1), Source::File { path: "/x.arw".into() }, "x.arw", "ARW", 8640, 5760, "2026-10-06T00:00:00");
|
||||
ph.captured = Some("2026-10-04T08:16:11".into());
|
||||
let mut info = crate::media::ProbeInfo { captured: ph.captured.clone(), ..Default::default() };
|
||||
info.meta.focus = Some(fp(0.3));
|
||||
let ops = fill_missing_meta(PhotoId(1), &ph, &info);
|
||||
let Some(Op::SetMeta { meta, .. }) = ops.first() else { panic!("{ops:?}") };
|
||||
assert_eq!(meta.focus, Some(fp(0.3)));
|
||||
ph.meta.focus = Some(fp(0.1));
|
||||
assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty(), "a known focus point is kept");
|
||||
}
|
||||
|
||||
fn lens() -> lightcraft_develop::EmbeddedLens {
|
||||
lightcraft_develop::EmbeddedLens {
|
||||
warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }),
|
||||
@@ -494,7 +539,7 @@ mod tests {
|
||||
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"));
|
||||
let f = root.join("raw/rw2-panasonic-gx80.rw2");
|
||||
if !f.exists() {
|
||||
eprintln!("skip: {} absent", f.display());
|
||||
lightcraft_testkit::skip!(Corpus, "{} absent", f.display());
|
||||
return;
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
|
||||
@@ -16,11 +16,11 @@ const SAME: f32 = 0.93;
|
||||
|
||||
fn secs(iso: &str) -> Option<i64> {
|
||||
let d = lightcraft_meta::DateTime::parse_iso(iso)?;
|
||||
Some(((d.year as i64 * 372 + d.month as i64 * 31 + d.day as i64) * 24 + d.hour as i64) * 3600 + d.minute as i64 * 60 + d.second as i64)
|
||||
Some(d.unix_seconds())
|
||||
}
|
||||
|
||||
fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() };
|
||||
let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() };
|
||||
let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32);
|
||||
let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false);
|
||||
// measure (thumbnail-level sources: fast, and enough for focus at 512 px)
|
||||
|
||||
@@ -349,9 +349,8 @@ fn export_presets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if chosen.is_empty() {
|
||||
return Err(bad(C, "no curve presets to export (save one first, or pass names)"));
|
||||
}
|
||||
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
}
|
||||
std::fs::write(&path, to_file(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
let target = path.to_string_lossy();
|
||||
s.check_write_target(&target).map_err(|e| bad(C, e))?;
|
||||
crate::export::write_file_durable(&target, to_file(&chosen).as_bytes()).map_err(crate::EngineError::Other)?;
|
||||
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, Setti
|
||||
use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, ok, str_param};
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, index_req, ok, str_param};
|
||||
use crate::{Result, Session, media::SourceLevel};
|
||||
|
||||
fn active(s: &Session, c: &str) -> Result<PhotoId> {
|
||||
@@ -144,7 +144,7 @@ fn user_preset<'a>(s: &'a mut Session, p: &Value, c: &str) -> Result<&'a mut Pre
|
||||
/// Change version `p.index` of the active photo (`f` gets it and the current settings).
|
||||
fn edit_version(s: &mut Session, p: &Value, c: &str, label: &str, f: impl FnOnce(&mut Version, std::sync::Arc<DevelopSettings>)) -> Result<Value> {
|
||||
let id = active(s, c)?;
|
||||
let i = f64_req(p, "index", c)? as usize;
|
||||
let i = index_req(p, "index", c)?;
|
||||
let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?;
|
||||
let mut versions = ph.versions.clone();
|
||||
let v = versions.get_mut(i).ok_or_else(|| bad(c, "no such version"))?;
|
||||
@@ -254,7 +254,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
}),
|
||||
cmd!("develop.reset", "Reset Edits", ["Photo"], Some("Cmd+Shift+R"), "{ids?}", has_selection, |s, p| {
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let ops = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
@@ -304,7 +304,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let targets = match named_photos(s, p, C)? {
|
||||
Some(ids) if !ids.iter().any(|id| outdated(s, *id)) => return Err(crate::EngineError::Disabled(C.into(), already_current())),
|
||||
Some(ids) => ids,
|
||||
None => s.targets(p),
|
||||
None => s.targets(p)?,
|
||||
};
|
||||
let ops: Vec<Op> = targets
|
||||
.into_iter()
|
||||
@@ -588,14 +588,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Straighten",
|
||||
[],
|
||||
None,
|
||||
"{angle: degrees -45..45} — keeps the largest crop of the current aspect",
|
||||
"{angle: degrees -45..45} — keeps the crop if it still fits, else shrinks it about its centre (the largest crop of its aspect stays the largest)",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let id = active(s, "crop.straighten")?;
|
||||
let (w, h) = crop_dims(s, id);
|
||||
let angle = f64_req(p, "angle", "crop.straighten")?.clamp(-45.0, 45.0);
|
||||
edit(s, "crop.straighten", "Straighten", |d| {
|
||||
// keep the current crop if it still fits, else the largest of its aspect that does
|
||||
// keep the current crop if it still fits, else shrink it about its centre until it does
|
||||
d.crop.geometry = d.crop.geometry.with_angle(angle, w, h);
|
||||
Ok(())
|
||||
})
|
||||
@@ -678,10 +678,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let (w, h) = crop_dims(s, id);
|
||||
edit(s, "crop.rotateAspect", "Rotate Crop", |d| {
|
||||
let r = d.crop.geometry.rect_px(w, h);
|
||||
let a = r.height() / r.width();
|
||||
// a degenerate crop has no aspect to rotate: fall back to the image's own
|
||||
let a = Some(r.height() / r.width()).filter(|a| a.is_finite() && *a > 0.0);
|
||||
d.crop.aspect = d.crop.aspect.map(|(x, y)| (y, x));
|
||||
let angle = d.crop.geometry.angle;
|
||||
d.crop.geometry = crop_fit_angle(w, h, angle, Some(a));
|
||||
d.crop.geometry = crop_fit_angle(w, h, angle, a);
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
@@ -810,7 +811,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.previous_active.filter(|id| s.catalog.photo(*id).is_some_and(|ph| !ph.deleted)).ok_or_else(|| bad(c, "no previous photo"))?;
|
||||
let groups = groups_param(p, c)?.unwrap_or_else(|| s.copy_groups.clone());
|
||||
let partial = lightcraft_develop::extract_groups(&s.develop_of(prev).unwrap_or_default(), &groups);
|
||||
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| *id != prev).collect();
|
||||
let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|id| *id != prev).collect();
|
||||
let ops = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
@@ -837,7 +838,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let full = lightcraft_develop::apply_partial(&DevelopSettings::default(), &clip, 1.0);
|
||||
clip = lightcraft_develop::extract_groups(&full, &only);
|
||||
}
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let ops = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
@@ -889,7 +890,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let delta = f64_req(p, "delta", c)?;
|
||||
let label = format!("Quick Develop: {}", spec.label);
|
||||
let ops: Vec<Op> = s
|
||||
.targets(p)
|
||||
.targets(p)?
|
||||
.into_iter()
|
||||
.filter_map(|id| s.develop_of(id).map(|d| (id, d)))
|
||||
.filter_map(|(id, d)| {
|
||||
@@ -942,7 +943,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
};
|
||||
let r_ev = gathered(s, reference).ok_or_else(|| bad(c, "the active photo has no shutter / aperture / ISO"))?;
|
||||
let r_slider = s.develop_of(reference).map(|d| d.light.exposure).unwrap_or(0.0);
|
||||
let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|x| *x != reference).collect();
|
||||
let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|x| *x != reference).collect();
|
||||
let (mut ops, mut skipped) = (Vec::new(), Vec::new());
|
||||
for id in ids {
|
||||
let (Some(ev), Some(d)) = (gathered(s, id), s.develop_of(id)) else {
|
||||
@@ -977,7 +978,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let pid = str_param(p, "id").ok_or_else(|| bad("preset.apply", "missing id"))?;
|
||||
let preset = s.presets.iter().find(|x| x.id == pid).cloned().ok_or_else(|| bad("preset.apply", format!("unknown preset `{pid}`")))?;
|
||||
let amount = f64_or(p, "amount", 100.0).clamp(0.0, 200.0) / 100.0;
|
||||
let ids = s.targets(p);
|
||||
// `id` names the preset here: the photos are `ids`, else the selection
|
||||
let ids = s.targets(&json!({"ids": p.get("ids")}))?;
|
||||
let ops = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
@@ -1060,14 +1062,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!("version.restore", "Restore Version", [], None, "{index}", has_active, |s, p| {
|
||||
let id = active(s, "version.restore")?;
|
||||
let i = f64_req(p, "index", "version.restore")? as usize;
|
||||
let i = index_req(p, "index", "version.restore")?;
|
||||
let v = s.catalog.photo(id).and_then(|ph| ph.versions.get(i).cloned()).ok_or_else(|| bad("version.restore", "no such version"))?;
|
||||
s.set_develop(id, (*v.settings).clone(), &format!("Restore {}", v.name))?;
|
||||
ok()
|
||||
}),
|
||||
cmd!("version.delete", "Delete Version", [], None, "{index}", has_active, |s, p| {
|
||||
let id = active(s, "version.delete")?;
|
||||
let i = f64_req(p, "index", "version.delete")? as usize;
|
||||
let i = index_req(p, "index", "version.delete")?;
|
||||
let mut versions = s.catalog.photo(id).map(|ph| ph.versions.clone()).unwrap_or_default();
|
||||
if i >= versions.len() {
|
||||
return Err(bad("version.delete", "no such version"));
|
||||
|
||||
@@ -17,7 +17,7 @@ use lightcraft_faces::yunet::{Detector, Face, Options};
|
||||
use lightcraft_meta::{Region, RegionKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_selection};
|
||||
use super::{CommandSpec, always, bad, cmd, has_selection};
|
||||
use crate::{AiModel, EngineError, Result, Session};
|
||||
|
||||
const C: &str = "faces.detect";
|
||||
@@ -54,10 +54,12 @@ impl NoDetector {
|
||||
}
|
||||
}
|
||||
|
||||
/// The YuNet loaded last, and from where.
|
||||
static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None);
|
||||
|
||||
/// The installed YuNet, loaded once and kept (a new one is loaded when the model folder changes).
|
||||
/// (also used by the background scan, which aligns faces by the detector's landmarks and finds faces in photos that have none)
|
||||
pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, NoDetector> {
|
||||
static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None);
|
||||
let home = s
|
||||
.face_models_dir
|
||||
.as_deref()
|
||||
@@ -82,10 +84,133 @@ pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, NoDete
|
||||
let d = Arc::new(
|
||||
Detector::new(&bytes).map_err(|e| NoDetector::Unusable(format!("{e}: remove the detector in Settings > Faces and download it again")))?,
|
||||
);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let net = d.clone();
|
||||
offer_gpu(s, "detection", move |gpu| net.accelerate(gpu, true));
|
||||
}
|
||||
*loaded = Some((path, d.clone()));
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// Where a face model runs, for Settings ▸ Faces and `faces.gpu`.
|
||||
#[derive(Clone, Debug, Default, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GpuState {
|
||||
/// The background check is still running.
|
||||
pub checking: bool,
|
||||
/// The GPU it runs on; `None` when it runs on the CPU.
|
||||
pub device: Option<String>,
|
||||
/// Why it runs on the CPU.
|
||||
pub why: Option<String>,
|
||||
}
|
||||
|
||||
/// Each model's [`GpuState`], by what it does ("detection", "recognition").
|
||||
static GPU_STATE: Mutex<Vec<(&'static str, GpuState)>> = Mutex::new(Vec::new());
|
||||
|
||||
pub(crate) fn gpu_state(what: &str) -> Option<GpuState> {
|
||||
GPU_STATE.lock().unwrap_or_else(PoisonError::into_inner).iter().find(|(w, _)| *w == what).map(|(_, st)| st.clone())
|
||||
}
|
||||
|
||||
fn set_gpu_state(what: &'static str, st: GpuState) {
|
||||
let mut all = GPU_STATE.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
match all.iter_mut().find(|(w, _)| *w == what) {
|
||||
Some((_, old)) => *old = st,
|
||||
None => all.push((what, st)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The user chose the CPU for face models (Settings ▸ Faces).
|
||||
pub(crate) fn cpu_only(s: &Session) -> bool {
|
||||
s.face_models_dir.as_deref().is_some_and(|d| super::face_models::read_settings(d).cpu_only)
|
||||
}
|
||||
|
||||
/// Check in the background whether the GPU runs a face model faster than the CPU and gives its results; the model
|
||||
/// runs on the CPU meanwhile, and stays there when the GPU is off, missing, wrong or slower, or the user chose the CPU.
|
||||
/// The check shows in the activity stack while it runs.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn offer_gpu(
|
||||
s: &Session,
|
||||
what: &'static str,
|
||||
attach: impl FnOnce(Arc<dyn lightcraft_faces::net::Accelerator>) -> std::result::Result<(), String> + Send + 'static,
|
||||
) {
|
||||
if cpu_only(s) {
|
||||
set_gpu_state(what, GpuState { why: Some("the CPU is chosen in Settings".into()), ..GpuState::default() });
|
||||
return;
|
||||
}
|
||||
let task = s.activity.start("facesGpu", "Checking the GPU for faces", crate::activity::Cancel::No);
|
||||
task.detail(what);
|
||||
set_gpu_state(what, GpuState { checking: true, ..GpuState::default() });
|
||||
let spawned = std::thread::Builder::new().name(format!("faces-gpu-{what}")).spawn(move || {
|
||||
let _task = task;
|
||||
let gpu = match lightcraft_gpu::faces::accelerator() {
|
||||
Ok(gpu) => gpu,
|
||||
Err(why) => {
|
||||
log::info!("faces: {what} runs on the CPU ({why})");
|
||||
return set_gpu_state(what, GpuState { why: Some(why), ..GpuState::default() });
|
||||
}
|
||||
};
|
||||
let device = lightcraft_faces::net::Accelerator::device(&*gpu);
|
||||
match attach(gpu) {
|
||||
Ok(()) => {
|
||||
log::info!("faces: {what} runs on {device}");
|
||||
set_gpu_state(what, GpuState { device: Some(device), ..GpuState::default() });
|
||||
}
|
||||
Err(why) => {
|
||||
log::info!("faces: {what} stays on the CPU: {why}");
|
||||
set_gpu_state(what, GpuState { why: Some(why), ..GpuState::default() });
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("faces: could not check the GPU for {what}: {e}");
|
||||
set_gpu_state(what, GpuState { why: Some(format!("could not check the GPU: {e}")), ..GpuState::default() });
|
||||
}
|
||||
}
|
||||
|
||||
/// `faces.gpu`: where the face models run, and the switch that keeps them on the CPU.
|
||||
fn gpu(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.gpu";
|
||||
if let Some(cpu) = p.get("cpuOnly") {
|
||||
let cpu = cpu.as_bool().ok_or_else(|| bad(C, "`cpuOnly` is true or false"))?;
|
||||
let dir = s.face_models_dir.clone().ok_or_else(|| bad(C, "this build has nowhere to keep face models (the desktop app does)"))?;
|
||||
super::face_models::set_cpu_only(&dir, cpu)?;
|
||||
let detector = LOADED.lock().unwrap_or_else(PoisonError::into_inner).as_ref().map(|(_, d)| d.clone());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let embedder = s.faces.embedder.as_ref().map(|(_, e)| e.clone());
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let embedder: Option<Arc<lightcraft_faces::runtime::Embedder>> = None;
|
||||
if cpu {
|
||||
for (what, net) in [("detection", detector.as_ref().map(|d| d.net())), ("recognition", embedder.as_ref().map(|e| e.net()))] {
|
||||
if let Some(net) = net {
|
||||
net.detach();
|
||||
}
|
||||
set_gpu_state(what, GpuState { why: Some("the CPU is chosen in Settings".into()), ..GpuState::default() });
|
||||
}
|
||||
} else {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if let Some(d) = detector {
|
||||
offer_gpu(s, "detection", move |gpu| d.accelerate(gpu, true));
|
||||
}
|
||||
if let Some(e) = embedder {
|
||||
offer_gpu(s, "recognition", move |gpu| e.accelerate(gpu, true).map_err(|e| e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(gpu_json(s))
|
||||
}
|
||||
|
||||
/// What `faces.gpu` and `faces.models.list` say about the GPU.
|
||||
pub(crate) fn gpu_json(s: &Session) -> Value {
|
||||
json!({
|
||||
"cpuOnly": cpu_only(s),
|
||||
"detection": gpu_state("detection"),
|
||||
"recognition": gpu_state("recognition"),
|
||||
})
|
||||
}
|
||||
|
||||
fn face_json(f: &Face) -> Value {
|
||||
json!({
|
||||
"rect": {"x0": f.x0, "y0": f.y0, "x1": f.x1, "y1": f.y1},
|
||||
@@ -132,7 +257,7 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let detector = detector(s).map_err(|e| e.into_error(C))?;
|
||||
let started = Instant::now();
|
||||
let (mut results, mut ops) = (Vec::new(), Vec::new());
|
||||
for id in s.targets(p) {
|
||||
for id in s.targets(p)? {
|
||||
let (rgb, width, height) = match render_rgb(s, id, LOOK_EDGE) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -173,15 +298,26 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"faces.detect",
|
||||
"Detect Faces",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched",
|
||||
has_selection,
|
||||
detect
|
||||
)]
|
||||
vec![
|
||||
cmd!(
|
||||
"faces.detect",
|
||||
"Detect Faces",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched",
|
||||
has_selection,
|
||||
detect
|
||||
),
|
||||
cmd!(
|
||||
query "faces.gpu",
|
||||
"Face Models on the GPU",
|
||||
[],
|
||||
None,
|
||||
"{cpuOnly?: bool} → {cpuOnly, detection, recognition}: where the face models run, each as {checking, device, why} (null until it is loaded). The GPU is used only after a background check shows it gives the CPU's results faster; `cpuOnly: true` keeps every face model on the CPU (Settings ▸ Faces), `false` checks the GPU again",
|
||||
always,
|
||||
gpu
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -27,6 +27,9 @@ pub(super) struct FaceSettings {
|
||||
pub(super) enabled: bool,
|
||||
/// The installed recogniser in use.
|
||||
pub(super) embedder: Option<String>,
|
||||
/// Keep every face model on the CPU, even when the GPU would be faster.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub(super) cpu_only: bool,
|
||||
}
|
||||
|
||||
fn fail(what: &str, e: impl std::fmt::Display) -> EngineError {
|
||||
@@ -57,6 +60,13 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Keep the face models on the CPU (or let them use the GPU again).
|
||||
pub(super) fn set_cpu_only(dir: &Path, on: bool) -> Result<()> {
|
||||
let mut st = read_settings(dir);
|
||||
st.cpu_only = on;
|
||||
write_settings(dir, &st)
|
||||
}
|
||||
|
||||
fn write_settings(dir: &Path, st: &FaceSettings) -> Result<()> {
|
||||
std::fs::create_dir_all(dir).map_err(|e| fail("could not create the face models folder", e))?;
|
||||
write_atomic(&dir.join("settings.json"), &serde_json::to_vec_pretty(st).map_err(|e| fail("settings", e))?)
|
||||
@@ -195,6 +205,8 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
"dir": dir.as_ref().map(|d| d.display().to_string()),
|
||||
"enabled": settings.enabled,
|
||||
"embedder": settings.embedder,
|
||||
// where the models run: {cpuOnly, detection, recognition} as `faces.gpu` gives it
|
||||
"gpu": super::face_detect::gpu_json(s),
|
||||
// Native builds always have the pure-Rust runner; model files/background scanning remain native-only.
|
||||
"runtime": cfg!(not(target_arch = "wasm32")),
|
||||
"models": models,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Recognition commands: `faces.index` (embed the faces in the library), `faces.suggest` (who might an unnamed
|
||||
//! face be?), `faces.person` (one person's faces, and the unnamed faces that look like them), `faces.unnamed` (every
|
||||
//! unnamed face, look-alikes together) and `faces.setName` / `faces.nameFaces` (name one face, or many at once, which is
|
||||
//! what teaches the suggestions).
|
||||
//! what teaches the suggestions). `faces.renamePerson` renames every in-library face of one person, or merges two
|
||||
//! people when asked.
|
||||
//!
|
||||
//! Everything here is a suggestion for the user to confirm; nothing is ever named automatically. All of it is
|
||||
//! catalog-only (no sidecar is written) and `faces.setName` is undoable.
|
||||
//! catalog-only (no sidecar is written) and naming is undoable.
|
||||
|
||||
use lightcraft_catalog::Op;
|
||||
use serde_json::{Value, json};
|
||||
@@ -92,6 +93,92 @@ fn set_name(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": id.0, "index": index, "name": name}))
|
||||
}
|
||||
|
||||
/// `faces.renamePerson {from, to, merge?}`: give every in-library face of `from` the exact name `to`, as one undo
|
||||
/// step. Names match the People view (trimmed, case-insensitive). A `to` that is already someone else is refused
|
||||
/// unless `merge` is set, and then the two people become one. The sidecar is not touched, and a refusal changes nothing.
|
||||
fn rename_person(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.renamePerson";
|
||||
let from = match p.get("from") {
|
||||
Some(Value::String(v)) => {
|
||||
let t = v.trim();
|
||||
if t.is_empty() {
|
||||
return Err(bad(C, "missing `from`"));
|
||||
}
|
||||
t.to_string()
|
||||
}
|
||||
Some(_) => return Err(bad(C, "`from` must be text")),
|
||||
None => return Err(bad(C, "missing `from`")),
|
||||
};
|
||||
let to = match p.get("to") {
|
||||
Some(Value::String(v)) => {
|
||||
let t = v.trim();
|
||||
if t.is_empty() {
|
||||
return Err(bad(C, "`to` must be a name"));
|
||||
}
|
||||
if t.chars().count() > 200 || t.chars().any(char::is_control) {
|
||||
return Err(bad(C, "a name is at most 200 characters, without control characters"));
|
||||
}
|
||||
t.to_string()
|
||||
}
|
||||
Some(_) => return Err(bad(C, "`to` must be text")),
|
||||
None => return Err(bad(C, "missing `to`")),
|
||||
};
|
||||
let merge = super::bool_or(p, "merge", false);
|
||||
let from_key = from.to_lowercase();
|
||||
let to_key = to.to_lowercase();
|
||||
let mut matched = 0usize;
|
||||
let mut other = false;
|
||||
let mut faces = 0usize;
|
||||
let mut ops = Vec::new();
|
||||
let ids: Vec<_> = s.catalog.photos().filter(|ph| ph.in_library()).map(|ph| ph.id).collect();
|
||||
for id in ids {
|
||||
let Some(ph) = s.catalog.photo(id) else { continue };
|
||||
let mut indexes = Vec::new();
|
||||
for (i, r) in ph.meta.regions.iter().enumerate() {
|
||||
if r.kind != lightcraft_meta::RegionKind::Face {
|
||||
continue;
|
||||
}
|
||||
let Some(n) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue };
|
||||
let key = n.to_lowercase();
|
||||
if key == from_key {
|
||||
matched += 1;
|
||||
if r.name.as_deref() != Some(to.as_str()) {
|
||||
indexes.push(i);
|
||||
}
|
||||
} else if key == to_key {
|
||||
other = true;
|
||||
}
|
||||
}
|
||||
if indexes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut meta = ph.meta.clone();
|
||||
for i in indexes {
|
||||
if let Some(region) = meta.regions.get_mut(i) {
|
||||
region.name = Some(to.clone());
|
||||
faces += 1;
|
||||
}
|
||||
}
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(meta) });
|
||||
}
|
||||
if matched == 0 {
|
||||
return Err(bad(C, format!("no person named {from}")));
|
||||
}
|
||||
if faces == 0 {
|
||||
return Err(bad(C, format!("{to} is already their name")));
|
||||
}
|
||||
if other && !merge {
|
||||
return Err(bad(C, format!("{to} is already another person; pass merge: true to combine them")));
|
||||
}
|
||||
let photos = ops.len();
|
||||
s.commit(if other { "Merge People" } else { "Rename Person" }, Op::Batch { ops })?;
|
||||
s.skip_auto_write = true;
|
||||
if s.filter.person.as_deref().is_some_and(|n| n.trim().to_lowercase() == from_key) {
|
||||
s.filter.person = Some(to.clone());
|
||||
}
|
||||
Ok(json!({"faces": faces, "photos": photos, "from": from, "to": to, "merged": other}))
|
||||
}
|
||||
|
||||
/// Most faces `faces.unnamed` lists.
|
||||
const MAX_UNNAMED: usize = 3000;
|
||||
|
||||
@@ -234,6 +321,8 @@ mod imp {
|
||||
return Ok(e.clone());
|
||||
}
|
||||
let embedder = Arc::new(Embedder::load(&dir.join(&id).join("model.onnx"), &installed.manifest).map_err(|e| bad(cmd, e.to_string()))?);
|
||||
let net = embedder.clone();
|
||||
super::super::face_detect::offer_gpu(s, "recognition", move |gpu| net.accelerate(gpu, true).map_err(|e| e.to_string()));
|
||||
let dim = match installed.manifest.output {
|
||||
lightcraft_faces::OutputSpec::Embedding { dim } => dim as usize,
|
||||
_ => return Err(bad(cmd, "the chosen model is not a recognition model")),
|
||||
@@ -275,7 +364,7 @@ mod imp {
|
||||
}
|
||||
let job = s.preview_job(id, EDGE, EDGE, false, &DevelopSettings::default())?;
|
||||
let preview = s.scan_preview_of(&photo);
|
||||
Some(Prepared { tag, epoch: s.faces.epoch, id, job, preview, todo, detect, detector })
|
||||
Some(Prepared { tag, epoch: s.faces.epoch, id, content: crate::media::content_key(&photo), job, preview, todo, detect, detector })
|
||||
}
|
||||
|
||||
/// The faces the detector found in a photo that had none become its face regions: unnamed, marked as detected (so a
|
||||
@@ -299,13 +388,16 @@ mod imp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Take a finished photo into the index (ignored when it was made for another model or another library). Returns
|
||||
/// (embedded, failed).
|
||||
/// Take a finished photo into the index (ignored when it was made for another model or another library, or from
|
||||
/// a file the photo no longer has: issue #1524). Returns (embedded, failed).
|
||||
fn ingest(s: &mut Session, d: Done) -> (usize, usize) {
|
||||
s.faces.in_flight.remove(&d.id);
|
||||
if d.epoch != s.faces.epoch || d.tag != s.faces.index.tag {
|
||||
return (0, 0);
|
||||
}
|
||||
if s.catalog.photo(d.id).is_none_or(|p| crate::media::content_key(p) != d.content) {
|
||||
return (0, 0);
|
||||
}
|
||||
if d.detect_wanted {
|
||||
match &d.found {
|
||||
Some(found) => {
|
||||
@@ -582,6 +674,9 @@ mod imp {
|
||||
if let Some(w) = &s.faces.worker {
|
||||
w.allow(target, pace);
|
||||
}
|
||||
// the GPU is held back the same way: a light pace leaves it mostly free for the window's own renders
|
||||
let (runs, share) = crate::faces_worker::gpu_pace(pace);
|
||||
lightcraft_gpu::faces::set_pace(runs, share);
|
||||
while target > 0 && s.faces.in_flight.len() < target + PREFETCH {
|
||||
let Some(id) = s.faces.queue.pop() else { break };
|
||||
let Some(job) = prepare(s, id, true) else { continue };
|
||||
@@ -751,7 +846,7 @@ mod imp {
|
||||
const C: &str = "faces.suggest";
|
||||
let embedder = current(s, C)?;
|
||||
collect(s);
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
let model_threshold = embedder.manifest().thresholds.match_cosine;
|
||||
let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v as f32);
|
||||
let threshold = num("threshold").or(model_threshold).unwrap_or(DEFAULT_THRESHOLD);
|
||||
@@ -830,7 +925,8 @@ mod imp {
|
||||
}
|
||||
|
||||
fn finished(s: &Session, id: PhotoId, found: Option<Vec<Rect>>, results: Vec<(Rect, Option<Vec<f32>>, Rect)>) -> Done {
|
||||
Done { tag: s.faces.index.tag.clone(), epoch: s.faces.epoch, id, results, detect_wanted: true, found }
|
||||
let content = s.catalog.photo(id).map(|p| crate::media::content_key(p)).unwrap_or_default();
|
||||
Done { tag: s.faces.index.tag.clone(), epoch: s.faces.epoch, id, content, results, detect_wanted: true, found }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -915,6 +1011,42 @@ mod imp {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A Reload that finds the file replaced (an outside edit) gives the photo other pixels: what a worker made from
|
||||
/// the old file is dropped when it comes back, the faces already embedded from it are forgotten and the photo is
|
||||
/// searched again (issue #1524).
|
||||
#[test]
|
||||
fn faces_made_from_a_replaced_file_are_dropped() {
|
||||
let (mut s, d) = loaded("replaced");
|
||||
let (a, b) = two_photos(&s);
|
||||
let rect = region(0.2, None).rect;
|
||||
s.faces.index.insert(a.0, &rect, unit(&[(0, 1.0)]));
|
||||
s.faces.scanned.insert(a.0);
|
||||
s.faces.index.insert(b.0, &rect, unit(&[(1, 1.0)]));
|
||||
s.faces.scanned.insert(b.0);
|
||||
let old = finished(&s, a, Some(vec![rect]), vec![(rect, Some(unit(&[(2, 1.0)])), rect)]);
|
||||
let (width, height) = s.catalog.photo(a).map(|p| (p.width, p.height)).unwrap();
|
||||
let replace = Op::SetContent {
|
||||
id: a,
|
||||
width,
|
||||
height,
|
||||
file_size: 1234,
|
||||
content_hash: Some("edited".into()),
|
||||
preview_only: None,
|
||||
preview_checked: String::new(),
|
||||
};
|
||||
s.commit("Reload Changed Files", replace).unwrap();
|
||||
assert!(s.faces.index.get(a.0, &rect).is_none() && !s.faces.scanned.contains(a.0), "the old file's faces are forgotten");
|
||||
assert!(needs_search(&s, s.catalog.photo(a).unwrap()), "and the photo is searched again");
|
||||
assert!(s.faces.index.get(b.0, &rect).is_some() && s.faces.scanned.contains(b.0), "other photos keep theirs");
|
||||
assert_eq!(ingest(&mut s, old), (0, 0), "a result made from the old file is dropped");
|
||||
assert!(s.catalog.photo(a).unwrap().meta.regions.is_empty() && !s.faces.scanned.contains(a.0));
|
||||
// what is made from the new file is taken in
|
||||
let new = finished(&s, a, Some(vec![rect]), vec![(rect, Some(unit(&[(3, 1.0)])), rect)]);
|
||||
assert_eq!(ingest(&mut s, new), (1, 0));
|
||||
assert!(s.faces.scanned.contains(a.0) && s.faces.index.get(a.0, &rect).is_some());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_unnamed_faces_that_look_like_a_person_come_best_first() {
|
||||
let (mut s, d) = loaded("similar");
|
||||
@@ -1047,5 +1179,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
super::always,
|
||||
set_name
|
||||
),
|
||||
cmd!(
|
||||
"faces.renamePerson",
|
||||
"Rename Person",
|
||||
[],
|
||||
None,
|
||||
"{from, to, merge?: bool} → {faces, photos, from, to, merged} — give every in-library face of `from` the name `to` (trimmed, any capitalization), one undo step; refused when `to` is already someone else unless `merge` is true, which combines the two; the sidecar is not touched",
|
||||
super::always,
|
||||
rename_person
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Focus points for photos already in the library: `photo.readFocusPoints` (Photo ▸ Read Focus Points) and the
|
||||
//! per-frame `focus.pump` that keeps the background job going. The job itself is `crate::focus_scan`.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{CommandSpec, cmd};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
crate::focus_scan::read_points(s, p)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn read_points(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Err(super::bad("photo.readFocusPoints", "reading focus points needs the photos' files on disk"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn pump(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
crate::focus_scan::pump(s, p)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn pump(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(serde_json::json!({"active": false}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"photo.readFocusPoints",
|
||||
"Read Focus Points",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, wait?: false} → {queued, running, last?: {total, done, found, none, unavailable, bytesRead, seconds, cancelled}} — read the camera's focus point for photos imported before it was read (Sony α1 and A7S III raws and JPEGs), from the few KB of each file that hold it, on a background job with a row in the activity stack. Without `ids`, every photo in the library not looked at yet; with `ids`, those photos, looked at again. Photos that have a point keep it. No undo step and no sidecar write. `wait: true` returns when all are read (scripts)",
|
||||
super::always,
|
||||
read_points
|
||||
),
|
||||
cmd!(
|
||||
query "focus.pump",
|
||||
"Read Focus Points in the Background",
|
||||
[],
|
||||
None,
|
||||
"{pace?: pause | light | normal} → {active, running?: {done, total, found}, last?, lookedAt} — cheap to call every frame (the app does): takes in what the background focus-point reader has read and commits it in batches; reads nothing new while paused and rests between files when light. Starts the reader by itself once per library opening when photos are left to look at",
|
||||
super::always,
|
||||
pump
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -115,7 +115,7 @@ fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place,
|
||||
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "metadata.reverseGeocode";
|
||||
let overwrite = bool_or(p, "overwrite", false);
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let todo: Vec<(PhotoId, LatLon)> = ids
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
|
||||
@@ -256,7 +256,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let Some(k) = current_keywords(s).get(i as usize - 1).cloned().filter(|k| !k.is_empty()) else {
|
||||
return Ok(json!({"changed": 0}));
|
||||
};
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let all = !ids.is_empty()
|
||||
&& ids.iter().all(|id| {
|
||||
s.catalog
|
||||
@@ -288,7 +288,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
|s, p| {
|
||||
let mut current: Vec<String> = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
for id in s.targets(p)? {
|
||||
if let Some(ph) = s.catalog.photo(id) {
|
||||
current.extend(ph.meta.keywords.iter().cloned());
|
||||
}
|
||||
@@ -336,7 +336,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Some(parent) => format!("{parent}|{name}"),
|
||||
None => name,
|
||||
});
|
||||
let photos = if bool_or(p, "addToSelected", false) { s.targets(p) } else { Vec::new() };
|
||||
let photos = if bool_or(p, "addToSelected", false) { s.targets(p)? } else { Vec::new() };
|
||||
let op = s
|
||||
.catalog
|
||||
.create_keyword_ops(&path, info_from(p, KeywordInfo::default()), &photos)
|
||||
|
||||
@@ -167,9 +167,22 @@ fn album_targets_or_err(s: &Session, command: &str, targets: Vec<PhotoId>) -> Re
|
||||
Ok((kept, skipped))
|
||||
}
|
||||
|
||||
/// Move to Recently Deleted and drop the photos from stacks so leftover members do not keep
|
||||
/// showing `i/n` (issue #1493).
|
||||
fn mark_deleted(s: &mut Session, ids: &[PhotoId], label: &str) -> Result<usize> {
|
||||
let mut ops: Vec<Op> = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect();
|
||||
let n = ops.len();
|
||||
if n == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
ops.extend(s.catalog.remove_from_stacks_ops(ids));
|
||||
s.commit(label, Op::Batch { ops })?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
/// Apply one op per target as a single undo step.
|
||||
fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option<Op>) -> Result<Value> {
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
let ops: Vec<Op> = targets.iter().filter_map(|id| f(*id)).collect();
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
@@ -181,7 +194,7 @@ fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> O
|
||||
/// Adjust each target's own rating; unchanged bounds do not create an undo step.
|
||||
fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<Value> {
|
||||
let mut ops = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
for id in s.targets(p)? {
|
||||
let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.rating;
|
||||
let rating = (i16::from(old) + i16::from(delta)).clamp(0, 5) as u8;
|
||||
if rating != old {
|
||||
@@ -196,6 +209,25 @@ fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<V
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
|
||||
/// Move each target's own flag by `step`: cycling wraps unflagged -> pick -> reject -> unflagged,
|
||||
/// raising/lowering clamps on reject < unflagged < pick. Unchanged photos make no history step.
|
||||
fn change_flag(s: &mut Session, p: &Value, label: &str, step: impl Fn(Flag) -> Flag) -> Result<Value> {
|
||||
let mut ops = Vec::new();
|
||||
for id in s.targets(p)? {
|
||||
let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.flag;
|
||||
let flag = step(old);
|
||||
if flag != old {
|
||||
ops.push(Op::SetFlag { id, flag });
|
||||
}
|
||||
}
|
||||
let n = ops.len();
|
||||
if n > 0 {
|
||||
s.commit(label, Op::Batch { ops })?;
|
||||
}
|
||||
advance_if(s, p);
|
||||
Ok(json!({"changed": n}))
|
||||
}
|
||||
|
||||
fn advance_if(s: &mut Session, p: &Value) {
|
||||
if bool_or(p, "advance", false) {
|
||||
let _ = step(s, 1);
|
||||
@@ -226,12 +258,16 @@ fn row_step(p: &Value) -> isize {
|
||||
}
|
||||
|
||||
fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> {
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
let mut ops = Vec::new();
|
||||
for id in targets {
|
||||
if let Some(d) = s.develop_of(id) {
|
||||
let mut d = (*d).clone();
|
||||
d.orientation = d.orientation.rotated(cw);
|
||||
// the crop lives in the oriented frame, so it turns with the image (else it keeps the old width and height and cuts the photo)
|
||||
let turn = lightcraft_geom::Orientation::Normal.rotated(cw);
|
||||
d.crop.geometry.rect = turn.map_norm_rect(d.crop.geometry.rect);
|
||||
d.crop.aspect = d.crop.aspect.map(|(w, h)| (h, w));
|
||||
ops.extend(s.develop_op(id, d, "Rotate"));
|
||||
}
|
||||
}
|
||||
@@ -241,7 +277,7 @@ fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
let mut ops = Vec::new();
|
||||
for id in targets {
|
||||
if let Some(d) = s.develop_of(id) {
|
||||
@@ -362,11 +398,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
other => return Err(bad("library.source", format!("unknown source `{other}`"))),
|
||||
};
|
||||
let vis = s.visible_cloned();
|
||||
if s.selection.active.is_none_or(|a| !vis.contains(&a)) {
|
||||
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
|
||||
}
|
||||
Ok(json!({"count": vis.len()}))
|
||||
s.keep_active_in_view();
|
||||
Ok(json!({"count": s.visible().len()}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
@@ -436,8 +469,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if covers_other_disks(s, path, &ids) && !bool_or(p, "disk", false) {
|
||||
return Err(bad(C, format!("{path} holds whole disks: pass `disk: true` to remove everything on them")));
|
||||
}
|
||||
let ops = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect();
|
||||
s.commit("Remove Folder from Library", Op::Batch { ops })?;
|
||||
mark_deleted(s, &ids, "Remove Folder from Library")?;
|
||||
if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) {
|
||||
s.filter.library_folder = None;
|
||||
}
|
||||
@@ -510,6 +542,44 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ok()
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"library.refresh",
|
||||
"Refresh View",
|
||||
["View"],
|
||||
None,
|
||||
"{} — show what matches now: a view that holds its photos (`library.holdView`) lets go of the ones that stopped matching a smart album's rules, Picks or the filter bar since it was opened, sorted or last refreshed",
|
||||
always,
|
||||
|s, _| {
|
||||
s.refresh_view();
|
||||
ok()
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"library.holdView",
|
||||
"Hold View Until Refreshed",
|
||||
["View"],
|
||||
None,
|
||||
"{on?: bool (toggles when omitted)} → {hold} — when on, an edit that makes a photo stop matching the view (a keyword given in a smart album of photos without keywords, a flag taken off in Picks, a rating under the filter bar's) keeps it in the view until `library.refresh`, Reshuffle, or another source, filter or sort; photos that start matching still appear at once, and deleting a photo or taking it out of the album or folder shown still takes it out of the view. Changing a smart album's rules is another view too. Off (the default): the view follows every edit. Kept with the library's preferences; asking for the state it already has changes nothing",
|
||||
always,
|
||||
|s, p| {
|
||||
let on = match p.get("on") {
|
||||
None | Some(Value::Null) => !s.hold_view,
|
||||
Some(Value::Bool(on)) => *on,
|
||||
Some(_) => return Err(bad("library.holdView", "on must be true or false")),
|
||||
};
|
||||
if on != s.hold_view {
|
||||
let was = s.hold_view;
|
||||
s.hold_view = on;
|
||||
if let Err(e) = s.save_prefs() {
|
||||
// not kept: not changed either, so asking again does what was asked
|
||||
s.hold_view = was;
|
||||
return Err(e);
|
||||
}
|
||||
s.refresh_view();
|
||||
}
|
||||
Ok(json!({"hold": s.hold_view}))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"library.shuffle",
|
||||
"Reshuffle",
|
||||
@@ -687,6 +757,40 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
advance_if(s, p);
|
||||
Ok(v)
|
||||
}),
|
||||
// Classic: ` cycles the flag; Cmd+Up / Cmd+Down raise / lower it (Ctrl on Windows and Linux)
|
||||
cmd!(
|
||||
"photo.cycleFlag",
|
||||
"Cycle Flag",
|
||||
["Photo", "Set Flag"],
|
||||
Some("`"),
|
||||
"{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
change_flag(s, p, "Cycle Flag", |f| match f {
|
||||
Flag::None => Flag::Pick,
|
||||
Flag::Pick => Flag::Reject,
|
||||
Flag::Reject => Flag::None,
|
||||
})
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
"photo.increaseFlag",
|
||||
"Increase Flag Status",
|
||||
["Photo", "Set Flag"],
|
||||
Some("Cmd+Up"),
|
||||
"{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick)",
|
||||
has_selection,
|
||||
|s, p| { change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick }) }
|
||||
),
|
||||
cmd!(
|
||||
"photo.decreaseFlag",
|
||||
"Decrease Flag Status",
|
||||
["Photo", "Set Flag"],
|
||||
Some("Cmd+Down"),
|
||||
"{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject)",
|
||||
has_selection,
|
||||
|s, p| { change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject }) }
|
||||
),
|
||||
cmd!("photo.pick", "Flag as Pick", ["Photo", "Set Flag"], Some("P"), "{ids?}", has_selection, |s, p| {
|
||||
for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick }))
|
||||
}),
|
||||
@@ -779,9 +883,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// ---- delete / restore
|
||||
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
|
||||
let before = s.visible_cloned();
|
||||
let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?;
|
||||
let targets = s.targets(p)?;
|
||||
let n = mark_deleted(s, &targets, "Delete")?;
|
||||
select_after_deletion(s, &before);
|
||||
Ok(v)
|
||||
Ok(json!({"changed": n}))
|
||||
}),
|
||||
cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
|
||||
id,
|
||||
@@ -807,7 +912,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| {
|
||||
let before = s.visible_cloned();
|
||||
let t = s.targets(p);
|
||||
let t = s.targets(p)?;
|
||||
let op = s.catalog.delete_photos_permanently_ops(&t);
|
||||
s.commit("Delete Permanently", op)?;
|
||||
select_after_deletion(s, &before);
|
||||
@@ -824,7 +929,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
let strs =
|
||||
|k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::<Vec<_>>());
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
// GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear
|
||||
let gps: Option<Option<(f64, f64)>> = match p.get("gps") {
|
||||
None => None,
|
||||
@@ -922,8 +1027,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
let folder = bool_or(p, "folder", false);
|
||||
let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);
|
||||
let (photos, skipped) =
|
||||
if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.targets(&Value::Null)) } else { (vec![], 0) };
|
||||
let (photos, skipped) = if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.selected()) } else { (vec![], 0) };
|
||||
let id = s.catalog.alloc_album_id();
|
||||
let cover = photos.first().copied();
|
||||
s.commit(
|
||||
@@ -1163,7 +1267,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_library_selection,
|
||||
|s, p| {
|
||||
let id = album_param(p, "id", "album.addPhotos")?;
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.selected());
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?;
|
||||
if al.is_smart() || al.folder {
|
||||
return Err(bad("album.addPhotos", "smart albums and folders can't hold photos"));
|
||||
@@ -1171,8 +1275,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?;
|
||||
let mut photos = al.photos.clone();
|
||||
let before = photos.len();
|
||||
// looked up, not searched for: adding thousands to a big album stays linear
|
||||
let mut held: std::collections::HashSet<PhotoId> = photos.iter().copied().collect();
|
||||
for t in targets {
|
||||
if !photos.contains(&t) {
|
||||
if held.insert(t) {
|
||||
photos.push(t);
|
||||
}
|
||||
}
|
||||
@@ -1190,7 +1296,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped",
|
||||
has_library_selection,
|
||||
|s, p| {
|
||||
let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)))?;
|
||||
let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.selected()))?;
|
||||
let (targets, skipped) = targets;
|
||||
let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder));
|
||||
let id = match target.or_else(|| s.catalog.quick_collection()) {
|
||||
@@ -1203,11 +1309,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
};
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?;
|
||||
let all_in = !targets.is_empty() && targets.iter().all(|t| al.photos.contains(t));
|
||||
// looked up, not searched for: thousands toggled in a big album stay linear
|
||||
let mut held: std::collections::HashSet<PhotoId> = al.photos.iter().copied().collect();
|
||||
let all_in = !targets.is_empty() && targets.iter().all(|t| held.contains(t));
|
||||
let photos: Vec<PhotoId> = if all_in {
|
||||
let targets: std::collections::HashSet<PhotoId> = targets.iter().copied().collect();
|
||||
al.photos.iter().copied().filter(|x| !targets.contains(x)).collect()
|
||||
} else {
|
||||
al.photos.iter().copied().chain(targets.iter().copied().filter(|t| !al.photos.contains(t))).collect()
|
||||
al.photos.iter().copied().chain(targets.iter().copied().filter(|t| held.insert(*t))).collect()
|
||||
};
|
||||
let name = al.name.clone();
|
||||
let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied());
|
||||
@@ -1334,17 +1443,39 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(result)
|
||||
}
|
||||
),
|
||||
cmd!("album.removePhotos", "Remove from Album", [], None, "{id: albumId, ids?}", has_selection, |s, p| {
|
||||
let id = album_param(p, "id", "album.removePhotos")?;
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null));
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?;
|
||||
if al.is_smart() {
|
||||
return Err(bad("album.removePhotos", "smart albums update automatically: change their rules"));
|
||||
cmd!(
|
||||
"album.removePhotos",
|
||||
"Remove from Album",
|
||||
["Photo"],
|
||||
None,
|
||||
"{id?: albumId (default: the album on screen), ids?} — takes the photos out of the album only: they stay in the library and in their folders (issue #1805; photo.delete removes them from the library) → {removed, album: name}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let id = match p.get("id") {
|
||||
Some(_) => album_param(p, "id", "album.removePhotos")?,
|
||||
None => match s.source {
|
||||
LibrarySource::Album(a) => a,
|
||||
_ => return Err(bad("album.removePhotos", "no album: pass `id` or show an album")),
|
||||
},
|
||||
};
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.selected());
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?;
|
||||
if al.is_smart() {
|
||||
return Err(bad("album.removePhotos", "smart albums update automatically: change their rules"));
|
||||
}
|
||||
if al.folder {
|
||||
return Err(bad("album.removePhotos", "album folders don't hold photos"));
|
||||
}
|
||||
let name = al.name.clone();
|
||||
let targets: std::collections::HashSet<PhotoId> = targets.into_iter().collect();
|
||||
let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect();
|
||||
let removed = al.photos.len().saturating_sub(photos.len());
|
||||
if removed > 0 {
|
||||
s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?;
|
||||
}
|
||||
Ok(json!({"removed": removed, "album": name}))
|
||||
}
|
||||
let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect();
|
||||
s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?;
|
||||
ok()
|
||||
}),
|
||||
),
|
||||
cmd!("album.setCover", "Set as Album Cover", [], None, "{id: albumId, photo?: photoId}", has_active, |s, p| {
|
||||
let id = album_param(p, "id", "album.setCover")?;
|
||||
let photo = p.get("photo").and_then(Value::as_u64).map(PhotoId).or(s.active());
|
||||
@@ -1357,7 +1488,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always, |_, p| {
|
||||
let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?;
|
||||
let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?;
|
||||
Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings}))
|
||||
Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings}))
|
||||
}),
|
||||
cmd!(
|
||||
"library.importLightroom",
|
||||
@@ -1623,3 +1754,7 @@ mod gps_tests {
|
||||
#[cfg(test)]
|
||||
#[path = "library/tests_relative_ratings.rs"]
|
||||
mod relative_rating_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "library/tests_flag_cycle.rs"]
|
||||
mod flag_cycle_tests;
|
||||
@@ -0,0 +1,100 @@
|
||||
//! `photo.cycleFlag` / `photo.increaseFlag` / `photo.decreaseFlag` through the real engine.
|
||||
use lightcraft_catalog::{Flag, Op, Photo, PhotoId, Source};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Result, Selection, Session};
|
||||
|
||||
fn session() -> Result<Session> {
|
||||
let mut s = Session::new();
|
||||
for (id, flag) in [(1, Flag::None), (2, Flag::Pick), (3, Flag::Reject), (4, Flag::None)] {
|
||||
let mut photo = Photo::new(
|
||||
PhotoId(id),
|
||||
Source::File { path: format!("/lightcraft-flag-cycle/{id}.jpg") },
|
||||
&format!("{id}.jpg"),
|
||||
"JPEG",
|
||||
40,
|
||||
30,
|
||||
"2026-10-13",
|
||||
);
|
||||
photo.flag = flag;
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?;
|
||||
}
|
||||
s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?;
|
||||
s.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2}))?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
fn flags(s: &Session) -> Vec<Flag> {
|
||||
(1..=4).map(|id| s.catalog.photo(PhotoId(id)).unwrap().flag).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_steps_unflagged_pick_reject_and_wraps() {
|
||||
let mut s = session().unwrap();
|
||||
s.execute("library.select", &json!({"ids": [1]})).unwrap();
|
||||
let seen: Vec<Flag> = (0..4)
|
||||
.map(|_| {
|
||||
s.execute("photo.cycleFlag", &json!({})).unwrap();
|
||||
flags(&s)[0]
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(seen, [Flag::Pick, Flag::Reject, Flag::None, Flag::Pick]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_uses_each_photos_own_flag_in_one_undo_step() {
|
||||
let mut s = session().unwrap();
|
||||
let undo = s.undo.len();
|
||||
assert_eq!(s.execute("photo.cycleFlag", &json!({})).unwrap()["changed"], 3);
|
||||
assert_eq!(flags(&s), [Flag::Pick, Flag::Reject, Flag::None, Flag::None]);
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert_eq!(s.active(), Some(PhotoId(2)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(flags(&s), [Flag::None, Flag::Pick, Flag::Reject, Flag::None]);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(flags(&s), [Flag::Pick, Flag::Reject, Flag::None, Flag::None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_ids_active_fallback_advance_and_errors() {
|
||||
let mut s = session().unwrap();
|
||||
s.execute("photo.cycleFlag", &json!({"ids": [4]})).unwrap();
|
||||
assert_eq!(flags(&s), [Flag::None, Flag::Pick, Flag::Reject, Flag::Pick]);
|
||||
s.selection = Selection { ids: vec![], active: Some(PhotoId(2)) };
|
||||
s.execute("photo.cycleFlag", &json!({})).unwrap();
|
||||
assert_eq!(flags(&s)[1], Flag::Reject);
|
||||
s.execute("photo.cycleFlag", &json!({"advance": true})).unwrap();
|
||||
assert_eq!(flags(&s)[1], Flag::None);
|
||||
assert_eq!(s.active(), Some(PhotoId(3)));
|
||||
let undo = s.undo.len();
|
||||
assert!(s.execute("photo.cycleFlag", &json!({"ids": [1, 999]})).is_err());
|
||||
assert_eq!(flags(&s)[0], Flag::None);
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
s.selection = Selection::default();
|
||||
assert!(s.execute("photo.cycleFlag", &json!({})).is_err());
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn increase_and_decrease_clamp_without_wrapping() {
|
||||
let mut s = session().unwrap();
|
||||
assert_eq!(s.execute("photo.increaseFlag", &json!({})).unwrap()["changed"], 2);
|
||||
assert_eq!(flags(&s), [Flag::Pick, Flag::Pick, Flag::None, Flag::None]);
|
||||
assert_eq!(s.execute("photo.increaseFlag", &json!({"ids": [2]})).unwrap()["changed"], 0);
|
||||
s.execute("photo.decreaseFlag", &json!({})).unwrap();
|
||||
assert_eq!(flags(&s), [Flag::None, Flag::None, Flag::Reject, Flag::None]);
|
||||
s.execute("photo.decreaseFlag", &json!({})).unwrap();
|
||||
assert_eq!(flags(&s), [Flag::Reject, Flag::Reject, Flag::Reject, Flag::None]);
|
||||
let undo = s.undo.len();
|
||||
assert_eq!(s.execute("photo.decreaseFlag", &json!({})).unwrap()["changed"], 0);
|
||||
assert_eq!(s.undo.len(), undo, "no change, no history step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registered_with_classic_shortcuts() {
|
||||
let specs = crate::command_specs();
|
||||
let key = |id: &str| specs.iter().find(|c| c.id == id).and_then(|c| c.shortcut);
|
||||
assert_eq!(key("photo.cycleFlag"), Some("`"));
|
||||
assert_eq!(key("photo.increaseFlag"), Some("Cmd+Up"));
|
||||
assert_eq!(key("photo.decreaseFlag"), Some("Cmd+Down"));
|
||||
}
|
||||
@@ -77,6 +77,7 @@ fn relative_ratings_and_history_use_auto_write() {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Metadata-only fixtures: no import, image decoding or model/service dependency.
|
||||
for id in 1..=4 {
|
||||
std::fs::write(dir.join(format!("{id}.jpg")), b"original").unwrap();
|
||||
s.catalog
|
||||
.apply(Op::SetFile {
|
||||
id: PhotoId(id),
|
||||
|
||||
@@ -135,6 +135,8 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
imported.push(json!({"id": id, "name": display, "group": group}));
|
||||
}
|
||||
s.save_prefs()?;
|
||||
// presets that name these profiles use them now
|
||||
crate::profile_refs::refresh(s);
|
||||
Ok(json!({"imported": imported, "failed": failed}))
|
||||
}
|
||||
|
||||
@@ -160,6 +162,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
lightcraft_pipeline::lut::unregister(&id);
|
||||
s.save_prefs()?;
|
||||
crate::profile_refs::refresh(s);
|
||||
Ok(json!({"deleted": id}))
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::Result;
|
||||
fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec<lightcraft_catalog::PhotoId>, String, usize)> {
|
||||
let template = str_param(p, "template").ok_or_else(|| bad(c, "missing `template`"))?.to_string();
|
||||
let start = p.get("start").and_then(Value::as_u64).unwrap_or(1) as usize;
|
||||
Ok((s.targets(p), template, start))
|
||||
Ok((s.targets(p)?, template, start))
|
||||
}
|
||||
|
||||
/// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name).
|
||||
@@ -96,7 +96,7 @@ fn auto_tag_tracklog(s: &mut crate::Session, p: &Value) -> Result<Value> {
|
||||
let (mut no_time, mut outside, mut has_gps, mut assumed_utc, mut interpolated) = (0, 0, 0, 0, 0);
|
||||
let mut ops = Vec::new();
|
||||
let mut photos = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
for id in s.targets(p)? {
|
||||
let Some(ph) = s.catalog.photo(id) else { continue };
|
||||
if ph.meta.gps.is_some() && !replace {
|
||||
has_gps += 1;
|
||||
@@ -183,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
use lightcraft_catalog::dates::{iso_seconds, normalize_iso, shift_iso};
|
||||
let c = "photo.setCaptureTime";
|
||||
let targets: Vec<_> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect();
|
||||
let targets: Vec<_> = s.targets(p)?.into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect();
|
||||
if targets.is_empty() {
|
||||
return Err(bad(c, "no photos"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
//! Mask effect preset files: Lightroom Classic local adjustment presets (`.lrtemplate`, Lua,
|
||||
//! `type = "LocalizedAdjustmentPreset"`), Camera Raw local correction presets (`.xmp` with
|
||||
//! `crs:Local*` fields, from its `LocalCorrections` folder), our `.lceffect` JSON, and `.zip`
|
||||
//! bundles of these. Folders, and Lightroom users' "divider" presets (`10 ----- DETAIL ----- 10`,
|
||||
//! empty presets that head a numbered section of the menu), become groups.
|
||||
//!
|
||||
//! Read from the files' public structure and black-box observation; settings we have no slider
|
||||
//! for are reported and kept with the effect (an export carries them).
|
||||
|
||||
use lightcraft_develop::LocalAdjustments;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::mask_effects::{MaskEffect, upsert};
|
||||
use super::{bad, str_param};
|
||||
use crate::preset_import::{Lua, group_from_dir, parse_lua, read_zip};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// File extension of exported effect presets (JSON).
|
||||
pub const EFFECT_EXT: &str = "lceffect";
|
||||
/// `format` tag of an exported effect-preset file.
|
||||
const FILE_FORMAT: &str = "lightcraft.maskEffects";
|
||||
/// The Lua `type` of a Lightroom local adjustment preset.
|
||||
const LR_LOCAL_TYPE: &str = "LocalizedAdjustmentPreset";
|
||||
/// Larger files aren't presets (or bundles of them).
|
||||
const MAX_FILE: u64 = 64 << 20;
|
||||
/// How deep nested Lua tables are kept as JSON.
|
||||
const MAX_DEPTH: usize = 16;
|
||||
|
||||
/// An effect read from a file, with the settings it couldn't keep and where it came from.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Found {
|
||||
pub effect: MaskEffect,
|
||||
pub unmapped: Vec<String>,
|
||||
pub file: String,
|
||||
/// Effects of one folder (or folder inside a bundle) share this key: divider presets group
|
||||
/// the effects next to them.
|
||||
pub dir: String,
|
||||
/// The group the folder gives (when the file names none).
|
||||
pub dir_group: Option<String>,
|
||||
/// The file named its group (`.lceffect`, `crs:Group`): kept.
|
||||
pub has_group: bool,
|
||||
}
|
||||
|
||||
/// A readable name for a Lightroom local setting we can't carry over.
|
||||
fn setting_label(key: &str) -> String {
|
||||
let k = key.trim_start_matches("Local");
|
||||
let lower = k.to_ascii_lowercase();
|
||||
match lower.as_str() {
|
||||
"grain" => "Grain".into(),
|
||||
"toningluminance" => "Color luminance".into(),
|
||||
"pointcolors" => "Point Color".into(),
|
||||
"refinesaturation" => "Refine Saturation".into(),
|
||||
"curverefinesaturation" => "Refine Saturation".into(),
|
||||
"maincurve" | "redcurve" | "greencurve" | "bluecurve" => "Curve".into(),
|
||||
_ => k.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A Lua value as JSON (tables nested at most [`MAX_DEPTH`] deep).
|
||||
fn lua_json(v: &Lua, depth: usize) -> Value {
|
||||
match v {
|
||||
Lua::Nil => Value::Null,
|
||||
Lua::Bool(b) => json!(b),
|
||||
Lua::Num(n) => serde_json::Number::from_f64(*n).map(Value::Number).unwrap_or(Value::Null),
|
||||
Lua::Str(s) => json!(s),
|
||||
Lua::Table(..) if depth >= MAX_DEPTH => Value::Null,
|
||||
Lua::Table(items, fields) if fields.is_empty() => Value::Array(items.iter().map(|x| lua_json(x, depth + 1)).collect()),
|
||||
Lua::Table(_, fields) => Value::Object(fields.iter().map(|(k, x)| (k.clone(), lua_json(x, depth + 1))).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Does a setting value do anything (a non-zero number, a non-empty table, …)?
|
||||
fn meaningful(v: &Value) -> bool {
|
||||
match v {
|
||||
Value::Null => false,
|
||||
Value::Bool(b) => *b,
|
||||
Value::Number(n) => n.as_f64().is_some_and(|x| x.abs() > 1e-9),
|
||||
Value::String(s) => !s.is_empty(),
|
||||
Value::Array(a) => !a.is_empty(),
|
||||
Value::Object(o) => !o.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `text` a Lightroom local adjustment preset (`.lrtemplate`)?
|
||||
pub fn is_lr_local_template(text: &str) -> bool {
|
||||
parse_lua(text.trim_start_matches('\u{feff}'))
|
||||
.ok()
|
||||
.and_then(|root| root.get("type").and_then(Lua::str).map(|t| t == LR_LOCAL_TYPE))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// `$$$/Path/Key=Default` (a localisable string) → `Default`.
|
||||
fn delocalize(t: &str) -> &str {
|
||||
t.strip_prefix("$$$/").and_then(|r| r.split_once('=')).map_or(t, |(_, d)| d)
|
||||
}
|
||||
|
||||
/// A Lightroom local adjustment preset → an effect named after its title (else `stem`), and the
|
||||
/// settings we have no equivalent for (e.g. `Grain`, `Point Color`), which the effect keeps in
|
||||
/// `extra`.
|
||||
pub fn parse_lr_local_template(text: &str, stem: &str) -> std::result::Result<(MaskEffect, Vec<String>), String> {
|
||||
let root = parse_lua(text.trim_start_matches('\u{feff}'))?;
|
||||
if root.get("type").and_then(Lua::str) != Some(LR_LOCAL_TYPE) {
|
||||
return Err("not a local adjustment preset".into());
|
||||
}
|
||||
let Some(Lua::Table(_, fields)) = root.get("value") else { return Err("no adjustments in this local preset".into()) };
|
||||
// `exposure2012` → `LocalExposure2012`: the template's keys are the XMP `Local*` names, lower camel case
|
||||
let crs = |k: &str| {
|
||||
let mut c = k.chars();
|
||||
c.next().map(|f| format!("Local{}{}", f.to_ascii_uppercase(), c.as_str())).unwrap_or_default()
|
||||
};
|
||||
let num = |k: &str| fields.iter().find(|(f, _)| crs(f) == k).and_then(|(_, v)| if let Lua::Num(n) = v { Some(*n) } else { None });
|
||||
let values = crate::crs_masks::local_values(num);
|
||||
let mut extra = Map::new();
|
||||
for (k, v) in fields {
|
||||
if crate::crs_masks::is_local_field(&crs(k)) {
|
||||
continue;
|
||||
}
|
||||
let j = lua_json(v, 0);
|
||||
if meaningful(&j) {
|
||||
extra.insert(k.clone(), j);
|
||||
}
|
||||
}
|
||||
let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(str::trim).filter(|t| !t.is_empty());
|
||||
let name = title.map(|t| delocalize(t).trim().to_string()).filter(|t| !t.is_empty()).unwrap_or_else(|| stem.to_string());
|
||||
build(name, values, extra)
|
||||
}
|
||||
|
||||
fn build(name: String, values: Map<String, Value>, extra: Map<String, Value>) -> std::result::Result<(MaskEffect, Vec<String>), String> {
|
||||
let adjust: LocalAdjustments = serde_json::from_value(Value::Object(values)).map_err(|e| format!("unreadable local preset: {e}"))?;
|
||||
let mut unmapped: Vec<String> = extra.keys().map(|k| setting_label(k)).collect();
|
||||
unmapped.sort();
|
||||
unmapped.dedup();
|
||||
let effect = MaskEffect { name, adjust, extra, ..Default::default() }.normalized();
|
||||
Ok((effect, unmapped))
|
||||
}
|
||||
|
||||
/// The local slider fields of a Camera Raw local correction preset: its top-level `crs:Local*`
|
||||
/// properties, or (a preset typed `Local` without them) its first correction's.
|
||||
fn xmp_local_fields(d: &lightcraft_meta::XmpData) -> Option<Map<String, Value>> {
|
||||
let mut out = Map::new();
|
||||
for (k, v) in &d.properties {
|
||||
if let Some(name) = k.strip_prefix("crs:").filter(|n| n.starts_with("Local") && !n.contains('/'))
|
||||
&& let Some(x) = v.first()
|
||||
{
|
||||
out.insert(name.to_string(), json!(x.trim()));
|
||||
}
|
||||
}
|
||||
if !out.is_empty() {
|
||||
return Some(out);
|
||||
}
|
||||
let typed_local = d.properties.get("crs:PresetType").and_then(|v| v.first()).is_some_and(|t| t.trim().eq_ignore_ascii_case("local"));
|
||||
if !typed_local {
|
||||
return None;
|
||||
}
|
||||
let correction = crate::crs_masks::CONTAINERS.iter().find_map(|c| d.values.get(*c).and_then(|l| l.items().first()))?;
|
||||
let lightcraft_meta::XmpValue::Struct(m) = correction else { return None };
|
||||
for (k, v) in m {
|
||||
if let Some(name) = k.strip_prefix("crs:").filter(|n| n.starts_with("Local"))
|
||||
&& let Some(x) = v.text()
|
||||
{
|
||||
out.insert(name.to_string(), json!(x));
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Is `text` a Camera Raw local correction preset (XMP)?
|
||||
pub fn is_local_xmp(text: &str) -> bool {
|
||||
lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).ok().and_then(|d| xmp_local_fields(&d)).is_some()
|
||||
}
|
||||
|
||||
/// A Camera Raw local correction preset (XMP) → an effect named after `crs:Name` (else `stem`),
|
||||
/// in `crs:Group` if it names one.
|
||||
pub fn parse_local_xmp(text: &str, stem: &str) -> std::result::Result<(MaskEffect, Vec<String>), String> {
|
||||
let d = lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).map_err(|e| e.to_string())?;
|
||||
let fields = xmp_local_fields(&d).ok_or("not a local correction preset (no crs:Local… settings)")?;
|
||||
let num = |v: &Value| v.as_str().map(|s| s.strip_prefix('+').unwrap_or(s)).and_then(|s| s.parse::<f64>().ok()).filter(|x| x.is_finite());
|
||||
let values = crate::crs_masks::local_values(|k| fields.get(k).and_then(num));
|
||||
let mut extra = Map::new();
|
||||
for (k, v) in &fields {
|
||||
if crate::crs_masks::is_local_field(k) {
|
||||
continue;
|
||||
}
|
||||
let v = num(v).map_or_else(|| v.clone(), |x| json!(x));
|
||||
if meaningful(&v) {
|
||||
extra.insert(k.clone(), v);
|
||||
}
|
||||
}
|
||||
let first = |k: &str| d.properties.get(k).and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
|
||||
let name = first("crs:Name").unwrap_or_else(|| stem.to_string());
|
||||
let (mut e, unmapped) = build(name, values, extra)?;
|
||||
e.group = first("crs:Group").unwrap_or_default();
|
||||
Ok((e, unmapped))
|
||||
}
|
||||
|
||||
/// Effects in an `.lceffect` file: `{format, version, effects: [...]}`, a bare array, or one effect.
|
||||
pub fn parse_file(bytes: &[u8]) -> std::result::Result<Vec<MaskEffect>, String> {
|
||||
let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not an effect preset file: {e}"))?;
|
||||
let items = match v {
|
||||
Value::Object(mut o) if o.contains_key("effects") => o.remove("effects").unwrap_or_default(),
|
||||
v @ Value::Array(_) => v,
|
||||
v @ Value::Object(_) => Value::Array(vec![v]),
|
||||
_ => return Err("not an effect preset file".into()),
|
||||
};
|
||||
let list: Vec<MaskEffect> = serde_json::from_value(items).map_err(|e| format!("not an effect preset file: {e}"))?;
|
||||
let list: Vec<MaskEffect> = list.into_iter().filter(|e| !e.name.trim().is_empty()).map(MaskEffect::normalized).collect();
|
||||
if list.is_empty() {
|
||||
return Err("no effect presets in the file".into());
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
fn ext_of(name: &str) -> String {
|
||||
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn stem_of(name: &str) -> String {
|
||||
std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "Effect".into())
|
||||
}
|
||||
|
||||
type Parsed = std::result::Result<Vec<(MaskEffect, Vec<String>)>, String>;
|
||||
|
||||
/// The effects in file `name` (`bytes`) if it is a local preset (`.lrtemplate` of the local type,
|
||||
/// a local correction `.xmp`, an `.lceffect`); `None` for any other file (a develop preset…).
|
||||
pub fn parse_bytes(name: &str, bytes: &[u8]) -> Option<Parsed> {
|
||||
let stem = stem_of(name);
|
||||
match ext_of(name).as_str() {
|
||||
"lrtemplate" => {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
is_lr_local_template(&text).then(|| parse_lr_local_template(&text, &stem).map(|e| vec![e]))
|
||||
}
|
||||
"xmp" => {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
is_local_xmp(&text).then(|| parse_local_xmp(&text, &stem).map(|e| vec![e]))
|
||||
}
|
||||
EFFECT_EXT => Some(parse_file(bytes).map(|v| v.into_iter().map(|e| (e, Vec::new())).collect())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The group effects in folder `dir` (relative to the imported folder) go to.
|
||||
pub fn group_for_dir(dir: &str) -> Option<String> {
|
||||
effect_group_from_dir(dir)
|
||||
}
|
||||
|
||||
/// Folder names that only say "local presets" (`Local Adjustment Presets`, `LocalCorrections`…)
|
||||
/// give no group.
|
||||
fn effect_group_from_dir(dir: &str) -> Option<String> {
|
||||
const FILLER: &[&str] = &[
|
||||
"local",
|
||||
"adjustment",
|
||||
"adjustments",
|
||||
"localcorrections",
|
||||
"corrections",
|
||||
"correction",
|
||||
"brush",
|
||||
"brushes",
|
||||
"mask",
|
||||
"masks",
|
||||
"effect",
|
||||
"effects",
|
||||
];
|
||||
let generic = |part: &str| {
|
||||
let lower = part.to_ascii_lowercase();
|
||||
let words: Vec<&str> = lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
|
||||
words.iter().any(|w| FILLER.contains(w))
|
||||
&& words.iter().all(|w| FILLER.contains(w) || matches!(*w, "presets" | "preset" | "lightroom" | "camera" | "raw" | "classic"))
|
||||
};
|
||||
let kept: Vec<&str> = dir.split(['/', '\\']).filter(|p| !p.is_empty() && !generic(p)).collect();
|
||||
group_from_dir(&kept.join("/"))
|
||||
}
|
||||
|
||||
/// Effects found in `bytes` of file `file` (a `.zip` is opened: the local presets inside).
|
||||
/// `dir_group`: the group the file's folder gives. Errors are `(file, message)`.
|
||||
pub fn found_in(file: &str, bytes: &[u8], dir_group: Option<String>, out: &mut Vec<Found>, failed: &mut Vec<(String, String)>) -> bool {
|
||||
let dir = std::path::Path::new(file).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default();
|
||||
if ext_of(file) == "zip" {
|
||||
let Ok(entries) = read_zip(bytes) else { return false };
|
||||
let zip_group = effect_group_from_dir(&stem_of(file)).or(dir_group);
|
||||
let mut any = false;
|
||||
for (inner, data) in entries {
|
||||
let inner_dir = inner.rsplit_once('/').map(|(d, _)| d.to_string()).unwrap_or_default();
|
||||
let group = effect_group_from_dir(&inner_dir).or_else(|| zip_group.clone());
|
||||
let label = format!("{file}/{inner}");
|
||||
match parse_bytes(&inner, &data) {
|
||||
Some(Ok(list)) => {
|
||||
any = true;
|
||||
push(out, list, &label, format!("{file}/{inner_dir}"), group, &inner);
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
any = true;
|
||||
failed.push((label, e));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
return any;
|
||||
}
|
||||
match parse_bytes(file, bytes) {
|
||||
Some(Ok(list)) => {
|
||||
push(out, list, file, dir, dir_group, file);
|
||||
true
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
failed.push((file.to_string(), e));
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn push(out: &mut Vec<Found>, list: Vec<(MaskEffect, Vec<String>)>, file: &str, dir: String, dir_group: Option<String>, name: &str) {
|
||||
let named = ext_of(name) == EFFECT_EXT;
|
||||
for (effect, unmapped) in list {
|
||||
let has_group = named || !effect.group.is_empty();
|
||||
out.push(Found { effect, unmapped, file: file.to_string(), dir: dir.clone(), dir_group: dir_group.clone(), has_group });
|
||||
}
|
||||
}
|
||||
|
||||
/// The number a preset name starts with (`24a-Eye` → 24).
|
||||
fn leading_number(name: &str) -> Option<u64> {
|
||||
let digits: String = name.trim().chars().take_while(char::is_ascii_digit).take(18).collect();
|
||||
digits.parse().ok()
|
||||
}
|
||||
|
||||
/// The section title of a divider preset — an effect that sets nothing, named like
|
||||
/// `10 ----- DETAIL ENHANCERS ----- 10` (a run of at least three `-`, `=`, `_`, `*`, `#`, `~`, `—`).
|
||||
pub fn divider_title(e: &MaskEffect) -> Option<String> {
|
||||
if !e.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let marks = ['-', '=', '_', '*', '#', '~', '—', '–', '•', '.'];
|
||||
let chars: Vec<char> = e.name.chars().collect();
|
||||
let has_run = chars.windows(3).any(|w| marks.contains(&w[0]) && w[0] == w[1] && w[1] == w[2]);
|
||||
if !has_run {
|
||||
return None;
|
||||
}
|
||||
let title: String = e.name.chars().map(|c| if marks.contains(&c) { ' ' } else { c }).collect();
|
||||
let title = title.trim().trim_start_matches(|c: char| c.is_ascii_digit() || c.is_whitespace());
|
||||
let title = title.trim_end_matches(|c: char| c.is_ascii_digit() || c.is_whitespace());
|
||||
let title = title.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if !title.chars().any(char::is_alphanumeric) {
|
||||
return None;
|
||||
}
|
||||
// "DETAIL ENHANCERS" → "Detail Enhancers"
|
||||
let shouting = title.chars().any(char::is_alphabetic) && !title.chars().any(char::is_lowercase);
|
||||
Some(if shouting {
|
||||
title
|
||||
.split(' ')
|
||||
.map(|w| {
|
||||
let mut c = w.chars();
|
||||
c.next().map(|f| f.to_uppercase().chain(c.flat_map(char::to_lowercase)).collect::<String>()).unwrap_or_default()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
} else {
|
||||
title
|
||||
})
|
||||
}
|
||||
|
||||
/// Give `found` their groups; returns the divider presets (which become groups, not effects) as
|
||||
/// `(found, group)`. A numbered effect goes to the divider with the largest number not above its
|
||||
/// own (`12 Sky` under `10 --- DETAIL --- 10`); others to their folder's group.
|
||||
pub fn assign_groups(found: Vec<Found>, group: Option<&str>) -> (Vec<Found>, Vec<(Found, String)>) {
|
||||
let (dividers, mut effects): (Vec<(Found, Option<String>)>, Vec<(Found, Option<String>)>) =
|
||||
found.into_iter().map(|f| (divider_title(&f.effect).filter(|_| !f.has_group), f)).map(|(t, f)| (f, t)).partition(|(_, t)| t.is_some());
|
||||
let dividers: Vec<(Found, String)> = dividers.into_iter().filter_map(|(f, t)| t.map(|t| (f, t))).collect();
|
||||
for (f, _) in &mut effects {
|
||||
if let Some(g) = group {
|
||||
f.effect.group = g.to_string();
|
||||
continue;
|
||||
}
|
||||
if f.has_group {
|
||||
continue;
|
||||
}
|
||||
let section = leading_number(&f.effect.name).and_then(|n| {
|
||||
dividers
|
||||
.iter()
|
||||
.filter(|(d, _)| d.dir == f.dir)
|
||||
.filter_map(|(d, t)| leading_number(&d.effect.name).filter(|m| *m <= n).map(|m| (m, t)))
|
||||
.max_by_key(|(m, _)| *m)
|
||||
.map(|(_, t)| t.clone())
|
||||
});
|
||||
f.effect.group = section.or_else(|| f.dir_group.clone()).unwrap_or_default();
|
||||
}
|
||||
(effects.into_iter().map(|(f, _)| f).collect(), dividers)
|
||||
}
|
||||
|
||||
/// Add the found effects (grouped; same-named ones replaced; effects left from an earlier import
|
||||
/// of a divider dropped) and save → the report rows `(imported, dividers)`.
|
||||
pub fn add_found(s: &mut Session, found: Vec<Found>, group: Option<&str>, dry: bool) -> Result<(Vec<Value>, Vec<Value>)> {
|
||||
let (effects, dividers) = assign_groups(found, group);
|
||||
let rows: Vec<Value> =
|
||||
effects.iter().map(|f| json!({"name": f.effect.name, "group": f.effect.group, "file": f.file, "unmapped": f.unmapped})).collect();
|
||||
let div_rows: Vec<Value> = dividers.iter().map(|(f, g)| json!({"name": f.effect.name, "group": g, "file": f.file})).collect();
|
||||
if dry || (effects.is_empty() && dividers.is_empty()) {
|
||||
return Ok((rows, div_rows));
|
||||
}
|
||||
s.mask_effects.retain(|e| !(e.is_empty() && dividers.iter().any(|(d, _)| d.effect.name.eq_ignore_ascii_case(&e.name))));
|
||||
for f in effects {
|
||||
upsert(s, f.effect);
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok((rows, div_rows))
|
||||
}
|
||||
|
||||
/// The files under `paths` an effect import reads (folders walked, at most 8 levels), each with
|
||||
/// the group its folder gives and whether it was named (not found in a folder).
|
||||
fn effect_files(paths: &[String]) -> Vec<(String, Option<String>, bool)> {
|
||||
fn walk(dir: &std::path::Path, base: &std::path::Path, depth: usize, out: &mut Vec<(String, Option<String>, bool)>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
let mut entries: Vec<_> = rd.flatten().map(|e| e.path()).collect();
|
||||
entries.sort();
|
||||
for p in entries {
|
||||
if p.is_dir() {
|
||||
if depth < 8 {
|
||||
walk(&p, base, depth + 1, out);
|
||||
}
|
||||
} else if p.extension().is_some_and(|e| ["lrtemplate", EFFECT_EXT, "xmp", "zip"].iter().any(|x| e.eq_ignore_ascii_case(x))) {
|
||||
let rel = p.parent().and_then(|d| d.strip_prefix(base).ok()).map(|d| d.to_string_lossy().to_string());
|
||||
out.push((p.to_string_lossy().to_string(), rel.and_then(|d| effect_group_from_dir(&d)), false));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for x in paths {
|
||||
let p = std::path::Path::new(x);
|
||||
if p.is_dir() {
|
||||
walk(p, p.parent().unwrap_or(std::path::Path::new("")), 0, &mut out);
|
||||
} else {
|
||||
out.push((x.clone(), None, true));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Read a file of at most [`MAX_FILE`] bytes.
|
||||
pub fn read_capped(path: &str) -> std::result::Result<Vec<u8>, String> {
|
||||
let len = std::fs::metadata(path).map_err(|e| e.to_string())?.len();
|
||||
if len > MAX_FILE {
|
||||
return Err(format!("too large for a preset file ({} MB)", len >> 20));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn import_effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.importEffects";
|
||||
let mut paths: Vec<String> =
|
||||
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
|
||||
if let Some(x) = str_param(p, "path") {
|
||||
paths.push(x.to_string());
|
||||
}
|
||||
if paths.is_empty() {
|
||||
return Err(bad(C, "missing `path` / `paths`"));
|
||||
}
|
||||
let group = str_param(p, "group").map(str::trim).filter(|g| !g.is_empty());
|
||||
let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false);
|
||||
let (mut found, mut failed) = (Vec::new(), Vec::new());
|
||||
for (f, dir_group, named) in effect_files(&paths) {
|
||||
match read_capped(&f) {
|
||||
Ok(bytes) => {
|
||||
if !found_in(&f, &bytes, dir_group, &mut found, &mut failed) && named {
|
||||
failed.push((f, "not a local adjustment preset".into()));
|
||||
}
|
||||
}
|
||||
Err(e) => failed.push((f, e)),
|
||||
}
|
||||
}
|
||||
let (imported, dividers) = add_found(s, found, group, dry)?;
|
||||
Ok(json!({"imported": imported, "dividers": dividers, "failed": failed.into_iter().map(|(f, e)| json!([f, e])).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
pub fn export_effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.exportEffects";
|
||||
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let mut path = std::path::PathBuf::from(path);
|
||||
if path.extension().is_none() {
|
||||
path.set_extension(EFFECT_EXT);
|
||||
}
|
||||
let names: Option<Vec<String>> =
|
||||
p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
|
||||
let group = str_param(p, "group").map(str::trim);
|
||||
let chosen: Vec<&MaskEffect> = s
|
||||
.mask_effects
|
||||
.iter()
|
||||
.filter(|e| names.as_ref().is_none_or(|n| n.iter().any(|x| x.eq_ignore_ascii_case(&e.name))))
|
||||
.filter(|e| group.is_none_or(|g| g.eq_ignore_ascii_case(&e.group)))
|
||||
.collect();
|
||||
if chosen.is_empty() {
|
||||
return Err(bad(C, "no effect presets to export"));
|
||||
}
|
||||
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
}
|
||||
let text = serde_json::to_string_pretty(&json!({"format": FILE_FORMAT, "version": 2, "effects": chosen}))
|
||||
.map_err(|e| crate::EngineError::Other(e.to_string()))?;
|
||||
std::fs::write(&path, text).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// A synthetic template in the shape Lightroom writes (our own values).
|
||||
const TEMPLATE: &str = r#"s = {
|
||||
id = "00000000-0000-0000-0000-000000000001",
|
||||
internalName = "Soft Glow",
|
||||
title = "Soft Glow",
|
||||
type = "LocalizedAdjustmentPreset",
|
||||
value = {
|
||||
clarity = 0.5,
|
||||
clarity2012 = -0.25,
|
||||
exposure = 0,
|
||||
exposure2012 = 0.125,
|
||||
grain = 0.25,
|
||||
highlights2012 = -0.5,
|
||||
luminanceNoise = 0.1,
|
||||
refineSaturation = 0,
|
||||
temperature = 0.2,
|
||||
texture = -0.3,
|
||||
toningHue = 240,
|
||||
toningLuminance = 0.1,
|
||||
toningSaturation = 0,
|
||||
pointColors = {
|
||||
{
|
||||
HueRange = { LowerFull = 0.3, LowerNone = 0, UpperFull = 0.6, UpperNone = 1, },
|
||||
HueShift = -0.05,
|
||||
RangeAmount = 0.5,
|
||||
SrcHue = 0.8,
|
||||
},
|
||||
},
|
||||
},
|
||||
version = 0,
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn reads_a_lightroom_local_template() {
|
||||
assert!(is_lr_local_template(TEMPLATE));
|
||||
let (e, unmapped) = parse_lr_local_template(TEMPLATE, "file").unwrap();
|
||||
assert_eq!(e.name, "Soft Glow");
|
||||
assert!((e.adjust.exposure - 0.5).abs() < 1e-9, "exposure2012 is a quarter of the stops");
|
||||
assert!((e.adjust.clarity + 25.0).abs() < 1e-9, "the 2012 field wins over the old one");
|
||||
assert!((e.adjust.highlights + 50.0).abs() < 1e-9);
|
||||
assert!((e.adjust.temp - 20.0).abs() < 1e-9);
|
||||
assert!((e.adjust.texture + 30.0).abs() < 1e-9);
|
||||
assert!((e.adjust.noise - 10.0).abs() < 1e-9);
|
||||
assert!((e.adjust.amount - 100.0).abs() < 1e-9);
|
||||
assert_eq!(unmapped, vec!["Color luminance".to_string(), "Grain".into(), "Point Color".into()]);
|
||||
assert_eq!(e.extra.get("grain"), Some(&json!(0.25)), "kept for export");
|
||||
assert_eq!(e.extra["pointColors"][0]["HueRange"]["UpperFull"], json!(0.6));
|
||||
assert!(!e.extra.contains_key("refineSaturation"), "zero settings aren't kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn develop_templates_are_not_local_ones() {
|
||||
let develop = r#"s = { title = "X", type = "Develop", value = { settings = { Exposure2012 = 1 } } }"#;
|
||||
assert!(!is_lr_local_template(develop));
|
||||
assert!(parse_lr_local_template(develop, "x").is_err());
|
||||
assert!(parse_bytes("x.lrtemplate", develop.as_bytes()).is_none(), "left to the develop preset import");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_files_are_errors_not_panics() {
|
||||
for bad in [
|
||||
"garbage {",
|
||||
"",
|
||||
"s = {",
|
||||
r#"s = { type = "LocalizedAdjustmentPreset", value = 3 }"#,
|
||||
r#"s = { type = "LocalizedAdjustmentPreset" }"#,
|
||||
r#"s = { type = "LocalizedAdjustmentPreset", value = { exposure2012 = "x", grain = {{{{}}}} } }"#,
|
||||
"s = { type = \"LocalizedAdjustmentPreset\", value = { exposure2012 = 1e400, contrast2012 = -1e400 } }",
|
||||
"\u{feff}s = { type = \"LocalizedAdjustmentPreset\", title = \"\", value = {} }",
|
||||
] {
|
||||
let _ = parse_lr_local_template(bad, "x");
|
||||
let _ = parse_bytes("x.lrtemplate", bad.as_bytes());
|
||||
}
|
||||
// deeply nested tables
|
||||
let deep = format!("s = {{ type = \"LocalizedAdjustmentPreset\", value = {{ pointColors = {}1{} }} }}", "{".repeat(200), "}".repeat(200));
|
||||
let _ = parse_lr_local_template(&deep, "x");
|
||||
let (e, _) = parse_lr_local_template("s = { type = \"LocalizedAdjustmentPreset\", value = { exposure2012 = 9 } }", "Stem").unwrap();
|
||||
assert_eq!((e.name.as_str(), e.adjust.exposure), ("Stem", 4.0), "values clamp, the file name names it");
|
||||
for bad in ["", "<x:xmpmeta", "<x:xmpmeta></x:xmpmeta>", "\u{0}\u{1}"] {
|
||||
assert!(parse_bytes("x.xmp", bad.as_bytes()).is_none_or(|r| r.is_err()));
|
||||
}
|
||||
for bad in ["", "3", "[]", "{\"effects\": 3}", "[{\"name\": 4}]", "{\"effects\": [{\"name\": \" \"}]}"] {
|
||||
assert!(parse_file(bad.as_bytes()).is_err(), "{bad}");
|
||||
}
|
||||
assert!(!found_in("x.zip", b"PK\x05\x06garbage", None, &mut Vec::new(), &mut Vec::new()));
|
||||
}
|
||||
|
||||
/// A Camera Raw local correction preset (our own values).
|
||||
const ACR: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
|
||||
crs:PresetType="Local" crs:LocalExposure2012="+0.25" crs:LocalShadows2012="0.4" crs:LocalGrain="0.2" crs:LocalTexture="0">
|
||||
<crs:Name><rdf:Alt><rdf:li xml:lang="x-default">Lift Shadows</rdf:li></rdf:Alt></crs:Name>
|
||||
<crs:Group><rdf:Alt><rdf:li xml:lang="x-default">Faces</rdf:li></rdf:Alt></crs:Group>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
|
||||
#[test]
|
||||
fn reads_a_camera_raw_local_preset() {
|
||||
assert!(is_local_xmp(ACR));
|
||||
let (e, unmapped) = parse_local_xmp(ACR, "file").unwrap();
|
||||
assert_eq!((e.name.as_str(), e.group.as_str()), ("Lift Shadows", "Faces"));
|
||||
assert_eq!((e.adjust.exposure, e.adjust.shadows), (1.0, 40.0));
|
||||
assert_eq!(unmapped, vec!["Grain".to_string()]);
|
||||
// a develop preset is not one
|
||||
let develop = ACR
|
||||
.replace(
|
||||
"crs:LocalExposure2012=\"+0.25\" crs:LocalShadows2012=\"0.4\" crs:LocalGrain=\"0.2\" crs:LocalTexture=\"0\"",
|
||||
"crs:Exposure2012=\"1\"",
|
||||
)
|
||||
.replace("PresetType=\"Local\"", "PresetType=\"Normal\"");
|
||||
assert!(!is_local_xmp(&develop));
|
||||
// a preset typed Local that keeps its sliders in a correction
|
||||
let nested = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:PresetType="Local">
|
||||
<crs:MaskGroupBasedCorrections><rdf:Seq><rdf:li><rdf:Description crs:What="Correction" crs:LocalClarity2012="-0.5"/></rdf:li></rdf:Seq></crs:MaskGroupBasedCorrections>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
let (e, _) = parse_local_xmp(nested, "Soft").unwrap();
|
||||
assert_eq!((e.name.as_str(), e.adjust.clarity), ("Soft", -50.0));
|
||||
}
|
||||
|
||||
fn found(name: &str, exposure: f64, dir: &str) -> Found {
|
||||
Found {
|
||||
effect: MaskEffect { name: name.into(), adjust: LocalAdjustments { exposure, ..Default::default() }, ..Default::default() },
|
||||
unmapped: Vec::new(),
|
||||
file: format!("{dir}/{name}.lrtemplate"),
|
||||
dir: dir.into(),
|
||||
dir_group: None,
|
||||
has_group: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dividers_become_groups() {
|
||||
let e = |n: &str| MaskEffect { name: n.into(), ..Default::default() };
|
||||
assert_eq!(divider_title(&e("10 ----- DETAIL ENHANCERS ----- 10")).as_deref(), Some("Detail Enhancers"));
|
||||
assert_eq!(divider_title(&e("50--------MISC-----50")).as_deref(), Some("Misc"));
|
||||
assert_eq!(divider_title(&e("=== Portraits ===")).as_deref(), Some("Portraits"));
|
||||
assert_eq!(divider_title(&e("11_General_-_All_Purpose")), None, "no run of marks");
|
||||
assert_eq!(divider_title(&e("-----")), None, "no title");
|
||||
let busy = MaskEffect { adjust: LocalAdjustments { exposure: 1.0, ..Default::default() }, ..e("20 --- X --- 20") };
|
||||
assert_eq!(divider_title(&busy), None, "a divider sets nothing");
|
||||
|
||||
let list = vec![
|
||||
found("01 Burn", -1.0, "a"),
|
||||
found("10 ----- DETAIL ENHANCERS ----- 10", 0.0, "a"),
|
||||
found("12 Sky", 0.5, "a"),
|
||||
found("20 ----- RETOUCHING ----- 20", 0.0, "a"),
|
||||
found("24a-Eye-Cleaner", 0.1, "a"),
|
||||
found("99 Late", 0.1, "a"),
|
||||
found("Burn (Darken)", -0.5, "a"),
|
||||
Found { dir_group: Some("Other".into()), ..found("15 Elsewhere", 0.2, "b") },
|
||||
];
|
||||
let (effects, dividers) = assign_groups(list.clone(), None);
|
||||
let groups: Vec<(&str, &str)> = effects.iter().map(|f| (f.effect.name.as_str(), f.effect.group.as_str())).collect();
|
||||
assert_eq!(
|
||||
groups,
|
||||
vec![
|
||||
("01 Burn", ""),
|
||||
("12 Sky", "Detail Enhancers"),
|
||||
("24a-Eye-Cleaner", "Retouching"),
|
||||
("99 Late", "Retouching"),
|
||||
("Burn (Darken)", ""),
|
||||
("15 Elsewhere", "Other"),
|
||||
]
|
||||
);
|
||||
assert_eq!(dividers.len(), 2);
|
||||
let (effects, _) = assign_groups(list, Some("Mine"));
|
||||
assert!(effects.iter().all(|f| f.effect.group == "Mine"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_names_that_only_say_local_presets_give_no_group() {
|
||||
assert_eq!(effect_group_from_dir("Local Adjustment Presets"), None);
|
||||
assert_eq!(effect_group_from_dir("LocalCorrections"), None);
|
||||
assert_eq!(effect_group_from_dir("Local Adjustment Presets/Portrait Brushes"), Some("Portrait Brushes".into()));
|
||||
assert_eq!(effect_group_from_dir("Skin Kit"), Some("Skin Kit".into()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,756 @@
|
||||
//! Mask effect presets: named sets of local adjustment sliders picked from the Masking panel's
|
||||
//! Effect menu (Lightroom's local adjustment / brush presets), saved with the library preferences.
|
||||
//! Grouped (folders in the menu), favourites, partial presets (only some sliders, with or without
|
||||
//! Amount) and optional brush settings. Files: [`super::mask_effect_files`].
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::mask_effect_files as files;
|
||||
use super::{CommandSpec, always, bad, cmd, has_active, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The local sliders an effect can set (`LocalAdjustments` field names; Amount aside).
|
||||
pub const FIELDS: &[&str] = &[
|
||||
"temp",
|
||||
"tint",
|
||||
"exposure",
|
||||
"contrast",
|
||||
"highlights",
|
||||
"shadows",
|
||||
"whites",
|
||||
"blacks",
|
||||
"texture",
|
||||
"clarity",
|
||||
"dehaze",
|
||||
"hue",
|
||||
"saturation",
|
||||
"sharpness",
|
||||
"noise",
|
||||
"moire",
|
||||
"defringe",
|
||||
"color_hue",
|
||||
"color_sat",
|
||||
];
|
||||
|
||||
/// Brush settings an effect can carry (Lightroom keeps these per brush, not in presets).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct BrushSettings {
|
||||
/// Radius as a fraction of the image's long edge.
|
||||
pub size: f64,
|
||||
/// 0..100.
|
||||
pub feather: f64,
|
||||
pub flow: f64,
|
||||
pub density: f64,
|
||||
pub auto_mask: bool,
|
||||
}
|
||||
|
||||
impl Default for BrushSettings {
|
||||
fn default() -> Self {
|
||||
Self { size: 0.04, feather: 50.0, flow: 100.0, density: 100.0, auto_mask: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl BrushSettings {
|
||||
fn clamped(self) -> Self {
|
||||
let c = |v: f64, lo: f64, hi: f64, d: f64| if v.is_finite() { v.clamp(lo, hi) } else { d };
|
||||
let d = Self::default();
|
||||
Self {
|
||||
size: c(self.size, 0.002, 0.5, d.size),
|
||||
feather: c(self.feather, 0.0, 100.0, d.feather),
|
||||
flow: c(self.flow, 1.0, 100.0, d.flow),
|
||||
density: c(self.density, 1.0, 100.0, d.density),
|
||||
auto_mask: self.auto_mask,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named set of local adjustment sliders. Applying one sets the sliders it names (all of them
|
||||
/// when `fields` is empty, as Lightroom's presets do: those it doesn't change go to 0); the mask's
|
||||
/// Amount stays unless `with_amount`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct MaskEffect {
|
||||
pub name: String,
|
||||
pub adjust: LocalAdjustments,
|
||||
/// The Effect menu folder ("" = none).
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub group: String,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub favorite: bool,
|
||||
/// The sliders this effect sets ([`FIELDS`] names); empty = all of them.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub fields: Vec<String>,
|
||||
/// Applying sets the mask's Amount to `adjust.amount` too.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub with_amount: bool,
|
||||
/// Brush settings the brush takes when the effect is applied.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub brush: Option<BrushSettings>,
|
||||
/// Settings of an imported preset that we have no slider for (Lightroom key → value), kept so
|
||||
/// an export carries them.
|
||||
#[serde(skip_serializing_if = "Map::is_empty")]
|
||||
pub extra: Map<String, Value>,
|
||||
}
|
||||
|
||||
impl Default for MaskEffect {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
name: String::new(),
|
||||
adjust: LocalAdjustments::default(),
|
||||
group: String::new(),
|
||||
favorite: false,
|
||||
fields: Vec::new(),
|
||||
with_amount: false,
|
||||
brush: None,
|
||||
extra: Map::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Field `k` of `a` (0 for an unknown name).
|
||||
pub fn get(a: &LocalAdjustments, k: &str) -> f64 {
|
||||
serde_json::to_value(a).ok().and_then(|v| v.get(k).and_then(Value::as_f64)).unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// `a` with field `k` set to `v` (unchanged for an unknown name).
|
||||
pub fn set(a: &LocalAdjustments, k: &str, v: f64) -> LocalAdjustments {
|
||||
let Ok(Value::Object(mut o)) = serde_json::to_value(a) else { return *a };
|
||||
if !o.contains_key(k) || !v.is_finite() {
|
||||
return *a;
|
||||
}
|
||||
o.insert(k.into(), json!(v));
|
||||
serde_json::from_value(Value::Object(o)).unwrap_or(*a)
|
||||
}
|
||||
|
||||
/// The sliders of `a` away from 0 (the colour's hue only with its saturation).
|
||||
pub fn changed_fields(a: &LocalAdjustments) -> Vec<String> {
|
||||
let mut out: Vec<String> = FIELDS.iter().filter(|k| get(a, k).abs() > 1e-9 && **k != "color_hue").map(|k| k.to_string()).collect();
|
||||
if a.color_sat.abs() > 1e-9 {
|
||||
out.push("color_hue".into());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl MaskEffect {
|
||||
/// The sliders this effect sets.
|
||||
pub fn field_list(&self) -> Vec<&str> {
|
||||
if self.fields.is_empty() { FIELDS.to_vec() } else { FIELDS.iter().copied().filter(|k| self.fields.iter().any(|f| f == k)).collect() }
|
||||
}
|
||||
/// `a` with this preset's sliders (and Amount, if it carries it).
|
||||
pub fn apply(&self, a: &LocalAdjustments) -> LocalAdjustments {
|
||||
let mut out = if self.fields.is_empty() {
|
||||
LocalAdjustments { amount: a.amount, ..self.adjust }
|
||||
} else {
|
||||
self.field_list().iter().fold(*a, |acc, k| set(&acc, k, get(&self.adjust, k)))
|
||||
};
|
||||
if self.with_amount {
|
||||
out.amount = self.adjust.amount;
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Does `a` carry exactly this preset's sliders? A tint's hue doesn't count without its
|
||||
/// saturation (it does nothing then).
|
||||
pub fn matches(&self, a: &LocalAdjustments) -> bool {
|
||||
let fields = self.field_list();
|
||||
let colorless = |x: &LocalAdjustments| x.color_sat.abs() < 1e-9;
|
||||
let same = |k: &str| (get(&self.adjust, k) - get(a, k)).abs() < 1e-6;
|
||||
fields.iter().all(|k| *k == "color_hue" && colorless(&self.adjust) && colorless(a) || same(k))
|
||||
&& (!self.with_amount || (self.adjust.amount - a.amount).abs() < 1e-6)
|
||||
}
|
||||
/// Does the effect set nothing (all its sliders at 0, nothing kept from an import)?
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.extra.is_empty() && changed_fields(&self.adjust).iter().all(|k| !self.field_list().contains(&k.as_str()))
|
||||
}
|
||||
/// The same effect with its values in range and its field list normalised.
|
||||
pub fn normalized(mut self) -> Self {
|
||||
self.name = self.name.trim().to_string();
|
||||
self.group = self.group.trim().to_string();
|
||||
let amount = if self.with_amount { self.adjust.amount } else { 100.0 };
|
||||
self.adjust = super::masks::clamp_local(LocalAdjustments { amount, ..self.adjust });
|
||||
self.fields.retain(|f| FIELDS.contains(&f.as_str()));
|
||||
if self.fields.iter().any(|f| f == "color_sat") && !self.fields.iter().any(|f| f == "color_hue") {
|
||||
self.fields.push("color_hue".into());
|
||||
}
|
||||
self.fields.sort_by_key(|f| FIELDS.iter().position(|k| k == f));
|
||||
self.fields.dedup();
|
||||
if self.fields.len() == FIELDS.len() {
|
||||
self.fields.clear();
|
||||
}
|
||||
self.brush = self.brush.map(BrushSettings::clamped);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare names the way people number their presets: digit runs as numbers ("2 …" before
|
||||
/// "10 …"), letters ignoring case.
|
||||
pub fn natural_cmp(a: &str, b: &str) -> Ordering {
|
||||
fn chunks(s: &str) -> Vec<(bool, String)> {
|
||||
let mut out: Vec<(bool, String)> = Vec::new();
|
||||
for c in s.chars() {
|
||||
let d = c.is_ascii_digit();
|
||||
match out.last_mut() {
|
||||
Some((kind, text)) if *kind == d => text.push(c),
|
||||
_ => out.push((d, c.to_string())),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
let (x, y) = (chunks(a), chunks(b));
|
||||
for (p, q) in x.iter().zip(y.iter()) {
|
||||
let o = match (p, q) {
|
||||
((true, m), (true, n)) => {
|
||||
let (m, n) = (m.trim_start_matches('0'), n.trim_start_matches('0'));
|
||||
m.len().cmp(&n.len()).then_with(|| m.cmp(n))
|
||||
}
|
||||
((_, m), (_, n)) => m.to_lowercase().cmp(&n.to_lowercase()),
|
||||
};
|
||||
if o != Ordering::Equal {
|
||||
return o;
|
||||
}
|
||||
}
|
||||
x.len().cmp(&y.len()).then_with(|| a.cmp(b))
|
||||
}
|
||||
|
||||
/// The Effect menu's order: groups in the order they first appear (ungrouped first), each with
|
||||
/// its effects (indices into `effects`) sorted by name.
|
||||
pub fn menu_order(effects: &[MaskEffect]) -> Vec<(String, Vec<usize>)> {
|
||||
let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
|
||||
if effects.iter().any(|e| e.group.is_empty()) {
|
||||
groups.push((String::new(), Vec::new()));
|
||||
}
|
||||
for (i, e) in effects.iter().enumerate() {
|
||||
match groups.iter_mut().find(|(g, _)| g.eq_ignore_ascii_case(&e.group)) {
|
||||
Some((_, v)) => v.push(i),
|
||||
None => groups.push((e.group.clone(), vec![i])),
|
||||
}
|
||||
}
|
||||
for (_, v) in &mut groups {
|
||||
v.sort_by(|a, b| match (effects.get(*a), effects.get(*b)) {
|
||||
(Some(x), Some(y)) => natural_cmp(&x.name, &y.name),
|
||||
_ => Ordering::Equal,
|
||||
});
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
/// Name of the effect the mask adjustments `a` match, if any (full presets before partial ones).
|
||||
pub fn matching_effect(s: &Session, a: &LocalAdjustments) -> Option<String> {
|
||||
let full = s.mask_effects.iter().filter(|e| e.fields.is_empty()).find(|e| e.matches(a));
|
||||
full.or_else(|| s.mask_effects.iter().find(|e| e.matches(a))).map(|e| e.name.clone())
|
||||
}
|
||||
|
||||
fn position(s: &Session, name: &str) -> Option<usize> {
|
||||
s.mask_effects.iter().position(|x| x.name.eq_ignore_ascii_case(name.trim()))
|
||||
}
|
||||
|
||||
/// Add or replace (same name, ignoring case) an effect; a replaced one keeps its star.
|
||||
pub(crate) fn upsert(s: &mut Session, e: MaskEffect) {
|
||||
match position(s, &e.name).and_then(|i| s.mask_effects.get_mut(i)) {
|
||||
Some(x) => {
|
||||
let favorite = x.favorite || e.favorite;
|
||||
*x = MaskEffect { favorite, ..e };
|
||||
}
|
||||
None => s.mask_effects.push(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn name_param(p: &Value, c: &str) -> Result<String> {
|
||||
name_of(p, "name", c)
|
||||
}
|
||||
|
||||
fn name_of(p: &Value, key: &str, c: &str) -> Result<String> {
|
||||
str_param(p, key).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, format!("missing `{key}`")))
|
||||
}
|
||||
|
||||
fn strings(p: &Value, key: &str) -> Option<Vec<String>> {
|
||||
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
|
||||
}
|
||||
|
||||
/// The names a command acts on: `names: [...]` or `name`.
|
||||
fn names_param(p: &Value, c: &str) -> Result<Vec<String>> {
|
||||
match strings(p, "names").filter(|v| !v.is_empty()) {
|
||||
Some(v) => Ok(v),
|
||||
None => name_param(p, c).map(|n| vec![n]),
|
||||
}
|
||||
}
|
||||
|
||||
fn effect_index(s: &Session, name: &str, c: &str) -> Result<usize> {
|
||||
position(s, name).ok_or_else(|| bad(c, format!("no effect `{name}`")))
|
||||
}
|
||||
|
||||
/// The selected mask's adjustments on the active photo.
|
||||
fn selected_mask(s: &Session, p: &Value, c: &str) -> Result<Mask> {
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let mid = p
|
||||
.get("id")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.or(s.active_mask)
|
||||
.ok_or_else(|| bad(c, "no mask selected (give `id`)"))?;
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
d.masks.iter().find(|m| m.id == mid).cloned().ok_or_else(|| bad(c, format!("no mask {mid}")))
|
||||
}
|
||||
|
||||
/// The kind of a mask's first component (`brush`, `linear`, `radial`, `subject`…): the tool the
|
||||
/// effect is remembered for.
|
||||
pub fn mask_tool(m: &Mask) -> Option<String> {
|
||||
m.components.first().and_then(|c| serde_json::to_value(&c.shape).ok()).and_then(|v| v.get("kind").and_then(Value::as_str).map(str::to_string))
|
||||
}
|
||||
|
||||
/// The adjustments a new mask made with `tool` starts with: the effect last applied to a mask of
|
||||
/// that kind (Lightroom carries the chosen effect over to the next mask), else all at 0.
|
||||
pub fn new_mask_adjust(s: &Session, tool: &str) -> LocalAdjustments {
|
||||
s.mask_tool_effects
|
||||
.get(tool)
|
||||
.and_then(|name| position(s, name))
|
||||
.and_then(|i| s.mask_effects.get(i))
|
||||
.map(|e| super::masks::clamp_local(e.apply(&LocalAdjustments::default())))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn list(s: &Session) -> Value {
|
||||
let current = s
|
||||
.active()
|
||||
.and_then(|id| s.develop_of(id))
|
||||
.and_then(|d| s.active_mask.and_then(|mid| d.masks.iter().find(|m| m.id == mid).map(|m| m.adjust)))
|
||||
.and_then(|a| matching_effect(s, &a));
|
||||
let groups: Vec<Value> = menu_order(&s.mask_effects)
|
||||
.into_iter()
|
||||
.map(|(g, ix)| json!({"name": g, "effects": ix.iter().filter_map(|i| s.mask_effects.get(*i)).map(|e| e.name.clone()).collect::<Vec<_>>()}))
|
||||
.collect();
|
||||
json!({"effects": s.mask_effects, "groups": groups, "current": current, "toolEffects": s.mask_tool_effects})
|
||||
}
|
||||
|
||||
/// Masks on `d` named like one of `names` (ignoring case).
|
||||
fn masks_named(d: &DevelopSettings, names: &[String]) -> Vec<u32> {
|
||||
d.masks.iter().filter(|m| names.iter().any(|n| n.eq_ignore_ascii_case(&m.name))).map(|m| m.id).collect()
|
||||
}
|
||||
|
||||
fn apply_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.applyEffect";
|
||||
let name = name_param(p, C)?;
|
||||
let effect = s.mask_effects.get(effect_index(s, &name, C)?).cloned().ok_or_else(|| bad(C, format!("no effect `{name}`")))?;
|
||||
let id = s.active().ok_or_else(|| bad(C, "no active photo"))?;
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
// the masks: `ids`, `all`, else `id` or the selected one
|
||||
let ids: Vec<u32> = if p.get("all").and_then(Value::as_bool).unwrap_or(false) {
|
||||
d.masks.iter().map(|m| m.id).collect()
|
||||
} else if let Some(a) = p.get("ids").and_then(Value::as_array) {
|
||||
a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).collect()
|
||||
} else {
|
||||
vec![selected_mask(s, p, C)?.id]
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no masks to apply the effect to"));
|
||||
}
|
||||
if let Some(missing) = ids.iter().find(|m| !d.masks.iter().any(|x| x.id == **m)) {
|
||||
return Err(bad(C, format!("no mask {missing}")));
|
||||
}
|
||||
let set_masks = |d: &DevelopSettings, ids: &[u32]| {
|
||||
let mut d = d.clone();
|
||||
for m in d.masks.iter_mut().filter(|m| ids.contains(&m.id)) {
|
||||
m.adjust = super::masks::clamp_local(effect.apply(&m.adjust));
|
||||
}
|
||||
d
|
||||
};
|
||||
let label = format!("Mask Effect: {}", effect.name);
|
||||
// with `photos: "selected"`, the other selected photos' masks of the same names too
|
||||
let others: Vec<(PhotoId, DevelopSettings)> = if str_param(p, "photos") == Some("selected") {
|
||||
let names: Vec<String> = d.masks.iter().filter(|m| ids.contains(&m.id)).map(|m| m.name.clone()).collect();
|
||||
s.selection
|
||||
.ids
|
||||
.iter()
|
||||
.filter(|x| **x != id)
|
||||
.filter_map(|x| s.develop_of(*x).map(|o| (*x, o)))
|
||||
.filter_map(|(x, o)| {
|
||||
let found = masks_named(&o, &names);
|
||||
(!found.is_empty()).then(|| (x, set_masks(&o, &found)))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mine = set_masks(&d, &ids);
|
||||
let photos = 1 + others.len();
|
||||
if others.is_empty() {
|
||||
s.set_develop(id, mine, &label)?;
|
||||
} else {
|
||||
// one undo step for every photo
|
||||
let mut ops: Vec<_> = s.develop_op(id, mine, &label).into_iter().collect();
|
||||
ops.extend(others.into_iter().filter_map(|(x, o)| s.develop_op(x, o, &label)));
|
||||
s.commit(&label, lightcraft_catalog::Op::Batch { ops })?;
|
||||
}
|
||||
// the next mask made with this tool starts with the effect
|
||||
if p.get("remember").and_then(Value::as_bool).unwrap_or(true) {
|
||||
for m in d.masks.iter().filter(|m| ids.contains(&m.id)) {
|
||||
if let Some(tool) = mask_tool(m) {
|
||||
s.mask_tool_effects.insert(tool, effect.name.clone());
|
||||
}
|
||||
}
|
||||
s.save_prefs()?;
|
||||
}
|
||||
Ok(json!({"name": effect.name, "masks": ids, "photos": photos, "brush": effect.brush}))
|
||||
}
|
||||
|
||||
/// The fields a save keeps: `fields: [..]` | `"changed"` | `"all"` (default).
|
||||
fn fields_param(p: &Value, a: &LocalAdjustments, c: &str) -> Result<Vec<String>> {
|
||||
match p.get("fields") {
|
||||
None | Some(Value::Null) => Ok(Vec::new()),
|
||||
Some(Value::String(x)) if x == "all" => Ok(Vec::new()),
|
||||
Some(Value::String(x)) if x == "changed" => {
|
||||
let f = changed_fields(a);
|
||||
if f.is_empty() { Err(bad(c, "the mask changes no slider; nothing to save")) } else { Ok(f) }
|
||||
}
|
||||
Some(Value::Array(v)) => {
|
||||
let f: Vec<String> = v.iter().filter_map(Value::as_str).filter(|k| FIELDS.contains(k)).map(str::to_string).collect();
|
||||
if f.is_empty() { Err(bad(c, format!("`fields` names no slider (one of {})", FIELDS.join(", ")))) } else { Ok(f) }
|
||||
}
|
||||
Some(_) => Err(bad(c, "`fields` is \"all\", \"changed\" or a list of slider names")),
|
||||
}
|
||||
}
|
||||
|
||||
fn brush_param(p: &Value, c: &str) -> Result<Option<BrushSettings>> {
|
||||
match p.get("brush") {
|
||||
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None),
|
||||
Some(v) => serde_json::from_value::<BrushSettings>(v.clone()).map(|b| Some(b.clamped())).map_err(|e| bad(c, format!("`brush`: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.saveEffect";
|
||||
let name = name_param(p, C)?;
|
||||
let adjust = selected_mask(s, p, C)?.adjust;
|
||||
let group = str_param(p, "group").map(|g| g.trim().to_string()).unwrap_or_default();
|
||||
let e = MaskEffect {
|
||||
name,
|
||||
adjust,
|
||||
group,
|
||||
fields: fields_param(p, &adjust, C)?,
|
||||
with_amount: p.get("amount").and_then(Value::as_bool).unwrap_or(false),
|
||||
brush: brush_param(p, C)?,
|
||||
..Default::default()
|
||||
};
|
||||
upsert(s, e.normalized());
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
/// Overwrite an effect's sliders with the selected mask's (keeping which sliders and whether
|
||||
/// Amount it carries, its group and star).
|
||||
fn update_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.updateEffect";
|
||||
let name = name_param(p, C)?;
|
||||
let i = effect_index(s, &name, C)?;
|
||||
let adjust = selected_mask(s, p, C)?.adjust;
|
||||
let brush = brush_param(p, C)?;
|
||||
let e = s.mask_effects.get_mut(i).ok_or_else(|| bad(C, format!("no effect `{name}`")))?;
|
||||
let mut next = MaskEffect { adjust, extra: Map::new(), ..e.clone() };
|
||||
if let Some(f) = p.get("fields").filter(|v| !v.is_null()) {
|
||||
next.fields = fields_param(&json!({"fields": f}), &adjust, C)?;
|
||||
}
|
||||
if let Some(a) = p.get("amount").and_then(Value::as_bool) {
|
||||
next.with_amount = a;
|
||||
}
|
||||
if brush.is_some() {
|
||||
next.brush = brush;
|
||||
}
|
||||
*e = next.normalized();
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
fn rename_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.renameEffect";
|
||||
let name = name_param(p, C)?;
|
||||
let to = name_of(p, "to", C)?;
|
||||
let i = effect_index(s, &name, C)?;
|
||||
if position(s, &to).is_some_and(|j| j != i) {
|
||||
return Err(bad(C, format!("an effect named `{to}` exists already")));
|
||||
}
|
||||
if let Some(e) = s.mask_effects.get_mut(i) {
|
||||
e.name = to.clone();
|
||||
}
|
||||
for v in s.mask_tool_effects.values_mut() {
|
||||
if v.eq_ignore_ascii_case(&name) {
|
||||
*v = to.clone();
|
||||
}
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
/// A name like `base` that no effect has: `base`, `base 2`, `base 3`…
|
||||
fn free_name(s: &Session, base: &str) -> String {
|
||||
(1..10_000)
|
||||
.map(|n| if n == 1 { base.to_string() } else { format!("{base} {n}") })
|
||||
.find(|n| position(s, n).is_none())
|
||||
.unwrap_or_else(|| base.into())
|
||||
}
|
||||
|
||||
fn duplicate_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.duplicateEffect";
|
||||
let name = name_param(p, C)?;
|
||||
let e = s.mask_effects.get(effect_index(s, &name, C)?).cloned().ok_or_else(|| bad(C, format!("no effect `{name}`")))?;
|
||||
let to = match str_param(p, "to").map(str::trim).filter(|t| !t.is_empty()) {
|
||||
Some(t) if position(s, t).is_some() => return Err(bad(C, format!("an effect named `{t}` exists already"))),
|
||||
Some(t) => t.to_string(),
|
||||
None => free_name(s, &format!("{} copy", e.name)),
|
||||
};
|
||||
s.mask_effects.push(MaskEffect { name: to.clone(), favorite: false, ..e });
|
||||
s.save_prefs()?;
|
||||
let mut out = list(s);
|
||||
out["name"] = json!(to);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn delete_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.deleteEffect";
|
||||
let names = names_param(p, C)?;
|
||||
if let Some(missing) = names.iter().find(|n| position(s, n).is_none()) {
|
||||
return Err(bad(C, format!("no effect `{missing}`")));
|
||||
}
|
||||
s.mask_effects.retain(|x| !names.iter().any(|n| n.eq_ignore_ascii_case(&x.name)));
|
||||
s.mask_tool_effects.retain(|_, v| !names.iter().any(|n| n.eq_ignore_ascii_case(v)));
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
fn favorite_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.favoriteEffect";
|
||||
let names = names_param(p, C)?;
|
||||
let mut idx = Vec::new();
|
||||
for n in &names {
|
||||
idx.push(effect_index(s, n, C)?);
|
||||
}
|
||||
let want = p.get("favorite").and_then(Value::as_bool);
|
||||
for i in idx {
|
||||
if let Some(e) = s.mask_effects.get_mut(i) {
|
||||
e.favorite = want.unwrap_or(!e.favorite);
|
||||
}
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
fn set_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.setEffectGroup";
|
||||
let names = names_param(p, C)?;
|
||||
let group = str_param(p, "group").map(|g| g.trim().to_string()).ok_or_else(|| bad(C, "missing `group` (\"\" = none)"))?;
|
||||
let mut idx = Vec::new();
|
||||
for n in &names {
|
||||
idx.push(effect_index(s, n, C)?);
|
||||
}
|
||||
// an existing group's spelling wins
|
||||
let group = s.mask_effects.iter().map(|e| &e.group).find(|g| g.eq_ignore_ascii_case(&group)).cloned().unwrap_or(group);
|
||||
for i in idx {
|
||||
if let Some(e) = s.mask_effects.get_mut(i) {
|
||||
e.group = group.clone();
|
||||
}
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
fn rename_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.renameEffectGroup";
|
||||
let group = name_of(p, "group", C)?;
|
||||
let to = str_param(p, "to").map(|g| g.trim().to_string()).ok_or_else(|| bad(C, "missing `to` (\"\" = ungroup)"))?;
|
||||
let mut n = 0;
|
||||
for e in s.mask_effects.iter_mut().filter(|e| e.group.eq_ignore_ascii_case(&group)) {
|
||||
e.group = to.clone();
|
||||
n += 1;
|
||||
}
|
||||
if n == 0 {
|
||||
return Err(bad(C, format!("no group `{group}`")));
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(list(s))
|
||||
}
|
||||
|
||||
fn tool_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "mask.toolEffect";
|
||||
let tool = name_of(p, "tool", C)?;
|
||||
match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
Some(n) => {
|
||||
let i = effect_index(s, n, C)?;
|
||||
let name = s.mask_effects.get(i).map(|e| e.name.clone()).unwrap_or_default();
|
||||
s.mask_tool_effects.insert(tool, name);
|
||||
}
|
||||
None => {
|
||||
s.mask_tool_effects.remove(&tool);
|
||||
}
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(json!({"toolEffects": s.mask_tool_effects}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "mask.effects",
|
||||
"Mask Effect Presets",
|
||||
[],
|
||||
None,
|
||||
"{} → {effects: [{name, adjust, group?, favorite?, fields?, with_amount?, brush?, extra?}], groups: [{name, effects: [name]}] (menu order), current: the effect the selected mask matches or null, toolEffects: {tool: effect a new mask of that kind starts with}}",
|
||||
always,
|
||||
|s, _| Ok(list(s))
|
||||
),
|
||||
cmd!(
|
||||
"mask.applyEffect",
|
||||
"Apply Mask Effect",
|
||||
[],
|
||||
None,
|
||||
"{name, id? | ids?: [maskId] | all?: bool, photos?: \"selected\" (also the masks of the same names on the other selected photos, one undo step), remember?: bool (true: new masks of this kind start with it)} — sets the effect's sliders (all, or the ones it keeps; Amount only if it carries it) → {name, masks, photos, brush}",
|
||||
has_active,
|
||||
apply_effect
|
||||
),
|
||||
cmd!(
|
||||
"mask.saveEffect",
|
||||
"Save Mask Effect",
|
||||
[],
|
||||
None,
|
||||
"{name, id?, group?, fields?: \"all\" (default) | \"changed\" | [slider], amount?: bool (carry Amount), brush?: {size, feather, flow, density, auto_mask}} — the selected mask's sliders as an effect preset (replaces one of that name), saved with the library",
|
||||
has_active,
|
||||
save_effect
|
||||
),
|
||||
cmd!(
|
||||
"mask.updateEffect",
|
||||
"Update Mask Effect",
|
||||
[],
|
||||
None,
|
||||
"{name, id?, fields?, amount?, brush?} — overwrite the effect with the selected mask's sliders (keeps its group, star and which sliders it carries unless given)",
|
||||
has_active,
|
||||
update_effect
|
||||
),
|
||||
cmd!("mask.renameEffect", "Rename Mask Effect", [], None, "{name, to}", always, rename_effect),
|
||||
cmd!(
|
||||
"mask.duplicateEffect",
|
||||
"Duplicate Mask Effect",
|
||||
[],
|
||||
None,
|
||||
"{name, to?} (default \"<name> copy\") → list + {name}",
|
||||
always,
|
||||
duplicate_effect
|
||||
),
|
||||
cmd!("mask.deleteEffect", "Delete Mask Effect", [], None, "{name | names: [name]} — removes effect presets", always, delete_effect),
|
||||
cmd!("mask.favoriteEffect", "Favorite Mask Effect", [], None, "{name | names, favorite?: bool (default: toggle)}", always, favorite_effect),
|
||||
cmd!("mask.setEffectGroup", "Move Mask Effect to Group", [], None, "{name | names, group: name (\"\" = none)}", always, set_group),
|
||||
cmd!(
|
||||
"mask.renameEffectGroup",
|
||||
"Rename Mask Effect Group",
|
||||
[],
|
||||
None,
|
||||
"{group, to} (\"\" ungroups; an existing name merges)",
|
||||
always,
|
||||
rename_group
|
||||
),
|
||||
cmd!(
|
||||
"mask.toolEffect",
|
||||
"Set Effect for New Masks",
|
||||
[],
|
||||
None,
|
||||
"{tool: brush|linear|radial|subject|…, name?} — the effect new masks of that kind start with (no name: none)",
|
||||
always,
|
||||
tool_effect
|
||||
),
|
||||
cmd!(
|
||||
"mask.importEffects",
|
||||
"Import Mask Effects",
|
||||
[],
|
||||
None,
|
||||
"{path | paths: [file or folder], group?, dryRun?} — Lightroom Classic local adjustment presets (.lrtemplate), Camera Raw local correction presets (.xmp), .lceffect files and .zip bundles of these; folders and Lightroom's divider presets (\"10 ----- DETAIL ----- 10\") become groups; same-named effects are replaced → {imported: [{name, group, file, unmapped}], dividers: [{name, group, file}], failed: [[file, error]]}",
|
||||
always,
|
||||
files::import_effects
|
||||
),
|
||||
cmd!(
|
||||
"mask.exportEffects",
|
||||
"Export Mask Effects",
|
||||
[],
|
||||
None,
|
||||
"{path, names?: [name], group?: name (default: all)} — writes an .lceffect JSON file (groups, stars and kept Lightroom settings included) → {path, count}",
|
||||
always,
|
||||
files::export_effects
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn apply_keeps_amount_and_matches_ignore_it() {
|
||||
let e = MaskEffect { name: "E".into(), adjust: LocalAdjustments { exposure: 1.0, ..Default::default() }, ..Default::default() };
|
||||
let current = LocalAdjustments { shadows: 40.0, amount: 60.0, ..Default::default() };
|
||||
let out = e.apply(¤t);
|
||||
assert_eq!((out.exposure, out.shadows, out.amount), (1.0, 0.0, 60.0));
|
||||
assert!(e.matches(&out));
|
||||
assert!(!e.matches(¤t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_effects_leave_other_sliders_and_can_carry_amount() {
|
||||
let e = MaskEffect {
|
||||
name: "Lift".into(),
|
||||
adjust: LocalAdjustments { shadows: 30.0, clarity: 99.0, amount: 70.0, ..Default::default() },
|
||||
fields: vec!["shadows".into()],
|
||||
with_amount: true,
|
||||
..Default::default()
|
||||
};
|
||||
let current = LocalAdjustments { exposure: 0.5, clarity: 10.0, ..Default::default() };
|
||||
let out = e.apply(¤t);
|
||||
assert_eq!((out.exposure, out.shadows, out.clarity, out.amount), (0.5, 30.0, 10.0, 70.0));
|
||||
assert!(e.matches(&out));
|
||||
assert!(!e.matches(&LocalAdjustments { amount: 100.0, ..out }), "Amount counts when carried");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hue_without_saturation_still_matches() {
|
||||
let e = MaskEffect {
|
||||
name: "E".into(),
|
||||
adjust: LocalAdjustments { color_hue: 240.0, exposure: 0.25, ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
assert!(e.matches(&LocalAdjustments { exposure: 0.25, ..Default::default() }));
|
||||
assert!(!e.matches(&LocalAdjustments { exposure: 0.25, color_sat: 10.0, ..Default::default() }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn natural_order_and_groups() {
|
||||
let mut v = vec!["10 b", "2 a", "Burn", "01 x", "1 y"];
|
||||
v.sort_by(|a, b| natural_cmp(a, b));
|
||||
assert_eq!(v, vec!["01 x", "1 y", "2 a", "10 b", "Burn"]);
|
||||
let e = |n: &str, g: &str| MaskEffect { name: n.into(), group: g.into(), ..Default::default() };
|
||||
let list = vec![e("12 b", "Detail"), e("3 a", ""), e("11 a", "detail"), e("20 r", "Retouch"), e("Burn", "")];
|
||||
let order = menu_order(&list);
|
||||
let names: Vec<(String, Vec<&str>)> = order.iter().map(|(g, ix)| (g.clone(), ix.iter().map(|i| list[*i].name.as_str()).collect())).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![("".to_string(), vec!["3 a", "Burn"]), ("Detail".to_string(), vec!["11 a", "12 b"]), ("Retouch".to_string(), vec!["20 r"]),]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_clamps_and_tidies_fields() {
|
||||
let e = MaskEffect {
|
||||
name: " X ".into(),
|
||||
adjust: LocalAdjustments { exposure: 99.0, amount: 150.0, ..Default::default() },
|
||||
fields: vec!["color_sat".into(), "bogus".into(), "exposure".into()],
|
||||
brush: Some(BrushSettings { size: f64::NAN, flow: 0.0, ..Default::default() }),
|
||||
..Default::default()
|
||||
}
|
||||
.normalized();
|
||||
assert_eq!(e.name, "X");
|
||||
assert_eq!(e.adjust.exposure, 4.0);
|
||||
assert_eq!(e.adjust.amount, 100.0, "Amount isn't carried");
|
||||
assert_eq!(e.fields, vec!["exposure", "color_hue", "color_sat"]);
|
||||
let b = e.brush.unwrap();
|
||||
assert_eq!((b.size, b.flow), (0.04, 1.0));
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,55 @@ fn masks_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Mas
|
||||
Ok(json!({"activeMask": active}))
|
||||
}
|
||||
|
||||
/// Most pieces a tapered ⇧-click segment is cut into.
|
||||
const MAX_TAPER_STEPS: usize = 64;
|
||||
|
||||
/// The strokes a ⇧-click adds: `to` drawn on from point `from`, the last point of stroke `last` (a straight segment
|
||||
/// to `to`'s first point, then the rest of `to`). When the brush's size, feather or flow changed since `last`, the
|
||||
/// segment is cut into pieces whose values step from `last`'s to `to`'s, so the line tapers; strokes combine by their
|
||||
/// maximum, so the pieces join without building up.
|
||||
fn connected_strokes(from: Point, last: &BrushStroke, to: BrushStroke) -> Vec<BrushStroke> {
|
||||
let Some(&first) = to.points.first() else { return vec![to] };
|
||||
let same = |a: f64, b: f64| (a - b).abs() <= 1e-9;
|
||||
// pen pressure along the line: the last dab's at its start, the click's at its end (none when neither has any)
|
||||
let pressured = !last.pressure.is_empty() || !to.pressure.is_empty();
|
||||
let (p_from, p_to) = (last.pressure_at(0), to.pressure_at(0));
|
||||
if same(last.size, to.size) && same(last.feather, to.feather) && same(last.flow, to.flow) {
|
||||
let mut points = Vec::with_capacity(to.points.len() + 1);
|
||||
points.push(from);
|
||||
points.extend(to.points.iter().copied());
|
||||
let pressure = if pressured { std::iter::once(p_from).chain((0..to.points.len()).map(|i| to.pressure_at(i))).collect() } else { Vec::new() };
|
||||
return vec![BrushStroke { points, pressure, ..to }];
|
||||
}
|
||||
// about 5% of size, or 2 points of feather or flow, per piece
|
||||
let ratio = if last.size > 0.0 && to.size > 0.0 { (to.size / last.size).ln().abs() / 1.05f64.ln() } else { 0.0 };
|
||||
let needed = [ratio, (to.feather - last.feather).abs() / 2.0, (to.flow - last.flow).abs() / 2.0]
|
||||
.into_iter()
|
||||
.filter(|v| v.is_finite())
|
||||
.fold(1.0f64, f64::max);
|
||||
let steps = (needed.ceil() as usize).clamp(1, MAX_TAPER_STEPS);
|
||||
let lerp = |a: f64, b: f64, t: f64| a + (b - a) * t;
|
||||
let at = |t: f64| Point::new(lerp(from.x, first.x, t), lerp(from.y, first.y, t));
|
||||
let mut out: Vec<BrushStroke> = (0..steps)
|
||||
.map(|i| {
|
||||
let (t0, t1) = (i as f64 / steps as f64, (i + 1) as f64 / steps as f64);
|
||||
let p = |t: f64| (f64::from(p_from) + (f64::from(p_to) - f64::from(p_from)) * t) as f32;
|
||||
BrushStroke {
|
||||
points: vec![at(t0), at(t1)],
|
||||
size: lerp(last.size, to.size, t1),
|
||||
feather: lerp(last.feather, to.feather, t1),
|
||||
flow: lerp(last.flow, to.flow, t1),
|
||||
pressure: if pressured { vec![p(t0), p(t1)] } else { Vec::new() },
|
||||
..to.clone()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if to.points.len() > 1 {
|
||||
out.push(to);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn mask_id(p: &Value, active: Option<u32>, c: &str) -> Result<u32> {
|
||||
p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)"))
|
||||
}
|
||||
@@ -178,11 +227,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
return Ok(json!({"pending": true}));
|
||||
}
|
||||
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
|
||||
// the effect last applied to a mask of this kind
|
||||
let adjust = super::mask_effects::new_mask_adjust(s, &kind);
|
||||
let out = masks_edit(s, "mask.add", "Add Mask", |masks, active| {
|
||||
masks.push(Mask {
|
||||
id: next,
|
||||
name: name.unwrap_or_else(|| format!("Mask {next}")),
|
||||
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }],
|
||||
adjust,
|
||||
..Default::default()
|
||||
});
|
||||
*active = Some(next);
|
||||
@@ -230,7 +282,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Brush Stroke",
|
||||
[],
|
||||
None,
|
||||
"{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool}",
|
||||
"{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool, pressure?: [p,…] pen pressure 0..1 per point (scales each dab's size and flow; omitted: full), connect?: bool — start from the last point of the mask's last stroke (⇧-click), size / feather / flow tapering from that stroke's to these} → {activeMask, connected}",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let pts: Vec<Point> = p
|
||||
@@ -240,6 +292,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.iter()
|
||||
.filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?)))
|
||||
.collect();
|
||||
let pts_len = pts.len();
|
||||
let d = BrushStroke::default();
|
||||
let stroke = BrushStroke {
|
||||
points: pts,
|
||||
@@ -249,14 +302,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
density: f64_or(p, "density", d.density),
|
||||
erase: bool_or(p, "erase", false),
|
||||
auto_mask: bool_or(p, "autoMask", false),
|
||||
pressure: pressure_list(p.get("pressure"), pts_len)?,
|
||||
};
|
||||
let connect = bool_or(p, "connect", false);
|
||||
let mut connected = false;
|
||||
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
|
||||
let want = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask);
|
||||
masks_edit(s, "mask.brushStroke", "Brush", |masks, active| {
|
||||
let adjust = super::mask_effects::new_mask_adjust(s, "brush");
|
||||
let mut out = masks_edit(s, "mask.brushStroke", "Brush", |masks, active| {
|
||||
let i = match want.and_then(|m| masks.iter().position(|x| x.id == m)) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
masks.push(Mask { id: next, name: format!("Mask {next}"), ..Default::default() });
|
||||
masks.push(Mask { id: next, name: format!("Mask {next}"), adjust, ..Default::default() });
|
||||
*active = Some(next);
|
||||
masks.len() - 1
|
||||
}
|
||||
@@ -265,7 +322,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) =
|
||||
m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. }))
|
||||
{
|
||||
strokes.push(stroke);
|
||||
// the previous stroke's end and its brush values (not its points)
|
||||
let anchor = strokes.last().filter(|_| connect).and_then(|l| {
|
||||
Some((
|
||||
*l.points.last()?,
|
||||
BrushStroke {
|
||||
points: Vec::new(),
|
||||
size: l.size,
|
||||
feather: l.feather,
|
||||
flow: l.flow,
|
||||
// the pressure the last dab was painted with, when the stroke recorded any
|
||||
pressure: if l.pressure.is_empty() { Vec::new() } else { vec![l.pressure_at(l.points.len().saturating_sub(1))] },
|
||||
..BrushStroke::default()
|
||||
},
|
||||
))
|
||||
});
|
||||
match anchor {
|
||||
Some((from, last)) => {
|
||||
strokes.extend(connected_strokes(from, &last, stroke));
|
||||
connected = true;
|
||||
}
|
||||
None => strokes.push(stroke),
|
||||
}
|
||||
} else {
|
||||
m.components.push(MaskComponent {
|
||||
name: None,
|
||||
@@ -275,7 +353,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
})?;
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.insert("connected".into(), json!(connected));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
),
|
||||
cmd!("mask.update", "Update Mask Shape", [], None, "{id?, component?: index (0), shape: MaskShape JSON}", has_active, |s, p| {
|
||||
@@ -921,7 +1003,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "redeye.catchlight";
|
||||
let i = super::f64_req(p, "index", c)? as usize;
|
||||
let i = super::index_req(p, "index", c)?;
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
let eye = dd.red_eye.get_mut(i).ok_or_else(|| bad(c, "no such eye"))?;
|
||||
@@ -936,7 +1018,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("redeye.delete", "Delete Red Eye Correction", [], None, "{index}", has_active, |s, p| {
|
||||
let c = "redeye.delete";
|
||||
let i = super::f64_req(p, "index", c)? as usize;
|
||||
let i = super::index_req(p, "index", c)?;
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let mut dd = (*s.develop_of(id).unwrap_or_default()).clone();
|
||||
if i >= dd.red_eye.len() {
|
||||
@@ -979,7 +1061,7 @@ fn pick_source(s: &mut Session, id: crate::PhotoId, d: &lightcraft_develop::Deve
|
||||
lightcraft_pipeline::spots::pick_source(&src, &info, d, spot, avoid)
|
||||
}
|
||||
|
||||
fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments {
|
||||
pub(crate) fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments {
|
||||
let c = |v: &mut f64, lo: f64, hi: f64| *v = if v.is_finite() { v.clamp(lo, hi) } else { 0.0 };
|
||||
c(&mut a.exposure, -4.0, 4.0);
|
||||
for v in [
|
||||
@@ -1007,3 +1089,17 @@ fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments {
|
||||
c(&mut a.amount, 0.0, 200.0);
|
||||
a
|
||||
}
|
||||
|
||||
/// `mask.brushStroke`'s `pressure`: one number 0..1 per point (clamped), at most one per point; absent or `null`
|
||||
/// is no pressure data.
|
||||
fn pressure_list(v: Option<&Value>, points: usize) -> Result<Vec<f32>> {
|
||||
let Some(v) = v.filter(|v| !v.is_null()) else { return Ok(Vec::new()) };
|
||||
let list = v.as_array().ok_or_else(|| bad("mask.brushStroke", "pressure must be a list of numbers 0..1"))?;
|
||||
if list.len() > points {
|
||||
return Err(bad("mask.brushStroke", "pressure has more values than points"));
|
||||
}
|
||||
list.iter()
|
||||
.map(|q| q.as_f64().filter(|f| f.is_finite()).map(|f| f.clamp(0.0, 1.0) as f32))
|
||||
.collect::<Option<Vec<f32>>>()
|
||||
.ok_or_else(|| bad("mask.brushStroke", "pressure must be a list of numbers 0..1"))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Photo Merge commands: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama`.
|
||||
//! Photo Merge commands: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama`, `merge.hdrBatch`.
|
||||
//!
|
||||
//! Run synchronously here (CLI, MCP, control channel); the desktop UI plans the same job with
|
||||
//! [`Session::plan_merge`] and runs it on a worker thread. `preview: true` merges ≤ 1024 px frames
|
||||
@@ -20,7 +20,7 @@ fn can_merge(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
let (kind, finish) = crate::merge::parse(id, p)?;
|
||||
let ids: Vec<PhotoId> = s.targets(p);
|
||||
let ids: Vec<PhotoId> = s.targets(p)?;
|
||||
if !bool_or(p, "preview", false) {
|
||||
return s.merge_now(kind, finish, &ids);
|
||||
}
|
||||
@@ -40,6 +40,26 @@ fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// `merge.hdrBatch`: an HDR merge of each bracket set among the photos ([`lightcraft_catalog::Catalog::bracket_sets`]),
|
||||
/// one after the other. A set that fails is reported and the others still run.
|
||||
fn run_batch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "merge.hdrBatch";
|
||||
let (kind, finish) = crate::merge::parse("merge.hdr", p)?;
|
||||
let ids: Vec<PhotoId> = s.targets(p)?;
|
||||
let sets = s.catalog.bracket_sets(&ids);
|
||||
if sets.is_empty() {
|
||||
return Err(bad(ID, crate::merge::NO_BRACKET_SETS));
|
||||
}
|
||||
let (mut merged, mut failed) = (Vec::new(), Vec::new());
|
||||
for set in sets {
|
||||
match s.merge_now(kind.clone(), finish.clone(), &set) {
|
||||
Ok(v) => merged.push(v),
|
||||
Err(e) => failed.push(json!({"photos": set.iter().map(|p| p.0).collect::<Vec<_>>(), "error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
Ok(json!({"merged": merged, "failed": failed}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -56,7 +76,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Panorama Merge",
|
||||
[],
|
||||
None,
|
||||
"{ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, stack=false, maxMegapixels=40, preview=false, previewPath?}",
|
||||
"{ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, stack=false, maxMegapixels=full size (≤ 512, held to memory), preview=false, previewPath?}",
|
||||
can_merge,
|
||||
|s, p| run(s, "merge.panorama", p)
|
||||
),
|
||||
@@ -69,5 +89,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
can_merge,
|
||||
|s, p| run(s, "merge.hdrPanorama", p)
|
||||
),
|
||||
cmd!(
|
||||
"merge.hdrBatch",
|
||||
"HDR for Each Bracket Set",
|
||||
["Photo", "Photo Merge"],
|
||||
None,
|
||||
"{ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false}: an HDR merge of each bracket set among the photos (each stack, then photos taken less than a second apart at exposures ≥ ½ EV apart) → {merged: [{id, path, …}], failed: [{photos, error}]}",
|
||||
can_merge,
|
||||
run_batch
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -19,11 +19,14 @@ pub(crate) mod face_detect;
|
||||
mod face_models;
|
||||
mod face_recognize;
|
||||
pub mod filters;
|
||||
mod focus;
|
||||
pub mod geocode;
|
||||
pub mod keywords;
|
||||
pub mod library;
|
||||
pub mod lut_profiles;
|
||||
pub mod manage;
|
||||
pub mod mask_effect_files;
|
||||
pub mod mask_effects;
|
||||
mod masks;
|
||||
mod merge;
|
||||
pub mod metadata;
|
||||
@@ -32,7 +35,10 @@ mod organize;
|
||||
mod prefs;
|
||||
mod preset_files;
|
||||
pub mod previews;
|
||||
mod print;
|
||||
mod query;
|
||||
pub mod recheck;
|
||||
mod slideshow;
|
||||
mod sync;
|
||||
mod xmp;
|
||||
|
||||
@@ -105,7 +111,7 @@ pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
/// can take (issue #684; Add to My Photos first).
|
||||
pub fn has_library_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
if s.targets(&serde_json::Value::Null).iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) }
|
||||
if s.selected().iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) }
|
||||
}
|
||||
/// Why an album command refused a Local-only selection.
|
||||
pub const LOCAL_ONLY: &str = "only Local photos: albums hold library photos (Add to My Photos first)";
|
||||
@@ -145,6 +151,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(manage::specs());
|
||||
v.extend(previews::specs());
|
||||
v.extend(lut_profiles::specs());
|
||||
v.extend(mask_effects::specs());
|
||||
v.extend(cull::specs());
|
||||
v.extend(convert::specs());
|
||||
v.extend(convert::edit_specs());
|
||||
@@ -153,10 +160,13 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(xmp::specs());
|
||||
v.extend(preset_files::specs());
|
||||
v.extend(prefs::specs());
|
||||
v.extend(slideshow::specs());
|
||||
v.extend(export::specs());
|
||||
v.extend(print::specs());
|
||||
v.extend(before::specs());
|
||||
v.extend(browse::specs());
|
||||
v.extend(missing::specs());
|
||||
v.extend(recheck::specs());
|
||||
v.extend(sync::specs());
|
||||
v.extend(metadata::specs());
|
||||
v.extend(geocode::specs());
|
||||
@@ -165,6 +175,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(face_models::specs());
|
||||
v.extend(face_detect::specs());
|
||||
v.extend(face_recognize::specs());
|
||||
v.extend(focus::specs());
|
||||
v.extend(activity::specs());
|
||||
v
|
||||
})
|
||||
@@ -187,6 +198,15 @@ pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result<f64> {
|
||||
p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`")))
|
||||
}
|
||||
/// A non-negative integer parameter: `index: -1` must be an error, not element 0 (`-1.0 as usize`
|
||||
/// saturates to 0), just as `history.restore` already refuses a negative step index.
|
||||
pub(crate) fn index_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
let i = f64_req(p, key, cmd)?;
|
||||
if !(i.is_finite() && i >= 0.0) {
|
||||
return Err(bad(cmd, format!("`{key}` must be a non-negative integer")));
|
||||
}
|
||||
Ok(i as usize)
|
||||
}
|
||||
pub(crate) fn f64_or(p: &Value, key: &str, d: f64) -> f64 {
|
||||
p.get(key).and_then(Value::as_f64).unwrap_or(d)
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ fn stacks_of(s: &Session, targets: &[PhotoId]) -> Vec<StackId> {
|
||||
|
||||
fn in_stack(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
if stacks_of(s, &s.targets(&Value::Null)).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) }
|
||||
if stacks_of(s, &s.selected()).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn has_stacks(s: &Session) -> std::result::Result<(), String> {
|
||||
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{ids?, name?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named `name` or \"Copy N\"",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string);
|
||||
let mut made = Vec::new();
|
||||
for t in targets {
|
||||
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{ids?, top?: photoId (default: the active photo), collapsed?: bool (default true)} — stacks the photos are already in are merged",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let targets = s.targets(p);
|
||||
let targets = s.targets(p)?;
|
||||
let top = p
|
||||
.get("top")
|
||||
.and_then(Value::as_u64)
|
||||
@@ -153,11 +153,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
cmd!("stack.ungroup", "Ungroup Stack", ["Photo", "Stack"], Some("Cmd+Shift+G"), "{ids?}", in_stack, |s, p| {
|
||||
let ops = s.catalog.ungroup_ops(&s.targets(p));
|
||||
let ops = s.catalog.ungroup_ops(&s.targets(p)?);
|
||||
Ok(json!({"ungrouped": commit_ops(s, "Ungroup Stack", ops)?}))
|
||||
}),
|
||||
cmd!("stack.remove", "Remove from Stack", ["Photo", "Stack"], None, "{ids?}", in_stack, |s, p| {
|
||||
let ops = s.catalog.remove_from_stacks_ops(&s.targets(p));
|
||||
let ops = s.catalog.remove_from_stacks_ops(&s.targets(p)?);
|
||||
Ok(json!({"changed": commit_ops(s, "Remove from Stack", ops)?}))
|
||||
}),
|
||||
cmd!("stack.setTop", "Set as Top of Stack", ["Photo", "Stack"], Some("Shift+S"), "{id?} (default: the active photo)", in_stack, |s, p| {
|
||||
@@ -189,7 +189,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| {
|
||||
let stacks = stacks_of(s, &s.targets(p));
|
||||
let stacks = stacks_of(s, &s.targets(p)?);
|
||||
let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false);
|
||||
let collapsed = bool_or(p, "collapsed", !first);
|
||||
set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" })
|
||||
@@ -212,7 +212,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
let gap = f64_or(p, "gap", 60.0).max(0.0);
|
||||
let explicit = p.get("ids").is_some();
|
||||
let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() };
|
||||
let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() };
|
||||
let groups = s.catalog.auto_stack_groups(&pool, gap);
|
||||
let photos: usize = groups.iter().map(Vec::len).sum();
|
||||
if bool_or(p, "preview", false) {
|
||||
|
||||
@@ -43,6 +43,9 @@ fn prefs(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = i.get("perCamera").and_then(Value::as_bool) {
|
||||
d.per_camera = v;
|
||||
}
|
||||
if let Some(v) = i.get("showOnCard").and_then(Value::as_bool) {
|
||||
d.show_on_card = v;
|
||||
}
|
||||
if let Some(v) = i.get("copyright").and_then(Value::as_str) {
|
||||
d.copyright = v.trim().to_string();
|
||||
}
|
||||
@@ -107,7 +110,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Library Preferences",
|
||||
[],
|
||||
None,
|
||||
"{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default), forgetLocalDays?: n (forget untouched Local photos of folders not browsed for n days; 0 = never)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, forgetLocalDays, persistent}",
|
||||
"{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one), showOnCard?: bool (open the import review when a camera or card is connected)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default), forgetLocalDays?: n (forget untouched Local photos of folders not browsed for n days; 0 = never)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, forgetLocalDays, persistent}",
|
||||
always,
|
||||
prefs
|
||||
)]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::mask_effect_files as mef;
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::preset_import::{group_from_dir, read_presets};
|
||||
use crate::presets::{LCPRESET_EXT, expand_preset_paths, to_lcpreset};
|
||||
@@ -18,6 +19,10 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut imported = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let mut skipped = 0usize;
|
||||
// local adjustment (brush) presets — Lightroom's .lrtemplate, Camera Raw's local .xmp, our
|
||||
// .lceffect, also inside zips — become mask effects
|
||||
let mut effects = Vec::new();
|
||||
let mut effect_failed = Vec::new();
|
||||
for top in &paths {
|
||||
let from_dir = std::path::Path::new(top).is_dir();
|
||||
for f in expand_preset_paths(std::slice::from_ref(top)) {
|
||||
@@ -25,18 +30,47 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// the path from the imported folder (inclusive) to the file's folder
|
||||
let base = std::path::Path::new(top).parent().unwrap_or(std::path::Path::new(""));
|
||||
let rel = std::path::Path::new(&f).parent().and_then(|d| d.strip_prefix(base).ok()).map(|d| d.to_string_lossy().to_string());
|
||||
let dir_group = rel.filter(|_| from_dir).and_then(|d| group_from_dir(&d));
|
||||
let parsed = std::fs::read(&f).map_err(|e| e.to_string()).and_then(|b| read_presets(&f, &b, dir_group));
|
||||
let rel = rel.filter(|_| from_dir);
|
||||
let bytes = match mef::read_capped(&f) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
failed.push(json!([f, e]));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let lower = f.to_ascii_lowercase();
|
||||
let maybe_local = [".lrtemplate", ".xmp", ".zip", ".lceffect"].iter().any(|x| lower.ends_with(x));
|
||||
let before = effects.len() + effect_failed.len();
|
||||
let local = maybe_local && mef::found_in(&f, &bytes, rel.as_deref().and_then(mef::group_for_dir), &mut effects, &mut effect_failed);
|
||||
// a zip may hold develop presets as well
|
||||
if local && !lower.ends_with(".zip") {
|
||||
continue;
|
||||
}
|
||||
let dir_group = rel.and_then(|d| group_from_dir(&d));
|
||||
let parsed = read_presets(&f, &bytes, dir_group);
|
||||
if local && parsed.is_err() && effects.len() + effect_failed.len() > before {
|
||||
continue;
|
||||
}
|
||||
match parsed {
|
||||
Ok(list) => {
|
||||
for mut it in list {
|
||||
if let Some(g) = &group {
|
||||
it.preset.group = g.clone();
|
||||
}
|
||||
// the profile it names (issue #1001): one the user imported, or ours for a basic one
|
||||
let mut profile = Value::Null;
|
||||
if let Some((r, _)) = crate::profile_refs::apply(&mut it.preset.settings, &s.lut_profiles) {
|
||||
let look = it.preset.settings["profile"]["look"].clone();
|
||||
it.unmapped.retain(|u| u != "Look");
|
||||
it.unmapped.extend(crate::profile_refs::report(&look, &r));
|
||||
profile = json!({"name": look["name"], "id": it.preset.settings["profile"].get("id")});
|
||||
}
|
||||
let (name, grp) = (it.preset.name.clone(), it.preset.group.clone());
|
||||
let id = if dry { Some(it.preset.id.clone()) } else { s.add_presets(vec![it.preset]).pop() };
|
||||
match id {
|
||||
Some(id) => imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped})),
|
||||
Some(id) => {
|
||||
imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped, "profile": profile}))
|
||||
}
|
||||
None => skipped += 1,
|
||||
}
|
||||
}
|
||||
@@ -45,7 +79,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"imported": imported, "skipped": skipped, "failed": failed}))
|
||||
failed.extend(effect_failed.into_iter().map(|(f, e)| json!([f, e])));
|
||||
let (effects, dividers) = mef::add_found(s, effects, group.as_deref(), dry)?;
|
||||
Ok(json!({"imported": imported, "skipped": skipped, "failed": failed, "effects": effects, "effectDividers": dividers}))
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -69,10 +105,9 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if chosen.is_empty() {
|
||||
return Err(bad("preset.export", "no presets to export (create one first, or pass ids/group)"));
|
||||
}
|
||||
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?;
|
||||
}
|
||||
std::fs::write(&path, to_lcpreset(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?;
|
||||
let target = path.to_string_lossy();
|
||||
s.check_write_target(&target).map_err(|e| bad("preset.export", e))?;
|
||||
crate::export::write_file_durable(&target, to_lcpreset(&chosen).as_bytes()).map_err(crate::EngineError::Other)?;
|
||||
Ok(json!({"path": path.display().to_string(), "count": chosen.len()}))
|
||||
}
|
||||
|
||||
@@ -83,7 +118,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Import Presets",
|
||||
[],
|
||||
None,
|
||||
"{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md → {imported: [{id,name,group,file,unmapped}], skipped, failed}",
|
||||
"{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md; local adjustment presets (Lightroom .lrtemplate, Camera Raw local .xmp, .lceffect) become mask effects (`mask.effects`; divider presets become their groups); a profile the preset names applies when it was imported with profile.import (before or after) → {imported: [{id,name,group,file,unmapped,profile: {name, id}|null}], skipped, failed, effects: [{name,group,file,unmapped}], effectDividers: [{name,group,file}]}",
|
||||
always,
|
||||
import
|
||||
),
|
||||
|
||||
@@ -152,7 +152,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "a preview build is already running"));
|
||||
}
|
||||
// explicit ids, else the selection, else everything in view
|
||||
let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
|
||||
let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p)? } else { s.visible_cloned() };
|
||||
let jobs: Vec<Vec<RenderJob>> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect();
|
||||
let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() });
|
||||
s.preview_build = Some(state.clone());
|
||||
@@ -324,7 +324,7 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if background && s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) {
|
||||
return Err(bad(C, "a preview build is already running"));
|
||||
}
|
||||
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() };
|
||||
let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p)? } else { s.visible_cloned() };
|
||||
let mut jobs: Vec<SmartJob> = Vec::new();
|
||||
for id in ids {
|
||||
let Some(ph) = s.catalog.photo(id).cloned() else { continue };
|
||||
@@ -503,7 +503,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
smart_location
|
||||
),
|
||||
cmd!(query "photo.smartPreview", "Smart Preview Status", [], None, "{id?} → {smartPreview: bool, originalOnline: bool}", always, |s, p| {
|
||||
let id = s.targets(p).first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?;
|
||||
let id = s.targets(p)?.first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?;
|
||||
let online = match s.catalog.photo(id).map(|p| p.source.clone()) {
|
||||
Some(lightcraft_catalog::Source::File { path }) => std::path::Path::new(&path).exists(),
|
||||
_ => true,
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
//! `print.*`: the Print module as commands.
|
||||
//!
|
||||
//! The module's state is [`Session::print`] (layout, image settings, page, guides, job), so the
|
||||
//! app, CLI, control channel and MCP share one print. Lengths are inches in page space (origin at
|
||||
//! the top-left of the page as printed); cell ids and photo ids are numbers. Nothing here touches
|
||||
//! the catalog, so none of it is journaled or undoable; saved templates live in prefs.json.
|
||||
//!
|
||||
//! * `print.get`, `print.set {layout?, image?, page?, guides?, job?}`, `print.reset`
|
||||
//! * `print.cell.add|move|resize|delete|stack|photo|page`, `print.layout.autoLayout|newPage|clear`
|
||||
//! * `print.exportPdf {path, ids?}`
|
||||
//! * `print.template.list|save|apply|delete`
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, f64_req};
|
||||
use crate::print::layout::{Layout, MAX_PAGES, Rect, Snap, Stack, paper_names, paper_size};
|
||||
use crate::print::template::{MAX_TEMPLATES, PrintSettings, PrintTemplate, clean_name};
|
||||
use crate::{Result, Session};
|
||||
|
||||
fn rect_json(r: &Rect) -> Value {
|
||||
json!({"x": r.x, "y": r.y, "w": r.w, "h": r.h})
|
||||
}
|
||||
|
||||
/// The state, with what a front end needs to draw it (page size, printable area, grid cells).
|
||||
fn state(s: &Session, photos: usize) -> Value {
|
||||
let l = &s.print.layout;
|
||||
let mut out = json!({
|
||||
"settings": s.print,
|
||||
"papers": paper_names().filter_map(|n| paper_size(n).map(|(w, h)| json!({"name": n, "width": w, "height": h}))).collect::<Vec<_>>(),
|
||||
"overlaps": l.has_overlaps(),
|
||||
"photos": photos,
|
||||
});
|
||||
if let (Ok((w, h)), Ok(area), Some(o)) = (l.page_size(), l.printable(), out.as_object_mut()) {
|
||||
o.insert("pageWidth".into(), json!(w));
|
||||
o.insert("pageHeight".into(), json!(h));
|
||||
o.insert("printable".into(), rect_json(&area));
|
||||
o.insert("pageCount".into(), json!(l.page_count(photos)));
|
||||
o.insert("cellsPerPage".into(), json!(l.cells_per_page()));
|
||||
if !l.style.is_package() {
|
||||
o.insert("gridCells".into(), json!(l.grid_rects().unwrap_or_default().iter().map(rect_json).collect::<Vec<_>>()));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn reply(s: &Session) -> Result<Value> {
|
||||
Ok(state(s, s.targets(&Value::Null)?.len()))
|
||||
}
|
||||
|
||||
/// Applies `patch` over `dst`: objects merge key by key, anything else replaces.
|
||||
fn merge(dst: &mut Value, patch: &Value, depth: usize) {
|
||||
match (dst, patch) {
|
||||
(Value::Object(d), Value::Object(p)) if depth < 8 => {
|
||||
for (k, v) in p {
|
||||
match d.get_mut(k) {
|
||||
Some(slot) => merge(slot, v, depth + 1),
|
||||
None => {
|
||||
d.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(d, p) => *d = p.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(state(s, s.targets(p)?.len()))
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.set";
|
||||
let patch = p.as_object().ok_or_else(|| bad(ID, "params must be an object"))?;
|
||||
if let Some(k) = patch.keys().find(|k| !matches!(k.as_str(), "layout" | "image" | "page" | "guides" | "job")) {
|
||||
return Err(bad(ID, format!("unknown parameter `{k}` (one of layout, image, page, guides, job)")));
|
||||
}
|
||||
if let Some(l) = patch.get("layout").and_then(Value::as_object)
|
||||
&& let Some(k) = l.keys().find(|k| matches!(k.as_str(), "cells" | "pages" | "nextId"))
|
||||
{
|
||||
return Err(bad(ID, format!("`layout.{k}` is changed with the print.cell.* and print.layout.* commands")));
|
||||
}
|
||||
let mut value = serde_json::to_value(&s.print).map_err(|e| bad(ID, e.to_string()))?;
|
||||
merge(&mut value, p, 0);
|
||||
let mut next: PrintSettings = serde_json::from_value(value).map_err(|e| bad(ID, e.to_string()))?;
|
||||
// A new paper, orientation or margin may leave cells outside the page: pull them back.
|
||||
if next.layout.page.printable().is_ok() {
|
||||
next.layout.fit_cells().map_err(|e| bad(ID, e))?;
|
||||
}
|
||||
next.validate().map_err(|e| bad(ID, e))?;
|
||||
s.print = next;
|
||||
reply(s)
|
||||
}
|
||||
|
||||
fn reset(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.print = PrintSettings::default();
|
||||
reply(s)
|
||||
}
|
||||
|
||||
fn cell_id(p: &Value, id: &str) -> Result<u32> {
|
||||
p.get("id").and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(id, "missing cell `id`"))
|
||||
}
|
||||
|
||||
fn opt_f64(p: &Value, key: &str, id: &str) -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_f64().map(Some).ok_or_else(|| bad(id, format!("`{key}` must be a number"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Snaps `r` (a cell being moved or resized) when the call asks for it.
|
||||
fn snapped(l: &Layout, guides: &crate::print::layout::Guides, id: u32, r: Rect, p: &Value, resize: bool, cmd: &str) -> Result<Rect> {
|
||||
let on = match p.get("snap") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad(cmd, "`snap` must be true or false")),
|
||||
};
|
||||
if !on || guides.snap == Snap::Off {
|
||||
return Ok(r);
|
||||
}
|
||||
let threshold = opt_f64(p, "threshold", cmd)?.unwrap_or(0.1);
|
||||
let f = if resize { Layout::snap_resize } else { Layout::snap_move };
|
||||
f(l, id, r, guides.snap, guides.grid_size, threshold).map_err(|e| bad(cmd, e))
|
||||
}
|
||||
|
||||
fn cell_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.add";
|
||||
let (w, h) = (f64_req(p, "width", ID)?, f64_req(p, "height", ID)?);
|
||||
let mut next = s.print.layout.clone();
|
||||
let id = next.add_cell(w, h).map_err(|e| bad(ID, e))?;
|
||||
if let (Some(x), Some(y)) = (opt_f64(p, "x", ID)?, opt_f64(p, "y", ID)?) {
|
||||
if let Some(page) = p.get("page").and_then(Value::as_u64) {
|
||||
let page = u32::try_from(page).map_err(|_| bad(ID, "`page` is too large"))?;
|
||||
next.set_cell_page(id, page).map_err(|e| bad(ID, e))?;
|
||||
}
|
||||
next.move_cell(id, x, y).map_err(|e| bad(ID, e))?;
|
||||
}
|
||||
if let Some(v) = p.get("photo").filter(|v| !v.is_null()) {
|
||||
let photo = v.as_u64().ok_or_else(|| bad(ID, "`photo` must be a photo id"))?;
|
||||
next.set_cell_photo(id, Some(photo)).map_err(|e| bad(ID, e))?;
|
||||
}
|
||||
s.print.layout = next;
|
||||
let cell = s.print.layout.cell(id).cloned();
|
||||
Ok(json!({"id": id, "cell": cell, "pages": s.print.layout.pages}))
|
||||
}
|
||||
|
||||
fn cell_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.move";
|
||||
let id = cell_id(p, ID)?;
|
||||
let (x, y) = (f64_req(p, "x", ID)?, f64_req(p, "y", ID)?);
|
||||
let mut next = s.print.layout.clone();
|
||||
let size = next.cell(id).map(|c| c.rect).ok_or_else(|| bad(ID, format!("no cell {id}")))?;
|
||||
let target = snapped(&next, &s.print.guides, id, Rect::new(x, y, size.w, size.h), p, false, ID)?;
|
||||
let r = next.move_cell(id, target.x, target.y).map_err(|e| bad(ID, e))?;
|
||||
s.print.layout = next;
|
||||
Ok(json!({"id": id, "rect": r}))
|
||||
}
|
||||
|
||||
fn cell_resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.resize";
|
||||
let id = cell_id(p, ID)?;
|
||||
let mut next = s.print.layout.clone();
|
||||
let cur = next.cell(id).map(|c| c.rect).ok_or_else(|| bad(ID, format!("no cell {id}")))?;
|
||||
let want = Rect::new(
|
||||
opt_f64(p, "x", ID)?.unwrap_or(cur.x),
|
||||
opt_f64(p, "y", ID)?.unwrap_or(cur.y),
|
||||
opt_f64(p, "width", ID)?.unwrap_or(cur.w),
|
||||
opt_f64(p, "height", ID)?.unwrap_or(cur.h),
|
||||
);
|
||||
let target = snapped(&next, &s.print.guides, id, want, p, true, ID)?;
|
||||
let r = next.resize_cell(id, target).map_err(|e| bad(ID, e))?;
|
||||
s.print.layout = next;
|
||||
Ok(json!({"id": id, "rect": r}))
|
||||
}
|
||||
|
||||
fn cell_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.delete";
|
||||
let id = cell_id(p, ID)?;
|
||||
s.print.layout.delete_cell(id).map_err(|e| bad(ID, e))?;
|
||||
Ok(json!({"deleted": id, "cells": s.print.layout.cells.len()}))
|
||||
}
|
||||
|
||||
fn cell_stack(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.stack";
|
||||
let id = cell_id(p, ID)?;
|
||||
let how = match p.get("order").and_then(Value::as_str) {
|
||||
Some("toBack") => Stack::ToBack,
|
||||
Some("backward") => Stack::Backward,
|
||||
Some("forward") => Stack::Forward,
|
||||
Some("toFront") => Stack::ToFront,
|
||||
_ => return Err(bad(ID, "`order` must be toBack, backward, forward or toFront")),
|
||||
};
|
||||
s.print.layout.restack(id, how).map_err(|e| bad(ID, e))?;
|
||||
Ok(json!({"order": s.print.layout.cells.iter().map(|c| c.id).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn cell_photo(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.photo";
|
||||
let id = cell_id(p, ID)?;
|
||||
let photo = match p.get("photo") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
let n = v.as_u64().ok_or_else(|| bad(ID, "`photo` must be a photo id or null"))?;
|
||||
if s.catalog.photo(lightcraft_catalog::PhotoId(n)).is_none() {
|
||||
return Err(bad(ID, format!("no photo {n}")));
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
};
|
||||
s.print.layout.set_cell_photo(id, photo).map_err(|e| bad(ID, e))?;
|
||||
Ok(json!({"id": id, "photo": photo}))
|
||||
}
|
||||
|
||||
fn cell_page(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.cell.page";
|
||||
let id = cell_id(p, ID)?;
|
||||
let page = p.get("page").and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(ID, "missing zero-based `page`"))?;
|
||||
s.print.layout.set_cell_page(id, page).map_err(|e| bad(ID, e))?;
|
||||
Ok(json!({"id": id, "page": page}))
|
||||
}
|
||||
|
||||
fn auto_layout(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.print.layout.auto_layout().map_err(|e| bad("print.layout.autoLayout", e))?;
|
||||
Ok(json!({"pages": s.print.layout.pages, "cells": s.print.layout.cells}))
|
||||
}
|
||||
|
||||
fn new_page(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let page = s.print.layout.new_page().map_err(|e| bad("print.layout.newPage", e))?;
|
||||
Ok(json!({"page": page, "pages": s.print.layout.pages, "max": MAX_PAGES}))
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.print.layout.clear();
|
||||
Ok(json!({"pages": 1, "cells": 0}))
|
||||
}
|
||||
|
||||
fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.exportPdf";
|
||||
let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(ID, "missing path"))?;
|
||||
if let Some(k) = p.as_object().and_then(|o| o.keys().find(|k| !matches!(k.as_str(), "path" | "ids" | "id"))) {
|
||||
return Err(bad(ID, format!("unknown parameter `{k}` (one of path, ids; change the print with print.set)")));
|
||||
}
|
||||
if p.get("ids").is_some_and(|v| v.as_array().is_none_or(|a| a.iter().any(|v| v.as_u64().is_none()))) {
|
||||
return Err(bad(ID, "ids must be an array of photo ids"));
|
||||
}
|
||||
if p.get("id").is_some_and(|v| v.as_u64().is_none()) {
|
||||
return Err(bad(ID, "id must be a photo id"));
|
||||
}
|
||||
s.check_write_target(path).map_err(|e| bad(ID, e))?;
|
||||
let ids = s.targets(p)?;
|
||||
let settings = s.print.clone();
|
||||
// a printer profile that can't be used fails the print before anything renders
|
||||
crate::print_color::PrintColorJob::new(&settings.job.color).map_err(|e| bad(ID, e))?;
|
||||
let title = std::path::Path::new(path).file_stem().and_then(|n| n.to_str()).unwrap_or("Print").to_string();
|
||||
let work = crate::print::compose::prepare(s, &settings, &ids).map_err(|e| bad(ID, e))?;
|
||||
let total = work.len() as u64;
|
||||
let row = s.activity.start("export", "Printing to PDF", crate::activity::Cancel::No);
|
||||
row.progress(0, total);
|
||||
let pages = work
|
||||
.run(&mut |done, name| {
|
||||
row.progress(done as u64, total);
|
||||
row.detail(name);
|
||||
true
|
||||
})
|
||||
.map_err(|e| bad(ID, e))?;
|
||||
let bytes = crate::print::output::write(&pages, &settings.job, &title).map_err(|e| bad(ID, e))?;
|
||||
s.check_write_target(path).map_err(|e| bad(ID, e))?;
|
||||
crate::export::write_file(path, &bytes).map_err(|e| bad(ID, e))?;
|
||||
Ok(json!({"path": path, "pages": pages.len(), "photos": ids.len(), "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
// ---- templates ------------------------------------------------------------------------------
|
||||
|
||||
fn template_list(s: &Session) -> Value {
|
||||
json!({"templates": s.print_templates.iter().map(|t| json!({"name": t.name, "style": t.settings.pointer("/layout/style"), "settings": t.settings})).collect::<Vec<_>>()})
|
||||
}
|
||||
|
||||
fn template_name(p: &Value, id: &str) -> Result<String> {
|
||||
clean_name(p.get("name").and_then(Value::as_str).ok_or_else(|| bad(id, "missing `name`"))?).map_err(|e| bad(id, e))
|
||||
}
|
||||
|
||||
fn template_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.template.save";
|
||||
let name = template_name(p, ID)?;
|
||||
let t = PrintTemplate::new(&name, &s.print).map_err(|e| bad(ID, e))?;
|
||||
match s.print_templates.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) {
|
||||
Some(x) => *x = t,
|
||||
None => {
|
||||
if s.print_templates.len() >= MAX_TEMPLATES {
|
||||
return Err(bad(ID, format!("at most {MAX_TEMPLATES} templates; delete one first")));
|
||||
}
|
||||
s.print_templates.push(t);
|
||||
}
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(template_list(s))
|
||||
}
|
||||
|
||||
fn template_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.template.apply";
|
||||
let name = template_name(p, ID)?;
|
||||
let t = s
|
||||
.print_templates
|
||||
.iter()
|
||||
.find(|x| x.name.eq_ignore_ascii_case(&name))
|
||||
.ok_or_else(|| bad(ID, format!("no print template `{name}` (see print.template.list)")))?;
|
||||
s.print = t.settings(&s.print.guides).map_err(|e| bad(ID, e))?;
|
||||
reply(s)
|
||||
}
|
||||
|
||||
fn template_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const ID: &str = "print.template.delete";
|
||||
let name = template_name(p, ID)?;
|
||||
let before = s.print_templates.len();
|
||||
s.print_templates.retain(|x| !x.name.eq_ignore_ascii_case(&name));
|
||||
if s.print_templates.len() == before {
|
||||
return Err(bad(ID, format!("no print template `{name}`")));
|
||||
}
|
||||
s.save_prefs()?;
|
||||
Ok(template_list(s))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "print.get", "Print Settings", [], None,
|
||||
"{ids?} → {settings: {layout, image, page, guides, job}, papers, pageWidth, pageHeight, printable, pageCount, cellsPerPage, gridCells?, overlaps}; lengths are inches",
|
||||
always, get),
|
||||
cmd!(query "print.set", "Set Print Settings", [], None,
|
||||
"{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b]}, guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get",
|
||||
always, set),
|
||||
cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset),
|
||||
cmd!(query "print.cell.add", "Add Print Cell", [], None,
|
||||
"{width, height, x?, y?, page?, photo?} inches; picture/custom package → {id, cell, pages}; the cell goes to the first free spot unless x and y are given",
|
||||
always, cell_add),
|
||||
cmd!(query "print.cell.move", "Move Print Cell", [], None, "{id, x, y, snap?: bool, threshold?: inches} → {id, rect}; snap uses the guides' snap mode", always, cell_move),
|
||||
cmd!(query "print.cell.resize", "Resize Print Cell", [], None,
|
||||
"{id, width?, height?, x?, y?, snap?: bool, threshold?} → {id, rect}", always, cell_resize),
|
||||
cmd!(query "print.cell.delete", "Delete Print Cell", [], None, "{id}", always, cell_delete),
|
||||
cmd!(query "print.cell.stack", "Arrange Print Cell", [], None, "{id, order: toBack|backward|forward|toFront} → {order: cell ids back to front}", always, cell_stack),
|
||||
cmd!(query "print.cell.photo", "Set Print Cell Photo", [], None, "{id, photo: photo id | null} (custom package: the photo printed in the cell)", always, cell_photo),
|
||||
cmd!(query "print.cell.page", "Move Print Cell to Page", [], None, "{id, page: zero-based}", always, cell_page),
|
||||
cmd!(query "print.layout.autoLayout", "Auto Layout", [], None, "{} — packs the package's cells onto as few pages as possible → {pages, cells}", always, auto_layout),
|
||||
cmd!(query "print.layout.newPage", "New Print Page", [], None, "{} → {page, pages}", always, new_page),
|
||||
cmd!(query "print.layout.clear", "Clear Print Layout", [], None, "{} — removes every cell and extra page", always, clear),
|
||||
cmd!(query "print.exportPdf", "Print to PDF", [], None,
|
||||
"{path, ids?: photo ids (default selection)} → {path, pages, photos, bytes}; prints the current print.get settings; replaces an existing output, never an original",
|
||||
always, export_pdf),
|
||||
cmd!(query "print.template.list", "Print Templates", [], None, "{} → {templates: [{name, style, settings}]}", always, |s, _| Ok(template_list(s))),
|
||||
cmd!(query "print.template.save", "Save Print Template", [], None,
|
||||
"{name} — saves the layout, image settings, page and job (not the cells' photos or the guides); an existing name (any case) is replaced → print.template.list",
|
||||
always, template_save),
|
||||
cmd!(query "print.template.apply", "Apply Print Template", [], None, "{name} → print.get", always, template_apply),
|
||||
cmd!(query "print.template.delete", "Delete Print Template", [], None, "{name} → print.template.list", always, template_delete),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Preview-only raws rechecked after an update (issue #662): a raw imported while its format or
|
||||
//! variant couldn't be decoded keeps [`Photo::preview_only`] until something re-reads its file.
|
||||
//! Each flag is stamped with the app version that set it ([`Photo::preview_checked`]); when a
|
||||
//! library opens in another version, its stale preview-only photos are probed again and get
|
||||
//! exactly what Reload from Disk would give them: a file that decodes now loses the flag, one that
|
||||
//! still doesn't gets the new reason and a fresh stamp, so it isn't probed again.
|
||||
//!
|
||||
//! The work is split so the app can probe on a worker thread: [`stale`] lists what to probe (no
|
||||
//! file-system calls), [`probe`] reads the files without the session, and [`apply`] applies the
|
||||
//! results as LightCraft's own bookkeeping (journaled, not an undo step). Agents and tests use
|
||||
//! `library.recheckPreviewOnly`, which does all three at once.
|
||||
|
||||
use lightcraft_catalog::{Catalog, Op, Photo, PhotoId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd};
|
||||
use crate::media::{FileProbe, ProbeInfo};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// A preview-only photo to probe, and what the library knew about it when it was listed (so a
|
||||
/// result for a photo that changed meanwhile is dropped).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Stale {
|
||||
pub id: PhotoId,
|
||||
pub path: String,
|
||||
preview_only: Option<String>,
|
||||
preview_checked: String,
|
||||
}
|
||||
|
||||
impl Stale {
|
||||
fn of(p: &Photo, path: &str) -> Stale {
|
||||
Stale { id: p.id, path: path.to_string(), preview_only: p.preview_only.clone(), preview_checked: p.preview_checked.clone() }
|
||||
}
|
||||
|
||||
/// The photo is still as it was listed: same file, flag and stamp.
|
||||
fn current(&self, p: &Photo) -> bool {
|
||||
crate::cmd::missing::checked_path(p) == Some(self.path.as_str())
|
||||
&& p.preview_only == self.preview_only
|
||||
&& p.preview_checked == self.preview_checked
|
||||
}
|
||||
}
|
||||
|
||||
/// The library's preview-only photos whose flag another app version set (or every preview-only
|
||||
/// photo, with `all`): library photos with a file, not virtual copies (they follow the photo they
|
||||
/// were copied from). No file-system calls.
|
||||
pub fn stale(cat: &Catalog, all: bool) -> Vec<Stale> {
|
||||
cat.photos()
|
||||
.filter(|p| p.copy_of.is_none() && p.preview_only.is_some() && (all || p.preview_only_stale()))
|
||||
.filter_map(|p| crate::cmd::missing::checked_path(p).map(|path| Stale::of(p, path)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The session's file probe, to [`probe`] with on a worker thread; `None` when the session has no
|
||||
/// file access (a demo or browser session), where nothing can be rechecked.
|
||||
pub fn file_probe(s: &Session) -> Option<FileProbe> {
|
||||
s.media.file_probe.clone()
|
||||
}
|
||||
|
||||
/// Probe each listed photo's file as Reload would. Doesn't touch the session.
|
||||
pub fn probe(probe: &FileProbe, stale: Vec<Stale>) -> Vec<(Stale, std::result::Result<ProbeInfo, String>)> {
|
||||
let paths: Vec<String> = stale.iter().map(|x| x.path.clone()).collect();
|
||||
stale.into_iter().zip(crate::import::probe_with(probe, &paths)).collect()
|
||||
}
|
||||
|
||||
/// What a recheck changed.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Rechecked {
|
||||
/// Photos brought up to date (virtual copies included).
|
||||
pub updated: Vec<PhotoId>,
|
||||
/// Of those, the ones that develop from their raw data now.
|
||||
pub cleared: Vec<PhotoId>,
|
||||
}
|
||||
|
||||
impl Rechecked {
|
||||
pub fn to_json(&self) -> Value {
|
||||
json!({"updated": self.updated.iter().map(|i| i.0).collect::<Vec<_>>(), "cleared": self.cleared.iter().map(|i| i.0).collect::<Vec<_>>()})
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply [`probe`]'s results: each photo gets the ops Reload would give it, all in one batch that is
|
||||
/// journaled but isn't an undo step. A file that can't be read (missing, offline) changes nothing,
|
||||
/// so it is tried again next time; a photo that changed since it was listed (relinked, reloaded,
|
||||
/// removed) is skipped.
|
||||
pub fn apply(s: &mut Session, results: Vec<(Stale, std::result::Result<ProbeInfo, String>)>) -> Result<Rechecked> {
|
||||
let mut ops = Vec::new();
|
||||
let mut out = Rechecked::default();
|
||||
for (item, info) in results {
|
||||
let Ok(info) = info else { continue };
|
||||
if !s.catalog.photo(item.id).is_some_and(|p| item.current(p)) {
|
||||
continue;
|
||||
}
|
||||
let first = out.updated.len();
|
||||
crate::cmd::convert::reload_ops(&s.catalog, item.id, &info, &mut ops, &mut out.updated);
|
||||
if info.preview_only.is_none() {
|
||||
out.cleared.extend_from_slice(&out.updated[first..]);
|
||||
}
|
||||
}
|
||||
if !ops.is_empty() {
|
||||
s.apply_system(Op::Batch { ops })?;
|
||||
}
|
||||
for id in &out.updated {
|
||||
s.media.forget(*id);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn recheck(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.recheckPreviewOnly";
|
||||
let all = p.get("all").and_then(Value::as_bool).unwrap_or(false);
|
||||
// without file access every probe "succeeds" with nothing known, which would clear every flag
|
||||
let fp = file_probe(s).ok_or_else(|| bad(C, "this session has no file access"))?;
|
||||
let listed = stale(&s.catalog, all);
|
||||
let n = listed.len();
|
||||
let mut r = apply(s, probe(&fp, listed))?.to_json();
|
||||
r["checked"] = json!(n);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"library.recheckPreviewOnly",
|
||||
"Recheck Preview-Only Raws",
|
||||
[],
|
||||
None,
|
||||
"{all?: bool} — probe again the raws shown from their embedded preview whose flag another LightCraft version set (the app does this in the background when a library opens; `all` rechecks every preview-only raw, e.g. when testing a decoder build of the same version), and bring each up to date as Reload from Disk would; not an undo step, develop settings and history are kept; files that can't be read are left for next time → {checked, updated: [ids], cleared: [ids] (develop from their raw data now)}",
|
||||
always,
|
||||
recheck
|
||||
)]
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
//! `slideshow.playback`: the slideshow's Playback settings (slide and fade durations, fade colour,
|
||||
//! random order, repeat, preparing previews in advance). `slideshow.layout`: how a slide looks
|
||||
//! (Layout, Options and Backdrop: margins, aspect preview, zoom to fill, stroke, shadow, backdrop
|
||||
//! colour, colour wash, background image). Both are library preferences, saved in the library's
|
||||
//! `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them. `slideshow.overlays`:
|
||||
//! the identity plate, rating stars, text overlays with tokens and the watermark drawn on every
|
||||
//! slide (also a library preference). `slideshow.titles`: the intro and ending screens (colour,
|
||||
//! identity plate or text, scale, duration; a library preference). `slideshow.preview` composes
|
||||
//! one slide, or a title screen, as a PNG (with the library's settings, or a template's).
|
||||
//! `slideshow.templates.*`: the Template Browser (built-in and user templates; see
|
||||
//! [`crate::slide_template`]). `slideshow.export`: the slideshow as a PDF, a JPEG sequence or an
|
||||
//! AV1 video, in the background ([`crate::slideshow_export`]); `slideshow.exportStatus` reads how
|
||||
//! it is going.
|
||||
|
||||
use lightcraft_catalog::PhotoId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, str_param};
|
||||
use crate::slide_template::{self as tpl, SlideLook};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Largest side `slideshow.preview` renders.
|
||||
pub const PREVIEW_MAX: usize = 4096;
|
||||
|
||||
fn playback(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let changes = p.as_object().is_some_and(|o| !o.is_empty());
|
||||
if changes {
|
||||
let next = s.slideshow.merged(p).map_err(|e| bad("slideshow.playback", e))?;
|
||||
if next != s.slideshow {
|
||||
s.slideshow = next;
|
||||
s.save_prefs()?;
|
||||
}
|
||||
}
|
||||
Ok(s.slideshow.to_json())
|
||||
}
|
||||
|
||||
fn layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let changes = p.as_object().is_some_and(|o| !o.is_empty());
|
||||
if changes {
|
||||
let next = s.slide_layout.merged(p).map_err(|e| bad("slideshow.layout", e))?;
|
||||
if next != s.slide_layout {
|
||||
s.slide_layout = next;
|
||||
s.save_prefs()?;
|
||||
}
|
||||
}
|
||||
Ok(s.slide_layout.to_json())
|
||||
}
|
||||
|
||||
fn overlays(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let changes = p.as_object().is_some_and(|o| !o.is_empty());
|
||||
if changes {
|
||||
let next = s.slide_overlays.merged(p).map_err(|e| bad("slideshow.overlays", e))?;
|
||||
if next != s.slide_overlays {
|
||||
s.slide_overlays = next;
|
||||
s.save_prefs()?;
|
||||
}
|
||||
}
|
||||
let mut out = s.slide_overlays.to_json();
|
||||
out["warnings"] = json!(s.slide_overlays.warnings());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn titles(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let changes = p.as_object().is_some_and(|o| !o.is_empty());
|
||||
if changes {
|
||||
let next = s.slide_titles.merged(p).map_err(|e| bad("slideshow.titles", e))?;
|
||||
if next != s.slide_titles {
|
||||
s.slide_titles = next;
|
||||
s.save_prefs()?;
|
||||
}
|
||||
}
|
||||
let mut out = s.slide_titles.to_json();
|
||||
out["warnings"] = json!(s.slide_titles.warnings(&s.slide_overlays));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A slide size from the request: both sides given, one (the other from the aspect preview), or
|
||||
/// neither (1280 wide). Each side 16–[`PREVIEW_MAX`].
|
||||
fn preview_size(layout: &lightcraft_slideshow::SlideSettings, p: &Value) -> Result<(usize, usize)> {
|
||||
const C: &str = "slideshow.preview";
|
||||
let side = |k: &str| -> Result<Option<usize>> {
|
||||
match p.get(k) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => match v.as_u64() {
|
||||
Some(n) if (16..=PREVIEW_MAX as u64).contains(&n) => Ok(Some(n as usize)),
|
||||
_ => Err(bad(C, format!("`{k}` must be a whole number of pixels from 16 to {PREVIEW_MAX}, not {v}"))),
|
||||
},
|
||||
}
|
||||
};
|
||||
let ratio = layout.aspect.ratio(f64::NAN);
|
||||
let clamp = |v: f64| (v.round() as usize).clamp(16, PREVIEW_MAX);
|
||||
Ok(match (side("width")?, side("height")?) {
|
||||
(Some(w), Some(h)) => (w, h),
|
||||
(Some(w), None) => (w, clamp(w as f64 / ratio)),
|
||||
(None, Some(h)) => (clamp(h as f64 * ratio), h),
|
||||
(None, None) => (1280, clamp(1280.0 / ratio)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Write `png` to `path`, or put it in the reply as base64.
|
||||
fn deliver(png: &[u8], path: Option<&str>, out: &mut Value) -> Result<()> {
|
||||
match path {
|
||||
Some(path) => {
|
||||
crate::export::write_file(path, png).map_err(crate::EngineError::Other)?;
|
||||
out["path"] = json!(path);
|
||||
}
|
||||
None => out["png"] = json!(base64(png)),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_png(img: &lightcraft_raster::Rgba8) -> Result<Vec<u8>> {
|
||||
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default())
|
||||
.map_err(|e| crate::EngineError::Other(format!("PNG: {e}")))
|
||||
}
|
||||
|
||||
/// `slideshow.preview {title}`: a title screen (whether or not it is switched on).
|
||||
fn preview_title(s: &mut Session, p: &Value, title: &Value, look: &SlideLook) -> Result<Value> {
|
||||
const C: &str = "slideshow.preview";
|
||||
let t = title
|
||||
.as_str()
|
||||
.and_then(crate::slideshow::Title::parse)
|
||||
.ok_or_else(|| bad(C, format!("`title` must be \"intro\" or \"ending\", not {title}")))?;
|
||||
if p.get("id").is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad(C, "pass `id` or `title`, not both"));
|
||||
}
|
||||
let (w, h) = preview_size(&look.layout, p)?;
|
||||
let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty());
|
||||
if let Some(path) = path {
|
||||
s.check_write_target(path).map_err(|e| bad(C, e))?;
|
||||
}
|
||||
let c = crate::slide_title::TitleCompose::new(&look.titles, &look.overlays, t, w, h);
|
||||
let img = c.compose();
|
||||
let png = encode_png(&img)?;
|
||||
let rect = |r: lightcraft_slideshow::Rect| json!([r.x0, r.y0, r.x1, r.y1]);
|
||||
let mut out = json!({
|
||||
"title": t.id(),
|
||||
"enabled": look.titles.screen(t).enabled,
|
||||
"width": img.width,
|
||||
"height": img.height,
|
||||
"plate": c.plate_box().map(rect),
|
||||
});
|
||||
deliver(&png, path, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn preview(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "slideshow.preview";
|
||||
// a template's look (the Template Browser's hover preview), else the library's settings
|
||||
let template = match p.get("template") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(id)) => Some(s.slide_template(id.trim()).map_err(|e| bad(C, e))?),
|
||||
Some(v) => return Err(bad(C, format!("`template` must be a template id, not {v}"))),
|
||||
};
|
||||
let look = template.as_ref().map_or_else(|| SlideLook::of(s), |t| t.look.clone());
|
||||
if let Some(t) = p.get("title").filter(|v| !v.is_null()) {
|
||||
let mut out = preview_title(s, p, t, &look)?;
|
||||
if let Some(t) = &template {
|
||||
out["template"] = json!(t.id);
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
let id = match p.get("id") {
|
||||
None | Some(Value::Null) => s.active().ok_or_else(|| bad(C, "no photo selected (select one or pass `id`)"))?,
|
||||
Some(v) => PhotoId(v.as_u64().ok_or_else(|| bad(C, format!("`id` must be a photo id, not {v}")))?),
|
||||
};
|
||||
if s.catalog.photo(id).is_none() {
|
||||
return Err(bad(C, format!("no photo {}", id.0)));
|
||||
}
|
||||
let (w, h) = preview_size(&look.layout, p)?;
|
||||
let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty());
|
||||
if let Some(path) = path {
|
||||
s.check_write_target(path).map_err(|e| bad(C, e))?;
|
||||
}
|
||||
let settings = look.layout.clone();
|
||||
let img = s.slide_now_look(&settings, &look.overlays, id, w, h).map_err(crate::EngineError::Other)?;
|
||||
let png = encode_png(&img)?;
|
||||
let aspect = s.slide_photo_aspect(id).unwrap_or(1.5);
|
||||
let l = lightcraft_slideshow::layout(&settings, aspect, img.width, img.height);
|
||||
let rect = |r: lightcraft_slideshow::Rect| json!([r.x0, r.y0, r.x1, r.y1]);
|
||||
let mut out = json!({
|
||||
"id": id.0,
|
||||
"width": img.width,
|
||||
"height": img.height,
|
||||
"cell": rect(l.cell),
|
||||
"photo": rect(l.visible),
|
||||
});
|
||||
let photo = s.catalog.photo(id).cloned();
|
||||
let o = crate::slide_overlay::SlideOverlays::resolve(&look.overlays, photo.as_deref());
|
||||
out["overlays"] = o.boxes(&l).into_iter().map(|(k, r)| json!({"kind": k.id(), "box": rect(r)})).collect();
|
||||
if let Some(t) = &template {
|
||||
out["template"] = json!(t.id);
|
||||
}
|
||||
deliver(&png, path, &mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Standard padded base64 (for an image returned in the reply).
|
||||
fn base64(bytes: &[u8]) -> String {
|
||||
const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
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 = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
|
||||
let at = |shift: u32| A.get((n >> shift) as usize & 63).copied().unwrap_or(b'=') as char;
|
||||
out.push(at(18));
|
||||
out.push(at(12));
|
||||
out.push(if chunk.len() > 1 { at(6) } else { '=' });
|
||||
out.push(if chunk.len() > 2 { at(0) } else { '=' });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------- templates
|
||||
|
||||
/// The settings as the `slideshow.*` commands return them (for template replies).
|
||||
fn settings_json(s: &Session) -> Value {
|
||||
json!({
|
||||
"playback": s.slideshow.to_json(),
|
||||
"layout": s.slide_layout.to_json(),
|
||||
"overlays": s.slide_overlays.to_json(),
|
||||
"titles": s.slide_titles.to_json(),
|
||||
})
|
||||
}
|
||||
|
||||
fn tpl_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let l = s.slide_templates();
|
||||
Ok(json!({
|
||||
"templates": l.templates.iter().map(|t| t.info()).collect::<Vec<_>>(),
|
||||
"previous": s.slide_templates_previous.is_some(),
|
||||
"folder": s.slide_templates_dir.as_ref().map(|d| d.to_string_lossy().to_string()),
|
||||
"problems": l.problems,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The template `id` or `name` names.
|
||||
fn named(s: &mut Session, p: &Value, c: &str) -> Result<std::sync::Arc<tpl::Template>> {
|
||||
s.slide_template_named(str_param(p, "id"), str_param(p, "name")).map_err(|e| bad(c, e))
|
||||
}
|
||||
|
||||
fn tpl_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "slideshow.templates.apply";
|
||||
let t = named(s, p, C)?;
|
||||
let warnings = s.apply_slide_look(&t.look)?;
|
||||
let mut out = json!({"applied": t.id, "name": t.name, "warnings": warnings, "previous": s.slide_templates_previous.is_some()});
|
||||
out["settings"] = settings_json(s);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn tpl_reply(t: &tpl::Template) -> Value {
|
||||
t.info()
|
||||
}
|
||||
|
||||
fn tpl_save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "slideshow.templates.save";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let replace = match p.get("replace") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad(C, format!("`replace` must be true or false, not {v}")))?,
|
||||
};
|
||||
let t = s.save_slide_template(name, replace).map_err(|e| bad(C, e))?;
|
||||
Ok(tpl_reply(&t))
|
||||
}
|
||||
|
||||
fn id_param<'a>(p: &'a Value, c: &str) -> Result<&'a str> {
|
||||
str_param(p, "id").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(c, "missing `id` (a template id from slideshow.templates.list)"))
|
||||
}
|
||||
|
||||
fn tpl_update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "slideshow.templates.update";
|
||||
let id = id_param(p, C)?;
|
||||
let t = s.update_slide_template(id).map_err(|e| bad(C, e))?;
|
||||
Ok(tpl_reply(&t))
|
||||
}
|
||||
|
||||
fn tpl_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "slideshow.templates.rename";
|
||||
let id = id_param(p, C)?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?;
|
||||
let t = s.rename_slide_template(id, name).map_err(|e| bad(C, e))?;
|
||||
Ok(tpl_reply(&t))
|
||||
}
|
||||
|
||||
fn tpl_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "slideshow.templates.delete";
|
||||
let id = id_param(p, C)?;
|
||||
let t = s.delete_slide_template(id).map_err(|e| bad(C, e))?;
|
||||
Ok(json!({"deleted": t.id, "name": t.name}))
|
||||
}
|
||||
|
||||
fn tpl_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "slideshow.templates.import";
|
||||
let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let (t, renamed) = s.import_slide_template(std::path::Path::new(path)).map_err(|e| bad(C, e))?;
|
||||
let mut out = tpl_reply(&t);
|
||||
out["renamed"] = json!(renamed);
|
||||
out["warnings"] = json!(t.look.warnings(s));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn tpl_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "slideshow.templates.export";
|
||||
let id = id_param(p, C)?;
|
||||
let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let t = s.slide_template(id).map_err(|e| bad(C, e))?;
|
||||
s.check_write_target(path).map_err(|e| bad(C, e))?;
|
||||
let bytes = serde_json::to_vec_pretty(&t.to_doc()).map_err(|e| crate::EngineError::Other(e.to_string()))?;
|
||||
crate::export::write_file(path, &bytes).map_err(crate::EngineError::Other)?;
|
||||
Ok(json!({"id": t.id, "name": t.name, "path": path}))
|
||||
}
|
||||
|
||||
/// `slideshow.export`: prepare on this thread, run on a worker (or here with `wait`).
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "slideshow.export";
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Err(bad(C, "slideshow export needs the desktop app or lightcraft-cli"));
|
||||
}
|
||||
if s.slideshow_export.as_ref().is_some_and(|r| r.running()) {
|
||||
return Err(bad(C, "a slideshow export is already running (activity.cancel stops it)"));
|
||||
}
|
||||
let prepared = crate::slideshow_export::prepare(s, p).map_err(|e| bad(C, e))?;
|
||||
let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false);
|
||||
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let task = s.activity.start("export", "Exporting slideshow", crate::activity::Cancel::Flag(cancel.clone()));
|
||||
let total = prepared.total();
|
||||
task.progress(0, total);
|
||||
task.detail(&prepared.request.path);
|
||||
let settings = &prepared.request.settings;
|
||||
let run = std::sync::Arc::new(crate::slideshow_export::ExportRun {
|
||||
task_id: task.id(),
|
||||
format: settings.format.id().into(),
|
||||
path: prepared.request.path.clone(),
|
||||
total: total.into(),
|
||||
cancel,
|
||||
..Default::default()
|
||||
});
|
||||
s.slideshow_export = Some(run.clone());
|
||||
let started = json!({
|
||||
"taskId": task.id(),
|
||||
"background": !wait,
|
||||
"format": settings.format.id(),
|
||||
"path": prepared.request.path,
|
||||
"slides": prepared.len(),
|
||||
"seconds": prepared.seconds(),
|
||||
"total": total,
|
||||
"width": prepared.width,
|
||||
"height": prepared.height,
|
||||
"warnings": prepared.warnings,
|
||||
});
|
||||
if wait {
|
||||
crate::slideshow_export::run_to_end(prepared, &run, &task);
|
||||
drop(task);
|
||||
return match run.result() {
|
||||
Some(Ok(v)) => Ok(v),
|
||||
Some(Err(e)) => Err(bad(C, e)),
|
||||
None => Err(bad(C, "the export did not finish")),
|
||||
};
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// the row goes when the worker ends, however it ends
|
||||
let r = run.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("lc-slideshow-export".into())
|
||||
.spawn(move || crate::slideshow_export::run_to_end(prepared, &r, &task))
|
||||
.map_err(|e| bad(C, format!("could not start: {e}")))?;
|
||||
}
|
||||
Ok(started)
|
||||
}
|
||||
|
||||
fn export_status(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(s.slideshow_export.as_ref().map_or_else(|| json!({"running": false}), |r| r.json()))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"slideshow.playback",
|
||||
"Slideshow Playback Settings",
|
||||
[],
|
||||
None,
|
||||
"{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews}",
|
||||
always,
|
||||
playback
|
||||
),
|
||||
cmd!(
|
||||
"slideshow.layout",
|
||||
"Slideshow Layout Settings",
|
||||
[],
|
||||
None,
|
||||
"{margins?: all four, marginLeft?, marginRight?, marginTop?, marginBottom?: fractions of the slide's shorter side (0–0.45; with linkMargins one sets all four), linkMargins?: bool, showGuides?: bool (margin guides in the slide editor), aspect?: \"screen\"|\"16:9\"|\"4:3\" (aspect preview), zoomToFill?: bool (crop the photo to fill its cell), strokeBorder?: bool, strokeColor?: \"#rrggbb\"|[r,g,b], strokeWidth?: fraction of the shorter side (0–0.05), castShadow?: bool, shadowOpacity?: 0–1, shadowOffset?: 0–0.1, shadowRadius?: 0–0.1 (fractions of the shorter side), shadowAngle?: degrees counter-clockwise from the right (−45 = down right), backdropColor?: colour, colorWash?: bool, washColor?: colour, washOpacity?: 0–1, washAngle?: degrees (the side the wash colour is full at), backgroundImage?: photo id|null, backgroundOpacity?: 0–1} — omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library → the settings",
|
||||
always,
|
||||
layout
|
||||
),
|
||||
cmd!(
|
||||
"slideshow.overlays",
|
||||
"Slideshow Overlay Settings",
|
||||
[],
|
||||
None,
|
||||
"{identityPlate?: {enabled?, text?, image?: PNG/JPEG file (drawn instead of the text; \"\" = the text), size?: height as a fraction of the slide's shorter side (0.005–0.5), color?: \"#rrggbb\"|[r,g,b], opacity?: 0–1, …placement, behind?: bool (render behind the photo)}, ratingStars?: {enabled?, color?, size?: a star's height (0.005–0.15), …placement}, texts?: [text overlay] (replaces the list, at most 16; each entry merged onto the one at its index), addText?: text overlay ({} = the default \"{file}\"), removeText?: index, showWatermark?: bool, watermark?: text | the export watermark object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth} (merged; sizes relative to the photo)} where a text overlay is {text?: template with tokens ({file}, {name}, {title}, {caption}, {date} (YYYY-MM-DD) / {date:%d.%m.%Y}, {exposure}, {shutter}, {aperture}, {focal}, {iso}, {camera}, {lens}, {rating}, {creator}, {copyright}, {folder}, {ext}; unknown tags show as typed), size?: line height (0.005–0.3), color?, opacity?, …placement, shadow?: bool, shadowOpacity?: 0–1, shadowOffset?, shadowRadius?: 0–0.05 (fractions of the shorter side), shadowAngle?: degrees counter-clockwise from the right} and the placement keys are anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, offsetX?, offsetY?: fractions of the slide's shorter side (x right, y down; −1–1), relativeTo?: \"slide\"|\"photo\" (the overlay is kept on the slide) — omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library; slideshow playback and slideshow.preview draw them → the settings and warnings (an unreadable identity plate image, unknown tokens)",
|
||||
always,
|
||||
overlays
|
||||
),
|
||||
cmd!(
|
||||
"slideshow.titles",
|
||||
"Slideshow Title Settings",
|
||||
[],
|
||||
None,
|
||||
"{intro?: screen, ending?: screen} where a screen is {enabled?: bool, color?: background \"#rrggbb\"|[r,g,b], content?: \"none\"|\"identityPlate\" (the Overlays identity plate, its text or image)|\"text\", text?: the text shown with content \"text\", overrideColor?: bool (draw the plate or text in textColor instead of the identity plate's colour; an image plate keeps its colours), textColor?: colour, scale?: height of the plate or text line as a fraction of the slide's shorter side (0.005–0.5; text wider than the slide shrinks to fit), duration?: seconds (0.5–120) | null (the slide duration)} — the intro plays once before the first slide, the ending after the last one when repeat is off; both fade with the playback fade; random order never moves them. Omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library → the settings and warnings (an unreadable identity plate image, a screen with nothing to show)",
|
||||
always,
|
||||
titles
|
||||
),
|
||||
cmd!(
|
||||
query "slideshow.templates.list",
|
||||
"Slideshow Templates",
|
||||
[],
|
||||
None,
|
||||
"{} → {templates: [{id: \"builtin:…\"|\"user:…\", name, builtin, group: \"builtin\"|\"user\", description?, path? (a user template's file)}], previous: bool (the settings before the last apply can be restored with apply {id: \"previous\"}), folder: the user templates folder | null, problems: [\"file: why it was skipped\"]} — built-ins first, then user templates by name (files in <settings>/slideshow-templates, at most 1000)",
|
||||
always,
|
||||
tpl_list
|
||||
),
|
||||
cmd!(
|
||||
"slideshow.templates.apply",
|
||||
"Apply Slideshow Template",
|
||||
[],
|
||||
None,
|
||||
"{id: template id | \"previous\" (the settings before the last apply) | name: template name (case-insensitive)} — replaces the playback (prepare previews kept), layout, overlays and titles settings with the template's and saves them with the library; there is no undo: the replaced settings are kept as \"previous\" for this session → {applied, name, warnings: [a background photo not in this library, an unreadable identity plate / watermark image…], previous, settings: {playback, layout, overlays, titles}}",
|
||||
always,
|
||||
tpl_apply
|
||||
),
|
||||
cmd!(
|
||||
"slideshow.templates.save",
|
||||
"Save Slideshow Template",
|
||||
[],
|
||||
None,
|
||||
"{name: 1–80 characters, replace?: bool (over a user template of that name)} — the current playback, layout, overlays and titles settings as a user template, <settings>/slideshow-templates/<name>.json (path separators and other reserved characters become _); a name taken (case-insensitive) or a built-in's is refused unless replace → {id, name, builtin, group, path}",
|
||||
always,
|
||||
tpl_save
|
||||
),
|
||||
cmd!(
|
||||
"slideshow.templates.update",
|
||||
"Update Slideshow Template",
|
||||
[],
|
||||
None,
|
||||
"{id: user template} — replace its settings with the current ones (name kept); built-ins can't change → {id, name, builtin, group, path}",
|
||||
always,
|
||||
tpl_update
|
||||
),
|
||||
cmd!(
|
||||
"slideshow.templates.rename",
|
||||
"Rename Slideshow Template",
|
||||
[],
|
||||
None,
|
||||
"{id: user template, name} — its file follows the name; a name taken is refused → {id (the new one), name, builtin, group, path}",
|
||||
always,
|
||||
tpl_rename
|
||||
),
|
||||
cmd!(
|
||||
"slideshow.templates.delete",
|
||||
"Delete Slideshow Template",
|
||||
[],
|
||||
None,
|
||||
"{id: user template} — deletes its file → {deleted, name}",
|
||||
always,
|
||||
tpl_delete
|
||||
),
|
||||
cmd!(
|
||||
"slideshow.templates.import",
|
||||
"Import Slideshow Template",
|
||||
[],
|
||||
None,
|
||||
"{path: a template .json file (at most 256 KB)} — checked like a saved one (format, version, every setting) and saved as a user template under its name (else the file's); a name taken gets \" (2)\"… → {id, name, builtin, group, path, renamed, warnings}",
|
||||
always,
|
||||
tpl_import
|
||||
),
|
||||
cmd!(
|
||||
"slideshow.templates.export",
|
||||
"Export Slideshow Template",
|
||||
[],
|
||||
None,
|
||||
"{id: template (built-in or user), path: .json file to write} → {id, name, path}",
|
||||
always,
|
||||
tpl_export
|
||||
),
|
||||
cmd!(
|
||||
query "slideshow.preview",
|
||||
"Slideshow Slide Preview",
|
||||
[],
|
||||
None,
|
||||
"{id?: photo (default: the active one) | title?: \"intro\"|\"ending\", template?: a template id (slideshow.templates.list): compose with its settings instead of the library's, which don't change (the Template Browser's hover preview), width?, height?: pixels (16–4096; one given: the other from the aspect preview; neither: 1280 wide), path?: write the PNG here} — the photo's slide composed with the slideshow layout and overlays → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), overlays: [{kind: identityPlate|ratingStars|textN|watermark, box: [x0,y0,x1,y1]}], path | png (base64 PNG)}; with `title`, that title screen (`slideshow.titles`; drawn even while it is off) → {title, enabled, width, height, plate: [x0,y0,x1,y1] | null (the plate or text), path | png}",
|
||||
always,
|
||||
preview
|
||||
),
|
||||
cmd!(
|
||||
query "slideshow.export",
|
||||
"Export Slideshow",
|
||||
[],
|
||||
None,
|
||||
"{format: \"pdf\"|\"jpeg\"|\"video\", path: the .pdf / .mp4 / .webm file, or for jpeg the folder (created if missing), size?: pdf/jpeg the long side in pixels (64–8192, default 1920), video the frame height (64–2160 or \"720p\"/\"1080p\", default 720), quality?: 1–100 (JPEG quality, default 90; video default 75), fps?: 24|25|30|60 (video, default 30), container?: \"mp4\"|\"webm\" (video, default mp4), fullScreen?: bool (pdf: open in the viewer's full screen mode, default true), speed?: 1–10 (video encoder speed, default 10 = fastest), name?: jpeg file name prefix (default the folder's name: <name>-001.jpg…), ids?: photo ids (default the photos in view, in view order), wait?: bool (run here and reply with the result)} — the photos' slides with the slideshow layout, overlays and the intro/ending screens that are on; video is AV1 (no audio), each slide held for its duration and fades rendered as frames, at most 2 hours. Runs in the background with a row in the activity stack (activity.cancel {id: taskId} stops it; nothing is left behind: files are written as .part and renamed when complete); a photo that can't be rendered is skipped with a warning → {taskId, background, format, path, slides, seconds, total, width, height, warnings}; with wait: the result (slideshow.exportStatus)",
|
||||
always,
|
||||
export
|
||||
),
|
||||
cmd!(
|
||||
query "slideshow.exportStatus",
|
||||
"Slideshow Export Status",
|
||||
[],
|
||||
None,
|
||||
"{} → the last slideshow export: {taskId, format, path, running, done, total (slides, or frames for video), cancelled, result?: {format, path, width, height, slides, skipped, warnings, pages? | files? | frames, samples, keyFrames, seconds, fps, container, codec: \"av1\", audio: false; bytes}, error?} | {running: false} when there was none",
|
||||
always,
|
||||
export_status
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -12,7 +12,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut merged = Vec::new();
|
||||
let mut backups = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let ids = s.targets(p);
|
||||
let ids = s.targets(p)?;
|
||||
let owners = crate::sidecar::StemOwners::among(&s.catalog, &ids);
|
||||
for id in ids {
|
||||
match s.save_sidecar_with(id, &owners) {
|
||||
@@ -37,7 +37,7 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut read = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
for id in s.targets(p) {
|
||||
for id in s.targets(p)? {
|
||||
match s.read_sidecar_op_with_warnings(id) {
|
||||
Ok(Some((op, from, mut photo_warnings))) => {
|
||||
ops.push(op);
|
||||
@@ -108,6 +108,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
prefs
|
||||
),
|
||||
cmd!(
|
||||
"library.retrySidecars",
|
||||
"Write Waiting XMP Sidecars",
|
||||
[],
|
||||
None,
|
||||
"{} — writes again the automatic XMP sidecars that failed (folder offline, read-only or full) whose originals are reachable now → {written, waiting}",
|
||||
always,
|
||||
|s, _| {
|
||||
let written = s.retry_unsaved_sidecars();
|
||||
Ok(json!({"written": written, "waiting": s.unsaved_sidecars()}))
|
||||
}
|
||||
),
|
||||
cmd!("library.toggleAutoWriteXmp", "Automatically Write Changes into XMP", ["File"], None, "{} → {autoWrite}", always, |s, _| {
|
||||
let on = !s.xmp.auto_write;
|
||||
prefs(s, &json!({"autoWrite": on}))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Paginated contact sheets, rendered with the normal export pipeline and packaged as PDF.
|
||||
//! Paginated contact sheets, rendered with the normal export pipeline and packaged as PDF
|
||||
//! ([`crate::pdf`]).
|
||||
use crate::export::{Anchor, ExportFormat, ExportOptions, Watermark, draw_watermark, encode_image};
|
||||
use crate::{Session, media::RenderJob};
|
||||
use lightcraft_pipeline::{OutputDepth, OutputSpace};
|
||||
@@ -82,7 +83,7 @@ pub fn prepare(session: &mut Session, p: &Value) -> Result<Prepared, String> {
|
||||
if p.get("id").is_some_and(|v| v.as_u64().is_none()) {
|
||||
return Err("id must be a photo id".into());
|
||||
}
|
||||
let ids = session.targets(p);
|
||||
let ids = session.targets(p).map_err(|e| e.to_string())?;
|
||||
if ids.is_empty() || ids.len() > 1000 {
|
||||
return Err("select between 1 and 1000 photos".into());
|
||||
}
|
||||
@@ -114,7 +115,7 @@ impl Prepared {
|
||||
let capacity = self.options.columns * self.options.rows;
|
||||
let photos = self.items.len();
|
||||
let pages = photos.div_ceil(capacity);
|
||||
let mut pdf = Pdf::new(pages, l.points);
|
||||
let mut pdf = crate::pdf::Pdf::new(Vec::new())?;
|
||||
let mut page = Rgba8::filled(l.width, l.height, [255; 4]);
|
||||
for (i, (name, job)) in self.items.into_iter().enumerate() {
|
||||
if !progress(i, &name) {
|
||||
@@ -149,17 +150,17 @@ impl Prepared {
|
||||
}
|
||||
if slot + 1 == capacity || i + 1 == photos {
|
||||
let jpg = encode_image(&page, &ExportOptions { format: ExportFormat::Jpeg, quality: 92, ..Default::default() })?;
|
||||
if pdf.bytes.len().saturating_add(jpg.len()) > 256 * 1024 * 1024 {
|
||||
if pdf.len().saturating_add(jpg.len()) > 256 * 1024 * 1024 {
|
||||
return Err("contact sheet exceeds 256 MiB; select fewer photos".into());
|
||||
}
|
||||
pdf.page(i / capacity, &jpg, l.width, l.height);
|
||||
pdf.image_page(&jpg, l.width, l.height, l.points, "")?;
|
||||
page.data.fill([255; 4]);
|
||||
}
|
||||
}
|
||||
if !progress(photos, "PDF") {
|
||||
return Err("Contact sheet cancelled".into());
|
||||
}
|
||||
Ok(Document { bytes: pdf.finish(), pages, photos })
|
||||
Ok(Document { bytes: pdf.finish("")?, pages, photos })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,67 +197,6 @@ fn blit(dst: &mut Rgba8, src: &Rgba8, x: usize, y: usize) -> Result<(), String>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Minimal PDF 1.4: one JPEG XObject per page, explicit byte lengths and classic xref offsets.
|
||||
struct Pdf {
|
||||
bytes: Vec<u8>,
|
||||
offsets: Vec<usize>,
|
||||
points: (f64, f64),
|
||||
}
|
||||
|
||||
impl Pdf {
|
||||
fn new(pages: usize, points: (f64, f64)) -> Self {
|
||||
let mut p = Self { bytes: b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n".to_vec(), offsets: vec![0], points };
|
||||
p.object(1, b"<< /Type /Catalog /Pages 2 0 R >>");
|
||||
let kids = (0..pages).map(|i| format!("{} 0 R", 3 + i * 3)).collect::<Vec<_>>().join(" ");
|
||||
p.object(2, format!("<< /Type /Pages /Count {pages} /Kids [{kids}] >>").as_bytes());
|
||||
p
|
||||
}
|
||||
|
||||
fn object(&mut self, id: usize, body: &[u8]) {
|
||||
self.offsets.push(self.bytes.len());
|
||||
self.bytes.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
|
||||
self.bytes.extend_from_slice(body);
|
||||
self.bytes.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
|
||||
fn stream(&mut self, id: usize, dictionary: &str, bytes: &[u8]) {
|
||||
let mut body = format!("<< {dictionary} /Length {} >>\nstream\n", bytes.len()).into_bytes();
|
||||
body.extend_from_slice(bytes);
|
||||
body.extend_from_slice(b"\nendstream");
|
||||
self.object(id, &body);
|
||||
}
|
||||
|
||||
fn page(&mut self, index: usize, jpg: &[u8], width: usize, height: usize) {
|
||||
let id = 3 + index * 3;
|
||||
let (w, h) = self.points;
|
||||
self.object(
|
||||
id,
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w:.2} {h:.2}] /Resources << /XObject << /Im {} 0 R >> >> /Contents {} 0 R >>",
|
||||
id + 2,
|
||||
id + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
self.stream(id + 1, "", format!("q {w:.2} 0 0 {h:.2} 0 0 cm /Im Do Q").as_bytes());
|
||||
self.stream(
|
||||
id + 2,
|
||||
&format!("/Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"),
|
||||
jpg,
|
||||
);
|
||||
}
|
||||
|
||||
fn finish(mut self) -> Vec<u8> {
|
||||
let start = self.bytes.len();
|
||||
self.bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).as_bytes());
|
||||
for offset in self.offsets.iter().skip(1) {
|
||||
self.bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
self.bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", self.offsets.len()).as_bytes());
|
||||
self.bytes
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -64,13 +64,20 @@ const LOCAL: &[(&str, &str, f64)] = &[
|
||||
("LocalClarity", "clarity", 100.0),
|
||||
];
|
||||
|
||||
fn adjust(c: &XmpValue) -> Value {
|
||||
/// Is `crs` (without the `crs:` prefix) a local slider field we map?
|
||||
pub(crate) fn is_local_field(crs: &str) -> bool {
|
||||
LOCAL.iter().any(|(k, _, _)| *k == crs)
|
||||
}
|
||||
|
||||
/// Our local adjustment values from the `Local*` slider fields that `get` finds (the name without
|
||||
/// the `crs:` prefix → the stored fraction); process-version 2012 fields win over the older ones.
|
||||
pub(crate) fn local_values(get: impl Fn(&str) -> Option<f64>) -> serde_json::Map<String, Value> {
|
||||
let mut a = serde_json::Map::new();
|
||||
for (crs, ours, k) in LOCAL {
|
||||
if a.contains_key(*ours) {
|
||||
continue;
|
||||
}
|
||||
if let Some(v) = num(c, &format!("crs:{crs}")) {
|
||||
if let Some(v) = get(crs).filter(|v| v.is_finite()) {
|
||||
let lim = if *ours == "exposure" {
|
||||
4.0
|
||||
} else if *ours == "color_hue" {
|
||||
@@ -82,6 +89,11 @@ fn adjust(c: &XmpValue) -> Value {
|
||||
a.insert((*ours).into(), json!(v));
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
fn adjust(c: &XmpValue) -> Value {
|
||||
let mut a = local_values(|crs| num(c, &format!("crs:{crs}")));
|
||||
let amount = num(c, "crs:CorrectionAmount").unwrap_or(1.0);
|
||||
a.insert("amount".into(), json!((amount * 100.0).clamp(0.0, 200.0)));
|
||||
Value::Object(a)
|
||||
@@ -98,8 +110,46 @@ fn pt(x: f64, y: f64) -> Value {
|
||||
json!({"x": x, "y": y})
|
||||
}
|
||||
|
||||
/// One mask component → our component JSON; `Err(kind)` names an unsupported one.
|
||||
fn component(m: &XmpValue, aspect: f64) -> Result<Value, String> {
|
||||
/// Whether a `Mask/CircularGradient` component puts its effect inside the ellipse, given the
|
||||
/// container it came from (`crs:Flipped` means different things in different formats).
|
||||
///
|
||||
/// What Lightroom Classic does, from its exports of synthetic DNGs whose masks we wrote (issue #1815):
|
||||
/// - **`MaskGroupBasedCorrections`, `crs:Version` ≥ 2** (what Lightroom writes today): an ordinary
|
||||
/// radial — effect inside — is written `Flipped="true"`, so `Flipped` is the "inside" flag.
|
||||
/// Inverting the mask is a separate field, `crs:MaskInverted` (see [`component`]). `Flipped="false"`
|
||||
/// is read as outside, the same sense as the legacy filter's; Lightroom never wrote one for us.
|
||||
/// - **`MaskGroupBasedCorrections` without `crs:Version`**: Lightroom renders the effect inside
|
||||
/// whatever `Flipped` says, and rewrites both `Flipped="false"` and `"true"` as `Flipped="true"`
|
||||
/// with `Version="2"`. So `Flipped` is ignored here.
|
||||
/// - **`CircularGradientBasedCorrections`** (the process-2012 Radial Filter): its default effect was
|
||||
/// outside, and "Invert Mask" — effect inside — was recorded as `Flipped="true"`. The same "inside"
|
||||
/// flag as version 2, with outside as the default when it is missing.
|
||||
fn radial_inside(m: &XmpValue, container: &str) -> bool {
|
||||
let flipped = flag(m, "crs:Flipped");
|
||||
if container == "crs:MaskGroupBasedCorrections" {
|
||||
if num(m, "crs:Version").is_some_and(|v| v >= 2.0) { flipped.unwrap_or(true) } else { true }
|
||||
} else {
|
||||
flipped.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Mask/CircularGradient` component's feather on our 0..100 scale.
|
||||
///
|
||||
/// Lightroom halves `crs:Feather` when it upgrades a `MaskGroupBasedCorrections` radial without
|
||||
/// `crs:Version` to version 2 (50 → 25, 100 → 50) and renders both the same, so the version-less
|
||||
/// value is on twice the scale. Version 2's value is used as it is: its 25 and 50 put the middle of
|
||||
/// Lightroom's fall-off at about 0.87 and 0.76 of the radius, ours at about 0.86 and 0.73. (The
|
||||
/// shape differs: Lightroom's fall-off straddles the ellipse edge, ours ends at it.) The legacy
|
||||
/// Radial Filter's value is used as it is: no observation says otherwise.
|
||||
fn radial_feather(m: &XmpValue, container: &str) -> f64 {
|
||||
let f = num(m, "crs:Feather").unwrap_or(50.0);
|
||||
let versionless_group = container == "crs:MaskGroupBasedCorrections" && !num(m, "crs:Version").is_some_and(|v| v >= 2.0);
|
||||
(if versionless_group { f / 2.0 } else { f }).clamp(0.0, 100.0)
|
||||
}
|
||||
|
||||
/// One mask component of a correction in `container` → our component JSON; `Err(kind)` names an
|
||||
/// unsupported one.
|
||||
fn component(m: &XmpValue, container: &str, aspect: f64) -> Result<Value, String> {
|
||||
let what = text(m, "crs:What").unwrap_or("");
|
||||
let op = match num(m, "crs:MaskBlendMode").unwrap_or(0.0) as i64 {
|
||||
1 => "subtract",
|
||||
@@ -123,8 +173,9 @@ fn component(m: &XmpValue, aspect: f64) -> Result<Value, String> {
|
||||
"rx": ((r - l).abs() / 2.0 * sx).max(1e-3),
|
||||
"ry": ((b - t).abs() / 2.0 * sy).max(1e-3),
|
||||
"angle": g("Angle", 0.0),
|
||||
"feather": g("Feather", 50.0).clamp(0.0, 100.0),
|
||||
"invert": flag(m, "crs:Flipped").unwrap_or(false),
|
||||
"feather": radial_feather(m, container),
|
||||
// our radial's `invert` = effect outside; `crs:MaskInverted` stays the component's
|
||||
"invert": !radial_inside(m, container),
|
||||
})
|
||||
}
|
||||
"Mask/Paint" => {
|
||||
@@ -193,7 +244,7 @@ pub fn masks(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) {
|
||||
let comps = c.field("crs:CorrectionMasks").map(XmpValue::items).unwrap_or_default();
|
||||
let mut parts = Vec::new();
|
||||
for m in comps {
|
||||
match component(m, aspect) {
|
||||
match component(m, container, aspect) {
|
||||
Ok(v) => parts.push(v),
|
||||
Err(kind) => skipped.push(kind),
|
||||
}
|
||||
@@ -392,7 +443,7 @@ mod tests {
|
||||
</rdf:Description></rdf:li>
|
||||
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="0.5" crs:LocalContrast2012="0.2">
|
||||
<crs:CorrectionMasks><rdf:Seq>
|
||||
<rdf:li crs:What="Mask/CircularGradient" crs:Top="0.3" crs:Left="0.2" crs:Bottom="0.7" crs:Right="0.8" crs:Angle="10" crs:Feather="40" crs:Flipped="true"/>
|
||||
<rdf:li crs:What="Mask/CircularGradient" crs:Top="0.3" crs:Left="0.2" crs:Bottom="0.7" crs:Right="0.8" crs:Angle="10" crs:Feather="40" crs:Flipped="true" crs:Version="2"/>
|
||||
<rdf:li crs:What="Mask/RangeMask" crs:MaskBlendMode="2"><crs:CorrectionRangeMask crs:Type="2" crs:LumRange="0.4 0.5 0.8 0.9"/></rdf:li>
|
||||
<rdf:li crs:What="Mask/Image" crs:MaskSubType="9"/>
|
||||
</rdf:Seq></crs:CorrectionMasks>
|
||||
@@ -433,7 +484,8 @@ mod tests {
|
||||
let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &rad.components[0].shape else { panic!() };
|
||||
assert!((center.x - 0.5).abs() < 1e-9 && (center.y - 0.5).abs() < 1e-9);
|
||||
assert!((rx - 0.3).abs() < 1e-9 && (ry - 0.2 / 1.5).abs() < 1e-9, "radii in long-edge units: {rx} {ry}");
|
||||
assert_eq!((*angle, *feather, *invert), (10.0, 40.0, true));
|
||||
assert_eq!((*angle, *feather, *invert), (10.0, 40.0, false), "Flipped=\"true\" is Lightroom's ordinary radial: effect inside");
|
||||
assert!(!rad.components[0].invert);
|
||||
let MaskShape::LuminanceRange { lo, hi, lo_feather, .. } = &rad.components[1].shape else { panic!() };
|
||||
assert!(lo < hi && *lo_feather > 0.0);
|
||||
let brush = &s.masks[2];
|
||||
@@ -491,6 +543,114 @@ mod tests {
|
||||
assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height");
|
||||
}
|
||||
|
||||
/// One radial correction in `container`, with `attrs` on its mask component (values written
|
||||
/// for this test; the component attributes follow what Lightroom Classic writes).
|
||||
fn radial_packet(container: &str, attrs: &str) -> String {
|
||||
format!(
|
||||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0">
|
||||
<crs:{container}><rdf:Seq>
|
||||
<rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="1" crs:LocalExposure2012="0.25">
|
||||
<crs:CorrectionMasks><rdf:Seq>
|
||||
<rdf:li crs:What="Mask/CircularGradient" crs:MaskValue="1" crs:Top="0.25" crs:Left="0.25" crs:Bottom="0.75" crs:Right="0.75" crs:Angle="0" crs:Midpoint="50" crs:Roundness="0" {attrs}/>
|
||||
</rdf:Seq></crs:CorrectionMasks>
|
||||
</rdf:Description></rdf:li>
|
||||
</rdf:Seq></crs:{container}>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn radial_settings(container: &str, attrs: &str) -> DevelopSettings {
|
||||
let d = lightcraft_meta::parse_xmp(&radial_packet(container, attrs)).unwrap();
|
||||
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
|
||||
assert!(unmapped.is_empty(), "{unmapped:?}");
|
||||
let s = DevelopSettings::default().merged(&partial).unwrap();
|
||||
assert_eq!(s.masks.len(), 1, "{:?}", s.masks);
|
||||
s
|
||||
}
|
||||
|
||||
/// (the radial's `invert`, its feather, the component's `invert`).
|
||||
fn radial_of(s: &DevelopSettings) -> (bool, f64, bool) {
|
||||
let c = &s.masks[0].components[0];
|
||||
let MaskShape::Radial { invert, feather, .. } = &c.shape else { panic!("{:?}", c.shape) };
|
||||
(*invert, *feather, c.invert)
|
||||
}
|
||||
|
||||
/// How much brighter the mask makes the centre and a corner of a flat grey frame (8-bit green).
|
||||
fn lift_inside_outside(s: &DevelopSettings) -> (i32, i32) {
|
||||
let img = lightcraft_raster::Image { width: 90, height: 60, data: vec![[0.18f32; 3]; 90 * 60] };
|
||||
let render =
|
||||
|d: &DevelopSettings| lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(90, 60)).image;
|
||||
let (with, without) = (render(s), render(&DevelopSettings { light: s.light, ..Default::default() }));
|
||||
let g = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| i32::from(im.data[y * im.width + x][1]);
|
||||
(g(&with, 45, 30) - g(&without, 45, 30), g(&with, 3, 3) - g(&without, 3, 3))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightroom_radial_flipped_is_inside() {
|
||||
// as Lightroom Classic writes an ordinary radial mask today (#1815)
|
||||
let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="true" crs:Version="2""#);
|
||||
assert_eq!(radial_of(&s), (false, 25.0, false));
|
||||
let (inside, outside) = lift_inside_outside(&s);
|
||||
assert!(inside > 20 && outside == 0, "the effect is inside the circle: {inside} {outside}");
|
||||
// no MaskInverted at all reads the same
|
||||
let s = radial_settings("MaskGroupBasedCorrections", r#"crs:Feather="25" crs:Flipped="true" crs:Version="2""#);
|
||||
assert_eq!(radial_of(&s), (false, 25.0, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightroom_radial_mask_inverted_is_outside() {
|
||||
let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="true" crs:Feather="25" crs:Flipped="true" crs:Version="2""#);
|
||||
assert_eq!(radial_of(&s), (false, 25.0, true), "the inversion is the component's");
|
||||
let (inside, outside) = lift_inside_outside(&s);
|
||||
assert!(inside == 0 && outside > 20, "the effect is outside the circle: {inside} {outside}");
|
||||
// version 2 with Flipped="false": outside, the legacy sense of the flag
|
||||
let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="false" crs:Version="2""#);
|
||||
assert_eq!(radial_of(&s), (true, 25.0, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versionless_group_radial_is_inside_with_half_the_feather() {
|
||||
// Lightroom renders both inside and rewrites them Flipped="true", Feather halved, Version="2"
|
||||
for flipped in ["true", "false"] {
|
||||
let s = radial_settings("MaskGroupBasedCorrections", &format!(r#"crs:MaskInverted="false" crs:Feather="50" crs:Flipped="{flipped}""#));
|
||||
assert_eq!(radial_of(&s), (false, 25.0, false), "Flipped={flipped}");
|
||||
let (inside, outside) = lift_inside_outside(&s);
|
||||
assert!(inside > 20 && outside == 0, "Flipped={flipped}: {inside} {outside}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_radial_filter_is_outside_unless_flipped() {
|
||||
// the process-2012 Radial Filter: effect outside by default, "Invert Mask" = Flipped="true"
|
||||
let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="false""#);
|
||||
assert_eq!(radial_of(&s), (true, 50.0, false));
|
||||
let (inside, outside) = lift_inside_outside(&s);
|
||||
assert!(inside == 0 && outside > 20, "default: outside: {inside} {outside}");
|
||||
assert_eq!(radial_of(&radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50""#)), (true, 50.0, false));
|
||||
let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="true""#);
|
||||
assert_eq!(radial_of(&s), (false, 50.0, false));
|
||||
let (inside, outside) = lift_inside_outside(&s);
|
||||
assert!(inside > 20 && outside == 0, "inverted: inside: {inside} {outside}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lrtemplate_radial_filter_reads_flipped() {
|
||||
// .lrtemplate presets and catalog develop settings are the same Lua tables
|
||||
let t = |flipped: &str| {
|
||||
format!(
|
||||
r#"s = {{ title = "Rad", value = {{ settings = {{ CircularGradientBasedCorrections = {{
|
||||
{{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.25, CorrectionMasks = {{
|
||||
{{ What = "Mask/CircularGradient", Top = 0.25, Left = 0.25, Bottom = 0.75, Right = 0.75, Angle = 0, Feather = 50, Flipped = {flipped}, MaskValue = 1 }} }} }} }} }} }} }}"#
|
||||
)
|
||||
};
|
||||
for (flipped, outside) in [("true", false), ("false", true)] {
|
||||
let v = crate::preset_import::read_presets("Rad.lrtemplate", t(flipped).as_bytes(), None).unwrap();
|
||||
let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap();
|
||||
assert_eq!(radial_of(&s).0, outside, "Flipped = {flipped}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Spot removal in the shape Lightroom Classic writes it (values written for this test):
|
||||
/// a healed circle with an explicit source, a cloned circle with an automatic source, a
|
||||
/// brushed removal, a circular-gradient spot, one without a mask, and the legacy list.
|
||||
|
||||
@@ -30,6 +30,16 @@ pub fn load(s: &mut Session) {
|
||||
creator: "LightCraft Demo".into(),
|
||||
..Default::default()
|
||||
};
|
||||
// one demo photo carries a camera focus point (as a Sony raw would), on the bloom's centre, so
|
||||
// View ▸ Show Focus Point and Zoom to Focus Point can be tried without a camera file
|
||||
if sc.name == "Cosmos bloom" {
|
||||
p.meta.focus = Some(lightcraft_meta::FocusPoint {
|
||||
rect: lightcraft_geom::Rect { x0: 0.42, y0: 0.433, x1: 0.50, y1: 0.487 },
|
||||
mode: Some("AF-S".into()),
|
||||
area: Some("Flexible Spot".into()),
|
||||
tracking: None,
|
||||
});
|
||||
}
|
||||
p.rating = [0, 3, 4, 5, 2, 0, 4, 3][sc.id as usize % 8];
|
||||
p.flag = match sc.id % 6 {
|
||||
1 => Flag::Pick,
|
||||
@@ -409,7 +419,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_scene_is_rendered_once_however_many_ask() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let (renders, peak) = within(60, || {
|
||||
let (renders, peak) = within(120, || {
|
||||
let slot = Arc::new(SceneSlot::default());
|
||||
let (running, peak, renders) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
|
||||
let render = {
|
||||
@@ -454,7 +464,7 @@ mod tests {
|
||||
/// and that pool's limits; other threads render in parallel on the scene pool.
|
||||
#[test]
|
||||
fn a_rayon_worker_renders_alone_on_its_own_thread() {
|
||||
let ((how, caller, renderer), (other_how, other_name)) = within(30, || {
|
||||
let ((how, caller, renderer), (other_how, other_name)) = within(120, || {
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap();
|
||||
let slot = SceneSlot::default();
|
||||
let mut seen = None;
|
||||
@@ -485,7 +495,7 @@ mod tests {
|
||||
/// minutes of CPU time on a small machine).
|
||||
#[test]
|
||||
fn callers_wait_for_the_generation_running() {
|
||||
let (a, b, renders) = within(30, || {
|
||||
let (a, b, renders) = within(120, || {
|
||||
let slot = Arc::new(SceneSlot::default());
|
||||
let renders = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let (started_tx, started) = std::sync::mpsc::channel::<()>();
|
||||
@@ -518,7 +528,7 @@ mod tests {
|
||||
/// generates the scene again.
|
||||
#[test]
|
||||
fn a_failed_generation_is_an_error_and_the_next_request_tries_again() {
|
||||
let (first, waiters, again) = within(30, || {
|
||||
let (first, waiters, again) = within(120, || {
|
||||
let slot = Arc::new(SceneSlot::default());
|
||||
let (started_tx, started) = std::sync::mpsc::channel::<()>();
|
||||
let (go, go_rx) = std::sync::mpsc::channel::<()>();
|
||||
@@ -564,7 +574,7 @@ mod tests {
|
||||
assert!(matches!(st.outcome(n), Some(Err(_))), "even once the retry succeeded");
|
||||
assert!(matches!(st.outcome(m), Some(Ok(_))));
|
||||
// and with threads: a retry racing the waiters of a failed generation never hands them its result
|
||||
let wrong = within(60, || {
|
||||
let wrong = within(120, || {
|
||||
let mut wrong = 0;
|
||||
for _ in 0..20 {
|
||||
let slot = Arc::new(SceneSlot::default());
|
||||
|
||||
@@ -104,6 +104,18 @@ impl DeviceWatch {
|
||||
s.list.clone()
|
||||
}
|
||||
|
||||
/// Like [`DeviceWatch::get`], but `None` until the first scan has landed, so an empty list
|
||||
/// means no cards rather than not looked yet.
|
||||
pub fn get_scanned(self: &Arc<Self>) -> Option<Vec<Device>> {
|
||||
let _ = self.get();
|
||||
let s = self.state();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let scanned = s.scanned.is_some();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let scanned = false;
|
||||
scanned.then(|| s.list.clone())
|
||||
}
|
||||
|
||||
/// Scan now on this thread, and serve the result to later [`DeviceWatch::get`] calls.
|
||||
pub fn scan_now(&self) -> Vec<Device> {
|
||||
let list = (self.scanner)();
|
||||
@@ -148,6 +160,12 @@ pub fn devices() -> Vec<Device> {
|
||||
global().get()
|
||||
}
|
||||
|
||||
/// [`devices`], but `None` until the first scan lands. The import prompt uses it so a card that was
|
||||
/// already in when LightCraft started isn't taken for a new one.
|
||||
pub fn devices_scanned() -> Option<Vec<Device>> {
|
||||
global().get_scanned()
|
||||
}
|
||||
|
||||
/// Scan the mounted volumes now (blocking), for explicit requests.
|
||||
pub fn devices_now() -> Vec<Device> {
|
||||
global().scan_now()
|
||||
@@ -285,4 +303,24 @@ mod tests {
|
||||
assert!(watch.scan_now().is_empty());
|
||||
assert_eq!(changes.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_scanned_waits_for_the_first_scan() {
|
||||
let gate = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let g = gate.clone();
|
||||
let watch = DeviceWatch::new(
|
||||
move || {
|
||||
while !g.load(Ordering::SeqCst) {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
vec![card("NIKON Z 8")]
|
||||
},
|
||||
Duration::from_secs(60),
|
||||
);
|
||||
assert_eq!(watch.get_scanned(), None, "nothing scanned yet");
|
||||
assert_eq!(watch.get_scanned(), None);
|
||||
gate.store(true, Ordering::SeqCst);
|
||||
assert!(until(|| watch.get_scanned().is_some()));
|
||||
assert_eq!(watch.get_scanned(), Some(vec![card("NIKON Z 8")]));
|
||||
}
|
||||
}
|
||||
Loaded 100 of 501 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user