Compare commits
1368 Commits
release-2.
...
lua-api
| Author | SHA1 | Date | |
|---|---|---|---|
| dca1d1c25b | |||
| 0831ee943c | |||
| bd40b4f850 | |||
|
|
5ebadd83ea | ||
|
|
01c3b7d2f6 | ||
|
|
bfee6b12dc | ||
|
|
503a59d590 | ||
|
|
78176e32e8 | ||
|
|
4d8d076d8e | ||
|
|
72f31d6208 | ||
|
|
784a6ea85d | ||
|
|
bbfd9d12e3 | ||
|
|
a55de740e5 | ||
|
|
dc31b221ef | ||
|
|
34b88490c2 | ||
|
|
d9b77bb3a2 | ||
|
|
cc198d6ca1 | ||
|
|
84582cbf29 | ||
|
|
9e7a18e290 | ||
|
|
8ff81fcfa4 | ||
|
|
b649e7a840 | ||
|
|
efa06ee34c | ||
|
|
77959422e0 | ||
|
|
0bff346b05 | ||
|
|
d05c494147 | ||
|
|
9cbbae9621 | ||
|
|
fb2aade198 | ||
|
|
7ffd8bdc27 | ||
|
|
b689dbc7ee | ||
|
|
027135a257 | ||
|
|
643057deaf | ||
|
|
a00f71f533 | ||
|
|
36d6db24e7 | ||
|
|
f62a5a817f | ||
|
|
44b40b305b | ||
|
|
97dade06cf | ||
|
|
30167631e7 | ||
|
|
8235de1f0a | ||
|
|
d67ada8300 | ||
|
|
c6788e4370 | ||
|
|
d238fcee61 | ||
|
|
28d71c989f | ||
|
|
74f020c809 | ||
|
|
80769a753a | ||
|
|
427359769e | ||
|
|
5e5b073cec | ||
|
|
c771c5fc1b | ||
|
|
7cdf1d4c7f | ||
|
|
b6ea6211db | ||
|
|
4d3924939b | ||
|
|
80ff1ef0aa | ||
|
|
af362315e8 | ||
|
|
28f3e9f6a2 | ||
|
|
4f05672809 | ||
|
|
8d8f295c77 | ||
|
|
a81ab437cf | ||
|
|
a94a0c2c90 | ||
|
|
0eda1b6ac5 | ||
|
|
58bc3a69df | ||
|
|
35398c55e5 | ||
|
|
1762b372b1 | ||
|
|
1907f9a1f5 | ||
|
|
b1dd3eca5a | ||
|
|
732d2788ca | ||
|
|
04908e3f0b | ||
|
|
3ba3facad2 | ||
|
|
cb209d1d56 | ||
|
|
36533b1004 | ||
|
|
cbbf0f413f | ||
|
|
181aea5bc2 | ||
|
|
94c6dff245 | ||
|
|
79ac6fc31e | ||
|
|
7a61d77c27 | ||
|
|
2635e261a0 | ||
|
|
d197851478 | ||
|
|
6f519b3ce8 | ||
|
|
03eb79e83a | ||
|
|
fb03b49a93 | ||
|
|
c0865c37bb | ||
|
|
6bbb619513 | ||
|
|
182a2c96c3 | ||
|
|
06522802d8 | ||
|
|
ad263fed11 | ||
|
|
ced60016fb | ||
|
|
1d6d0695fe | ||
|
|
fbe504b47a | ||
|
|
7da38b3665 | ||
|
|
08fe1ba410 | ||
|
|
088c79e140 | ||
|
|
f06eebe316 | ||
|
|
d6a1cec9fa | ||
|
|
e194ad2bc7 | ||
|
|
22e3c59d69 | ||
|
|
eb743e4b3b | ||
|
|
7a8f3afc40 | ||
|
|
2b3910c698 | ||
|
|
c584ee05c9 | ||
|
|
bb3a74feac | ||
|
|
90d2764a5a | ||
|
|
b0373f7166 | ||
|
|
c6002e7eb7 | ||
|
|
a47de82afd | ||
|
|
7f09e55525 | ||
|
|
f83ac1366a | ||
|
|
6cda27172e | ||
|
|
df78522a79 | ||
|
|
6e64d6db6f | ||
|
|
7f00cb8d6c | ||
|
|
dfeff73fcd | ||
|
|
d75f00daa0 | ||
|
|
3e430ee2be | ||
|
|
3856ae3449 | ||
|
|
d54a9f8b7e | ||
|
|
8f6cc850fb | ||
|
|
e9f18a3d36 | ||
|
|
f3a89a89bd | ||
|
|
d075fb7128 | ||
|
|
7f4bae4234 | ||
|
|
071a3527c1 | ||
|
|
22c9a7cd6e | ||
|
|
12ccefcc3b | ||
|
|
fa6014fefa | ||
|
|
e831f377bf | ||
|
|
d696e6e3f2 | ||
|
|
db505f1e0a | ||
|
|
57d15a3fa6 | ||
|
|
3c131c41d1 | ||
|
|
4d54b5eaba | ||
|
|
ffa125b134 | ||
|
|
dbf0c5d853 | ||
|
|
193a615772 | ||
|
|
056f616283 | ||
|
|
447980ddc4 | ||
|
|
8af5476f8d | ||
|
|
c95547caa7 | ||
|
|
b4d3b2901f | ||
|
|
cb6b3161d8 | ||
|
|
2d82eea237 | ||
|
|
3beee93af4 | ||
|
|
68291414f3 | ||
|
|
5662519314 | ||
|
|
28ab065293 | ||
|
|
aed3275943 | ||
|
|
d7aeb43340 | ||
|
|
ea79d10b11 | ||
|
|
e4001f984f | ||
|
|
453d744b69 | ||
|
|
b9cc0dccce | ||
|
|
84fea20ee5 | ||
|
|
e054bfc59d | ||
|
|
8cd10dce68 | ||
|
|
f8d49ccd01 | ||
|
|
25afb41924 | ||
|
|
3001a0dc1a | ||
|
|
62e932990e | ||
|
|
980fe3962c | ||
|
|
0043769946 | ||
|
|
0cb9a8872e | ||
|
|
5309ff1726 | ||
|
|
c6eee15d75 | ||
|
|
8c2d0ddf2e | ||
|
|
87d18549a6 | ||
|
|
6fdd78114f | ||
|
|
f20721373a | ||
|
|
9c0f8d63e6 | ||
|
|
ea2596ae13 | ||
|
|
a9191cf084 | ||
|
|
59b5039579 | ||
|
|
e070a1462c | ||
|
|
faa3302a37 | ||
|
|
115dbbec32 | ||
|
|
9bf7efe71f | ||
|
|
3ec9dc4963 | ||
|
|
cddb86ded0 | ||
|
|
2624338100 | ||
|
|
067fe29f76 | ||
|
|
926272b1cd | ||
|
|
363524fbaf | ||
|
|
edeceb268e | ||
|
|
252cbc96f2 | ||
|
|
b70fdc0ecf | ||
|
|
96457bc598 | ||
|
|
cd31d18075 | ||
|
|
4e140f10d1 | ||
|
|
593f8049a0 | ||
|
|
89973b3f9a | ||
|
|
deca366ce4 | ||
|
|
914a17fb4e | ||
|
|
1e33ea4192 | ||
|
|
9f3fb16161 | ||
|
|
3f064783b7 | ||
|
|
0ab4b0cd6d | ||
|
|
e2f5c993b8 | ||
|
|
3def8b43d9 | ||
|
|
7b2942ca29 | ||
|
|
bfed38f1db | ||
|
|
6d16a8778b | ||
|
|
3c67c0c693 | ||
|
|
1ba488d073 | ||
|
|
3bdbfd12bf | ||
|
|
77c405882d | ||
|
|
ecc3fb29f5 | ||
|
|
a09140c03c | ||
|
|
95e2e4924e | ||
|
|
72ce0b0f93 | ||
|
|
e4da226661 | ||
|
|
a3404b64de | ||
|
|
13ae486ab3 | ||
|
|
268aa7df42 | ||
|
|
ea7ac8e474 | ||
|
|
2a4452cd20 | ||
|
|
749db97dd5 | ||
|
|
84a1c1ddfe | ||
|
|
3791b47914 | ||
|
|
f6a1d1c20b | ||
|
|
118bbad5cb | ||
|
|
44e442e7eb | ||
|
|
08726427aa | ||
|
|
39f63cc8c5 | ||
|
|
99c3548950 | ||
|
|
7e8340c526 | ||
|
|
2e95c4837b | ||
|
|
fe6d262d9b | ||
|
|
794b12305c | ||
|
|
1a3a7f17ff | ||
|
|
496f8e704d | ||
|
|
28f87566e1 | ||
|
|
865d29fb40 | ||
|
|
1986f4e148 | ||
|
|
b963eaa38c | ||
|
|
3b520d6291 | ||
|
|
87ca8bde27 | ||
|
|
d27df0c0ed | ||
|
|
9bceb79a15 | ||
|
|
ed9f2bb0d0 | ||
|
|
b5c93e55d1 | ||
|
|
9bfda93b51 | ||
|
|
daa0877b4c | ||
|
|
da275c2a66 | ||
|
|
3fa913d68a | ||
|
|
e4e194b0ec | ||
|
|
c70ebf4297 | ||
|
|
7e3c6471ff | ||
|
|
a39f6feba7 | ||
|
|
ee7e92019a | ||
|
|
96b7ba24bc | ||
|
|
8581af29d2 | ||
|
|
ab532a724c | ||
|
|
b1e6bb8daa | ||
|
|
26e797d5e5 | ||
|
|
3941c7ab52 | ||
|
|
b7c8759af3 | ||
|
|
c5711880d3 | ||
|
|
e7dbd6e369 | ||
|
|
1b5cb1ae6e | ||
|
|
2953cf7e91 | ||
|
|
5e4ba9da5e | ||
|
|
3a1f6bbc7a | ||
|
|
21ec2883fe | ||
|
|
a0af01884d | ||
|
|
8e129d3825 | ||
|
|
d123634679 | ||
|
|
13f210bec9 | ||
|
|
8e4767c42a | ||
|
|
300bb9d443 | ||
|
|
4f8df88fec | ||
|
|
44da339202 | ||
|
|
0e37a287ca | ||
|
|
2af2530e45 | ||
|
|
99eb595f4c | ||
|
|
31cdd3f24a | ||
|
|
631d89e581 | ||
|
|
526e97a4e1 | ||
|
|
a835ab89bc | ||
|
|
da7674ca51 | ||
|
|
0e535b850c | ||
|
|
64840089e9 | ||
|
|
d754e37cf4 | ||
|
|
f6cb8091e7 | ||
|
|
e359567620 | ||
|
|
36a6a54364 | ||
|
|
ecff920537 | ||
|
|
842f2fca66 | ||
|
|
ee9384b9a7 | ||
|
|
272c35499a | ||
|
|
4165d46b33 | ||
|
|
5bed7c914c | ||
|
|
684178b27e | ||
|
|
a72abe0ad0 | ||
|
|
8863f6ade7 | ||
|
|
e0463eba83 | ||
|
|
3a2ff7a7b9 | ||
|
|
ce7ddfa472 | ||
|
|
3fb00586c3 | ||
|
|
167620fadf | ||
|
|
f0bdc890c1 | ||
|
|
abdfc9de0b | ||
|
|
c65e070a76 | ||
|
|
05c57c0247 | ||
|
|
5be3d6986e | ||
|
|
f0e3627800 | ||
|
|
ea08557a0e | ||
|
|
ac43afd8c9 | ||
|
|
ee98f96a4f | ||
|
|
a33eeddbd6 | ||
|
|
746c6a07c4 | ||
|
|
96fee48ef2 | ||
|
|
6c686128d2 | ||
|
|
6ca8f0034e | ||
|
|
3d1f12fe44 | ||
|
|
a079860cd8 | ||
|
|
b07c680332 | ||
|
|
200fc059c0 | ||
|
|
aeb1f2226c | ||
|
|
46e729b40e | ||
|
|
66b613dc29 | ||
|
|
535ba78a06 | ||
|
|
cc11c1c3b8 | ||
|
|
fba639e98a | ||
|
|
3e37c06b84 | ||
|
|
1b272be5f5 | ||
|
|
9f423eeef3 | ||
|
|
dc81c0b1c5 | ||
|
|
e8a99c8396 | ||
|
|
7c93868bf0 | ||
|
|
36023d8d4f | ||
|
|
97aa4ce363 | ||
|
|
316a89d6a8 | ||
|
|
d4a479b025 | ||
|
|
dd22c947c6 | ||
|
|
09f47b7b09 | ||
|
|
ba94914c0e | ||
|
|
3bb4e9c59a | ||
|
|
f8c076f308 | ||
|
|
94f41db6fc | ||
|
|
5727b2d251 | ||
|
|
c95f8a23b2 | ||
|
|
88e4ac1a64 | ||
|
|
a3fe02bf95 | ||
|
|
ff20098175 | ||
|
|
cdf4b743dd | ||
|
|
3efc1805ca | ||
|
|
0b46cf8abf | ||
|
|
28622e620e | ||
|
|
f21a117d29 | ||
|
|
35cbff03dd | ||
|
|
5571e2f2dc | ||
|
|
3c77f2e863 | ||
|
|
4ff1111341 | ||
|
|
abfa8e9878 | ||
|
|
bbe9109fca | ||
|
|
34d5df65b2 | ||
|
|
539f25517b | ||
|
|
5b55a3116d | ||
|
|
3e032616a0 | ||
|
|
9f2dbef4f0 | ||
|
|
2abb83b485 | ||
|
|
32e966f8d9 | ||
|
|
67538a4de2 | ||
|
|
96471ae7e7 | ||
|
|
038418b89f | ||
|
|
fe48d7498c | ||
|
|
2f330ea9cc | ||
|
|
81b0f1fb48 | ||
|
|
9088bc9144 | ||
|
|
c3ce8d532d | ||
|
|
f1d8ad7fce | ||
|
|
a3b295112f | ||
|
|
c36618f5fb | ||
|
|
fecbb39347 | ||
|
|
cbe2c5329a | ||
|
|
dcbe575e9c | ||
|
|
417c165d16 | ||
|
|
232baab86b | ||
|
|
3ed4219e6c | ||
|
|
97e33672ea | ||
|
|
7b2018412a | ||
|
|
3e2afffed8 | ||
|
|
1399e8a4b4 | ||
|
|
5c3b2777f0 | ||
|
|
bf138abeb2 | ||
|
|
284c0f9714 | ||
|
|
e41029d3f4 | ||
|
|
55e68f8cd0 | ||
|
|
ac945b9843 | ||
|
|
f6916c3a15 | ||
|
|
cd3788f012 | ||
|
|
ebd2cd7bf7 | ||
|
|
52b4508e08 | ||
|
|
b1b3392b92 | ||
|
|
19fdbc14c5 | ||
|
|
63d5aad465 | ||
|
|
9d6ff85893 | ||
|
|
125af63bda | ||
|
|
ddf0c89402 | ||
|
|
3b0ff3475a | ||
|
|
401509cfe4 | ||
|
|
9c4e906033 | ||
|
|
65c22e475a | ||
|
|
a11726889d | ||
|
|
e78e718769 | ||
|
|
86ee8adbce | ||
|
|
40949b3855 | ||
|
|
06ab263e74 | ||
|
|
bccc86c70a | ||
|
|
231e9b352b | ||
|
|
e47ab34745 | ||
|
|
1fd2ca20ab | ||
|
|
05a73d8810 | ||
|
|
12640c7d29 | ||
|
|
febc3b1659 | ||
|
|
9fdd69556c | ||
|
|
4735be470a | ||
|
|
66b33d6f48 | ||
|
|
37dba718a2 | ||
|
|
c11845cbf9 | ||
|
|
f9fe33f930 | ||
|
|
3b8e802e42 | ||
|
|
f54d449316 | ||
|
|
1b0fe4d7b9 | ||
|
|
cf1dca9e1a | ||
|
|
45d54ef4e8 | ||
|
|
8bfab0325b | ||
|
|
4b99d00ab5 | ||
|
|
a5961eb066 | ||
|
|
95e19ef163 | ||
|
|
d4eb6577bf | ||
|
|
294b693905 | ||
|
|
23835edc1f | ||
|
|
5f29211f79 | ||
|
|
025f4299ea | ||
|
|
dc50d40886 | ||
|
|
867c49b1b9 | ||
|
|
13f02c95ea | ||
|
|
d55dae73f0 | ||
|
|
34b9831472 | ||
|
|
014a0fc1c9 | ||
|
|
bf17dccd69 | ||
|
|
890168d0d0 | ||
|
|
191aecacdf | ||
|
|
44ec01b136 | ||
|
|
a177da2040 | ||
|
|
c899040688 | ||
|
|
e8af665b3c | ||
|
|
875734d455 | ||
|
|
14d5a79db2 | ||
|
|
e1b2715ee9 | ||
|
|
7ecd5ca55d | ||
|
|
931e7a4d37 | ||
|
|
1792410efe | ||
|
|
8cbd9224f1 | ||
|
|
24b552d619 | ||
|
|
68ae9326bf | ||
|
|
b4626f2787 | ||
|
|
2fc8ed78e4 | ||
|
|
236b25a235 | ||
|
|
a4f6af73ec | ||
|
|
fad74d6438 | ||
|
|
3e2d4c956d | ||
|
|
cd1d623390 | ||
|
|
9e0374d597 | ||
|
|
e47672b495 | ||
|
|
1905be8577 | ||
|
|
4726821e7d | ||
|
|
7ac37ddc1a | ||
|
|
ad10542b30 | ||
|
|
5331a38f10 | ||
|
|
c9943a2065 | ||
|
|
3889742e06 | ||
|
|
6bf3706455 | ||
|
|
612ecaff6c | ||
|
|
a85364ecff | ||
|
|
c0af03c503 | ||
|
|
9b61e4793b | ||
|
|
75a19e74a0 | ||
|
|
21d7e72b0c | ||
|
|
05d555eae5 | ||
|
|
8e412c3eb3 | ||
|
|
80147a9ebc | ||
|
|
3f6a3e25f1 | ||
|
|
800cbeef9d | ||
|
|
574f6a976f | ||
|
|
030b360e57 | ||
|
|
b883714192 | ||
|
|
d556f48168 | ||
|
|
cd7cac0eec | ||
|
|
9b31f18470 | ||
|
|
42a194b3d7 | ||
|
|
6748a7125e | ||
|
|
c2b3f29f3b | ||
|
|
572a634d90 | ||
|
|
652606ffb0 | ||
|
|
803537c63e | ||
|
|
4648c2d9af | ||
|
|
edceb3e7e9 | ||
|
|
da6bddcd77 | ||
|
|
b11f43e903 | ||
|
|
ef218a4d2e | ||
|
|
b061f27e7a | ||
|
|
8e64d54ab1 | ||
|
|
56b40a80ac | ||
|
|
d359d8ecb4 | ||
|
|
2bc55d00bb | ||
|
|
d3cf564b71 | ||
|
|
4fb8b97084 | ||
|
|
32c5cceb1d | ||
|
|
f38c14d6b5 | ||
|
|
3070ca0bc4 | ||
|
|
0122b3f717 | ||
|
|
79678fd82f | ||
|
|
034ddfbafb | ||
|
|
aebd26d03a | ||
|
|
d609823491 | ||
|
|
8f414f783a | ||
|
|
2e521553bf | ||
|
|
e93f5fc812 | ||
|
|
3dbbb7dfd5 | ||
|
|
fbb92e1023 | ||
|
|
ea4c6373f9 | ||
|
|
40b2569a21 | ||
|
|
ef6e4d8f0b | ||
|
|
28ae2ea501 | ||
|
|
657407c7a0 | ||
|
|
b254577bc8 | ||
|
|
63763bedc8 | ||
|
|
3d50d0fdb4 | ||
|
|
1ca6b18f5e | ||
|
|
b82ffc2af3 | ||
|
|
d142151055 | ||
|
|
91b78529fe | ||
|
|
7aec6884f4 | ||
|
|
7f140828bb | ||
|
|
4f13a56814 | ||
|
|
27d3ea253d | ||
|
|
5209b9899d | ||
|
|
6a40c836c0 | ||
|
|
c4df81bdef | ||
|
|
e99827d237 | ||
|
|
a0cbe8efd5 | ||
|
|
b4ee97a768 | ||
|
|
4bdaaaf1ce | ||
|
|
3cf0fea092 | ||
|
|
97e1451dc6 | ||
|
|
4cbb7724f2 | ||
|
|
8f6c2c82d3 | ||
|
|
a6d5001e84 | ||
|
|
6af180a313 | ||
|
|
86cc22d943 | ||
|
|
c1fbfd80d4 | ||
|
|
040a83fb17 | ||
|
|
2bc23a1446 | ||
|
|
97f4c911ed | ||
|
|
53d9a42a1a | ||
|
|
c96c5234c4 | ||
|
|
9a3eb7bc3e | ||
|
|
53f15ba8b8 | ||
|
|
ecdc74b5a6 | ||
|
|
422fdbe2cf | ||
|
|
887bc03f32 | ||
|
|
e32c0c70a8 | ||
|
|
440dfcadfd | ||
|
|
93cc0adb39 | ||
|
|
f8ec85ddae | ||
|
|
688333f756 | ||
|
|
8f314059e4 | ||
|
|
9386a08af0 | ||
|
|
19cd118f8f | ||
|
|
c196f98bc6 | ||
|
|
56ec258cc7 | ||
|
|
5da64a7da0 | ||
|
|
f272aeb9fe | ||
|
|
cf26b82d87 | ||
|
|
2041fb34c9 | ||
|
|
4b63240ab9 | ||
|
|
9d59479049 | ||
|
|
350cdfa436 | ||
|
|
15de64dcf5 | ||
|
|
d9c2519a83 | ||
|
|
9885e34f82 | ||
|
|
ed0adfbae1 | ||
|
|
beafb6c21b | ||
|
|
7810791991 | ||
|
|
1d10d5234f | ||
|
|
9900f46be4 | ||
|
|
0d2c4dfb3a | ||
|
|
752e27d97f | ||
|
|
33523fbe36 | ||
|
|
db47dd5110 | ||
|
|
a71097a8c9 | ||
|
|
931e448ebd | ||
|
|
abd8bf4a60 | ||
|
|
aee1f82d04 | ||
|
|
8112472df5 | ||
|
|
c9fa13b97b | ||
|
|
73cd301ff4 | ||
|
|
aa33ae0706 | ||
|
|
4e9c45c162 | ||
|
|
f35cea75d3 | ||
|
|
4053ceb9e5 | ||
|
|
448da35327 | ||
|
|
6d9c4c90ba | ||
|
|
5b8282e933 | ||
|
|
b1498aa664 | ||
|
|
2b28f021ee | ||
|
|
dc4938c377 | ||
|
|
69b846a856 | ||
|
|
1460396d4f | ||
|
|
b9804d9d48 | ||
|
|
ade7008127 | ||
|
|
da0235ab27 | ||
|
|
c17e832572 | ||
|
|
c12d6f01e7 | ||
|
|
778ad2ce20 | ||
|
|
33ef58ac97 | ||
|
|
8091ba9398 | ||
|
|
8e0bf9e991 | ||
|
|
daee82aa6a | ||
|
|
359a1fa3a9 | ||
|
|
021b32c1d4 | ||
|
|
1d1eb6a768 | ||
|
|
a9fd0718b0 | ||
|
|
6341a0a459 | ||
|
|
952b8b2eb2 | ||
|
|
b08d7f27d2 | ||
|
|
a71889021f | ||
|
|
367631af99 | ||
|
|
62753d1a50 | ||
|
|
dfe589e843 | ||
|
|
d09574bb17 | ||
|
|
2be12e5b37 | ||
|
|
d2086b6480 | ||
|
|
132668d04c | ||
|
|
0a4aa95965 | ||
|
|
af1e774d1f | ||
|
|
e5271d79a0 | ||
|
|
dfd714540e | ||
|
|
a0fd22be19 | ||
|
|
c0dc55b18e | ||
|
|
258def1cf8 | ||
|
|
b2095fd947 | ||
|
|
e51278f76e | ||
|
|
9af60063d8 | ||
|
|
37b523f989 | ||
|
|
96ba075e86 | ||
|
|
e86a3a3236 | ||
|
|
622a9ebab4 | ||
|
|
b26ec58f9f | ||
|
|
b41b2a1566 | ||
|
|
b893383353 | ||
|
|
ddcf3ed3f4 | ||
|
|
da05748393 | ||
|
|
976ad4ab17 | ||
|
|
8dcf19834c | ||
|
|
43315bb9cc | ||
|
|
4ab20d8088 | ||
|
|
485de32bb4 | ||
|
|
536b9726b2 | ||
|
|
41ae43e741 | ||
|
|
b5b49245fa | ||
|
|
d172ac7049 | ||
|
|
8808306a5f | ||
|
|
00dac577f6 | ||
|
|
85c218b350 | ||
|
|
6f71ef4d6b | ||
|
|
394cbdb454 | ||
|
|
62e2a7969d | ||
|
|
d9c284b54d | ||
|
|
bd2bc3659c | ||
|
|
f76fa01240 | ||
|
|
45d62f418c | ||
|
|
650325257b | ||
|
|
a0a8a61d56 | ||
|
|
998732287b | ||
|
|
509c7da384 | ||
|
|
771e03ce76 | ||
|
|
7d9b8dbdda | ||
|
|
912ae93d10 | ||
|
|
b613cc266a | ||
|
|
dfb8d4e7c6 | ||
|
|
b8e3ec4302 | ||
|
|
d4e21b7877 | ||
|
|
d10cf968f1 | ||
|
|
880ec7fc58 | ||
|
|
432b6b5dd7 | ||
|
|
5f5ad426bf | ||
|
|
52acdb9036 | ||
|
|
138b23674a | ||
|
|
8a710798f7 | ||
|
|
be5557080b | ||
|
|
994b9c6472 | ||
|
|
d05536bed4 | ||
|
|
cc4f1d7251 | ||
|
|
2ddd0965cd | ||
|
|
4e68e420cc | ||
|
|
50be8a9240 | ||
|
|
d1e330eee0 | ||
|
|
6aa5893b5f | ||
|
|
797d668ebb | ||
|
|
b23595ff54 | ||
|
|
7790c9700b | ||
|
|
6903f8385b | ||
|
|
e8205c6435 | ||
|
|
1d5192a39c | ||
|
|
04525e1258 | ||
|
|
c114856699 | ||
|
|
57a9a8495c | ||
|
|
53bb95e6fd | ||
|
|
a40fe3099d | ||
|
|
8d38ea0cf2 | ||
|
|
ac81c95f7e | ||
|
|
48b915b0b2 | ||
|
|
593917db9a | ||
|
|
2d49b49bec | ||
|
|
323c8b6845 | ||
|
|
e5ab30006b | ||
|
|
14b97cdbd9 | ||
|
|
8f0f0a5298 | ||
|
|
497dbc8eaa | ||
|
|
11baf14280 | ||
|
|
948e8690dd | ||
|
|
802c17d590 | ||
|
|
71e72b688f | ||
|
|
877b56ad67 | ||
|
|
87502fb17f | ||
|
|
ed3e141601 | ||
|
|
f66d3ed3aa | ||
|
|
5488e45c78 | ||
|
|
72bd66d3f5 | ||
|
|
1378441dfa | ||
|
|
26d97b8cdf | ||
|
|
2e27b812d6 | ||
|
|
fd97ac18da | ||
|
|
b9d14a2711 | ||
|
|
1c9042c496 | ||
|
|
60fa4b9ee0 | ||
|
|
4c536b41bc | ||
|
|
33f91af1af | ||
|
|
05d86e89da | ||
|
|
a02176fc63 | ||
|
|
55ced3ecc7 | ||
|
|
4a81b560f9 | ||
|
|
6a7cd31ab4 | ||
|
|
2515db31d8 | ||
|
|
54a66f9ab2 | ||
|
|
0856858e73 | ||
|
|
23131a301b | ||
|
|
aef7363bee | ||
|
|
d579b257a8 | ||
|
|
4455347930 | ||
|
|
5709cfebd4 | ||
|
|
01184bee72 | ||
|
|
8a8f6338c5 | ||
|
|
71207e8f57 | ||
|
|
749f5b8e8f | ||
|
|
88c1be09c2 | ||
|
|
c1b59256be | ||
|
|
a3f57665a9 | ||
|
|
b8d29ca831 | ||
|
|
90880ff4d1 | ||
|
|
286ef77663 | ||
|
|
0bb8b72183 | ||
|
|
28b9073010 | ||
|
|
fe97e29654 | ||
|
|
792e782e8c | ||
|
|
33b99a97e4 | ||
|
|
91ab85c825 | ||
|
|
8c75b9316d | ||
|
|
9870ddbe86 | ||
|
|
c7eb101920 | ||
|
|
2b171aef7c | ||
|
|
ec0fe0b047 | ||
|
|
bbfa81f051 | ||
|
|
bd5bef279c | ||
|
|
afa95d5d34 | ||
|
|
fb331710e1 | ||
|
|
224dce1b76 | ||
|
|
a582f0229f | ||
|
|
a8e5a9b453 | ||
|
|
2d69536489 | ||
|
|
ebd33c4c16 | ||
|
|
3f5cf06bd9 | ||
|
|
1ec9cfb772 | ||
|
|
050175802f | ||
|
|
19a1be1f74 | ||
|
|
093593dcef | ||
|
|
51f28a386b | ||
|
|
4065bd5e11 | ||
|
|
3e1746fc6e | ||
|
|
3b6dd856aa | ||
|
|
635ea3f231 | ||
|
|
48d1c62fed | ||
|
|
e97f2f3fc6 | ||
|
|
19b016a3dd | ||
|
|
576e160372 | ||
|
|
e586e0479d | ||
|
|
0b5c866df6 | ||
|
|
9828fb3934 | ||
|
|
430f443916 | ||
|
|
d32e0eb5ab | ||
|
|
f72750576f | ||
|
|
e96b78b175 | ||
|
|
4708110804 | ||
|
|
2725b82543 | ||
|
|
1eafae6c6f | ||
|
|
11e25a21fa | ||
|
|
51eda4e755 | ||
|
|
847c39749d | ||
|
|
cb0975fa69 | ||
|
|
5b977733b3 | ||
|
|
c3d13a9890 | ||
|
|
bc4c04f795 | ||
|
|
32d5240e5d | ||
|
|
64f423f3d1 | ||
|
|
18d0e974e4 | ||
|
|
c259f2dc72 | ||
|
|
5223fdf4ae | ||
|
|
a4132bd9bc | ||
|
|
bf8d147ca5 | ||
|
|
5116d645db | ||
|
|
a2850cac6b | ||
|
|
ffed4efdec | ||
|
|
cfa71927de | ||
|
|
5d7a60206e | ||
|
|
3556fef5ed | ||
|
|
252965a5d9 | ||
|
|
a7a25c0798 | ||
|
|
944087649f | ||
|
|
69c9db6dda | ||
|
|
f5e86a8d25 | ||
|
|
6a89357b54 | ||
|
|
64c8f8fc3e | ||
|
|
54ff56b021 | ||
|
|
3d3ad89fba | ||
|
|
7ee140c8c8 | ||
|
|
8afd6ccee4 | ||
|
|
e427294ec1 | ||
|
|
18283ccbc6 | ||
|
|
b7857c8462 | ||
|
|
2d231806d2 | ||
|
|
36b58333ca | ||
|
|
fc77af598d | ||
|
|
602295ae89 | ||
|
|
1c295a88ac | ||
|
|
232535faf9 | ||
|
|
10b5dee40f | ||
|
|
be48459092 | ||
|
|
cb6199c1a8 | ||
|
|
c9eb087ade | ||
|
|
87e223a854 | ||
|
|
7c7118dfad | ||
|
|
fdcd6c6dfe | ||
|
|
205cbcf05d | ||
|
|
c9ccbb2750 | ||
|
|
eacfc4b623 | ||
|
|
d3dafd4ce1 | ||
|
|
8004b96c41 | ||
|
|
7cc58d9fac | ||
|
|
f82ae6c091 | ||
|
|
6ab029e692 | ||
|
|
2c3536afc9 | ||
|
|
0fe95e91d5 | ||
|
|
cadd98dc59 | ||
|
|
92f99c3780 | ||
|
|
f1d397f908 | ||
|
|
eeaa6b478b | ||
|
|
53d6b54de8 | ||
|
|
ffd6abbe95 | ||
|
|
f08421a48c | ||
|
|
a383d1ebf8 | ||
|
|
d355095e16 | ||
|
|
a81d992552 | ||
|
|
eb13518b96 | ||
|
|
bf6e180291 | ||
|
|
fcad7a5733 | ||
|
|
7ffdf1631c | ||
|
|
a5ad5e67f3 | ||
|
|
4b3b1f7786 | ||
|
|
844e89a652 | ||
|
|
d88af9ffd9 | ||
|
|
7a3a9ba559 | ||
|
|
5d3910da49 | ||
|
|
f740d34f1f | ||
|
|
58ca70c511 | ||
|
|
ee59540648 | ||
|
|
9e97b8f5a1 | ||
|
|
95968c3d95 | ||
|
|
85c242ff0e | ||
|
|
18783e7ea5 | ||
|
|
7bc4846c0a | ||
|
|
49c4406bb4 | ||
|
|
fedf2229a7 | ||
|
|
2e5e2e9f0d | ||
|
|
fa25ec4a26 | ||
|
|
0ef9f5fc6c | ||
|
|
3df9040cfb | ||
|
|
bd3a74954c | ||
|
|
69b9a1fa5c | ||
|
|
287d9e94cf | ||
|
|
24862a26f2 | ||
|
|
a319428489 | ||
|
|
11881b3b73 | ||
|
|
5a6ee174af | ||
|
|
9183beeed4 | ||
|
|
e5537fdff0 | ||
|
|
9e0490d371 | ||
|
|
2b2a09e36d | ||
|
|
5ff36fe2ec | ||
|
|
9440085169 | ||
|
|
5c24077a30 | ||
|
|
68a8eabbbb | ||
|
|
a038333cce | ||
|
|
a5304fe74c | ||
|
|
aaf564b35c | ||
|
|
de087f743f | ||
|
|
21a8e441d2 | ||
|
|
32fc4556b1 | ||
|
|
238856ed89 | ||
|
|
8f6a236eb6 | ||
|
|
d9ce826b9d | ||
|
|
76cc1d47f6 | ||
|
|
a3d0983d33 | ||
|
|
245a7b59c4 | ||
|
|
8b714edb67 | ||
|
|
2a3d532881 | ||
|
|
5555ee278e | ||
|
|
451c7fb956 | ||
|
|
391b3b9e4f | ||
|
|
130a8edcef | ||
|
|
5fd3a56a31 | ||
|
|
b682284a2a | ||
|
|
d1676ac2e0 | ||
|
|
2cb3397d75 | ||
|
|
936f4fe254 | ||
|
|
f611213662 | ||
|
|
ff0352cfe6 | ||
|
|
4d6dfea3a5 | ||
|
|
be386e1172 | ||
|
|
2a2fbd18cd | ||
|
|
56159e91ce | ||
|
|
3675f4d7cd | ||
|
|
3a129d3c4f | ||
|
|
cc4e7197ba | ||
|
|
fc5677e7b5 | ||
|
|
19542c1640 | ||
|
|
68b5acc154 | ||
|
|
5c2c9e2a8f | ||
|
|
0376e11544 | ||
|
|
6d910ffb60 | ||
|
|
32f6310947 | ||
|
|
f464a52ea4 | ||
|
|
e3d755fb9a | ||
|
|
e05e05d866 | ||
|
|
b7ff1cd579 | ||
|
|
0550f4e28a | ||
|
|
3456dc90d8 | ||
|
|
8e006cf853 | ||
|
|
0e36af38d8 | ||
|
|
e10f3e2285 | ||
|
|
31f1614679 | ||
|
|
80e7eca064 | ||
|
|
adb85e349a | ||
|
|
52a241c1ec | ||
|
|
d092016729 | ||
|
|
11607e7e7d | ||
|
|
2d01f801bc | ||
|
|
6e15373ce9 | ||
|
|
e95ed5ad2b | ||
|
|
dc9324f322 | ||
|
|
e7adf8cfb2 | ||
|
|
5f7c49ca85 | ||
|
|
f724335b91 | ||
|
|
3a5dfbc842 | ||
|
|
380a1b66e6 | ||
|
|
c6cccf2d93 | ||
|
|
0858a19138 | ||
|
|
f42199aec0 | ||
|
|
8d746427a6 | ||
|
|
3c9b2b90d4 | ||
|
|
abc959a814 | ||
|
|
c71c289547 | ||
|
|
02bb942abd | ||
|
|
a37c33ec36 | ||
|
|
0e4926426f | ||
|
|
a309db8739 | ||
|
|
1a998581ef | ||
|
|
f49b951331 | ||
|
|
26e16ffeda | ||
|
|
9e4a72e098 | ||
|
|
a7d053648a | ||
|
|
61a55f71f5 | ||
|
|
9979d4abda | ||
|
|
5a2af47eda | ||
|
|
eab6b99155 | ||
|
|
4acba4d48a | ||
|
|
8b5a43e435 | ||
|
|
26fce03d0f | ||
|
|
1a22bfbcef | ||
|
|
050783fc8b | ||
|
|
d96d8fb4fc | ||
|
|
4b32c34ebd | ||
|
|
927bf12243 | ||
|
|
a867791165 | ||
|
|
67232094c9 | ||
|
|
b5e79abf08 | ||
|
|
84c0fd7414 | ||
|
|
850a7d8676 | ||
|
|
ded24e8037 | ||
|
|
71fbf5705c | ||
|
|
87b78717f3 | ||
|
|
1cd9a34761 | ||
|
|
b9072cccba | ||
|
|
18fb3a68fd | ||
|
|
c0b89e51d7 | ||
|
|
f2ac2595f9 | ||
|
|
b241007d4f | ||
|
|
2b1af40bd1 | ||
|
|
66f85a1f35 | ||
|
|
22633d6130 | ||
|
|
8ed4d838ee | ||
|
|
9175916771 | ||
|
|
7acf2b9cac | ||
|
|
965d8791d8 | ||
|
|
f961e6aa6b | ||
|
|
b8182cfde5 | ||
|
|
78f0e367dc | ||
|
|
aca0224263 | ||
|
|
c74636e2c0 | ||
|
|
f113bc5b8a | ||
|
|
befae8b191 | ||
|
|
1767e8f074 | ||
|
|
44081b772f | ||
|
|
3051eddd2e | ||
|
|
5266aa7547 | ||
|
|
7e57f61947 | ||
|
|
1c587d3726 | ||
|
|
b4ea97ce37 | ||
|
|
83c1a5789a | ||
|
|
841026661a | ||
|
|
692441e1d5 | ||
|
|
46ba555a20 | ||
|
|
de18432a7d | ||
|
|
497b6cb59c | ||
|
|
c073ecf0dd | ||
|
|
2ea4db8e38 | ||
|
|
fe942100a4 | ||
|
|
79eb1428b0 | ||
|
|
467365dcbb | ||
|
|
33a323fe85 | ||
|
|
4ad9e77a7e | ||
|
|
5d57347730 | ||
|
|
54572de8d9 | ||
|
|
0e57a0804a | ||
|
|
95077f639b | ||
|
|
493dcc7ef7 | ||
|
|
f86b16735b | ||
|
|
0c1de20a1a | ||
|
|
e9d0f706ed | ||
|
|
d297718358 | ||
|
|
b406b36cda | ||
|
|
570732a9b2 | ||
|
|
92b5507e0e | ||
|
|
73897b8ee7 | ||
|
|
2d9cdc5a24 | ||
|
|
234e5bc5fc | ||
|
|
664496b430 | ||
|
|
67d92bd070 | ||
|
|
d6e53cc005 | ||
|
|
2c77712ab4 | ||
|
|
10c1eff1b6 | ||
|
|
4c26465dde | ||
|
|
d29e5f49f4 | ||
|
|
0191558d26 | ||
|
|
d3692bcc09 | ||
|
|
befa83bee8 | ||
|
|
b4347e5701 | ||
|
|
c4ed6c2b97 | ||
|
|
6b5f650aac | ||
|
|
63b38c1f53 | ||
|
|
33b6f0d72e | ||
|
|
a6f8070f65 | ||
|
|
29aaaf55cf | ||
|
|
b47da137c1 | ||
|
|
8e6552ee9c | ||
|
|
024e016df2 | ||
|
|
97dd8a1257 | ||
|
|
83fc339c4b | ||
|
|
693c535428 | ||
|
|
d74f2eba92 | ||
|
|
d0ec941009 | ||
|
|
178528508b | ||
|
|
20a31b39e0 | ||
|
|
fc252d5e7e | ||
|
|
4090266989 | ||
|
|
19b7d29483 | ||
|
|
af4e3884bb | ||
|
|
d3742bb410 | ||
|
|
75cb1a5fdd | ||
|
|
8436c878e8 | ||
|
|
63d3a34f46 | ||
|
|
6cda97c335 | ||
|
|
a40d8b8bc5 | ||
|
|
15a492cfa3 | ||
|
|
445e882579 | ||
|
|
d4b155d0c2 | ||
|
|
65f4960302 | ||
|
|
dc9e8d406d | ||
|
|
4d76006c2d | ||
|
|
731a6d0410 | ||
|
|
e4542aa451 | ||
|
|
ecc4a29be4 | ||
|
|
0f0b3d6df5 | ||
|
|
a826c1725b | ||
|
|
4e3b461fd3 | ||
|
|
d8a9dfafb2 | ||
|
|
ede621bea7 | ||
|
|
2f7da8356f | ||
|
|
d555cb2c94 | ||
|
|
750d70bfff | ||
|
|
dcc205ec4f | ||
|
|
356756286e | ||
|
|
79511d9b42 | ||
|
|
bff2df018b | ||
|
|
2562a218a0 | ||
|
|
d46b7e4aaf | ||
|
|
dee7ee1dde | ||
|
|
01fc339da5 | ||
|
|
ef46cd9b45 | ||
|
|
ac3ccb287b | ||
|
|
f52292e8d1 | ||
|
|
693c9858c2 | ||
|
|
c8d5e9d9d2 | ||
|
|
50668817db | ||
|
|
91758b61c3 | ||
|
|
39ae92a0fa | ||
|
|
95c203793c | ||
|
|
c7ac974ab9 | ||
|
|
51931d2b01 | ||
|
|
d271f6d46d | ||
|
|
efcd48f1d9 | ||
|
|
76daa583b3 | ||
|
|
3425a85fd0 | ||
|
|
9a6c1510fd | ||
|
|
44c391cb5a | ||
|
|
6085be7589 | ||
|
|
7d9bf8c729 | ||
|
|
1d72457dc1 | ||
|
|
0e32d2b1b4 | ||
|
|
75165e8838 | ||
|
|
d9536f1294 | ||
|
|
44e4c3ddc5 | ||
|
|
f67755f3b5 | ||
|
|
68d768cbd3 | ||
|
|
0be268c01f | ||
|
|
1831db530d | ||
|
|
8a32bf21d0 | ||
|
|
81c5bb4d52 | ||
|
|
93796bf4a8 | ||
|
|
4c34113f78 | ||
|
|
e4b189a06e | ||
|
|
0ebc885eb1 | ||
|
|
b4658ddf76 | ||
|
|
b38e2964ca | ||
|
|
5021e512b9 | ||
|
|
6d927540c8 | ||
|
|
6168767766 | ||
|
|
5af200a29d | ||
|
|
8b333c1ab5 | ||
|
|
d1f34ec136 | ||
|
|
c887a0d2b5 | ||
|
|
1168ba4557 | ||
|
|
e389420f07 | ||
|
|
0590beb876 | ||
|
|
d30c0d0213 | ||
|
|
eb774d37c6 | ||
|
|
cade6d5e0b | ||
|
|
19b71c5782 | ||
|
|
5f802a8179 | ||
|
|
99e1141446 | ||
|
|
3bd76b4fb5 | ||
|
|
3fb4ef34f4 | ||
|
|
f7c2abdcd7 | ||
|
|
cc6006e226 | ||
|
|
2816c2bcf5 | ||
|
|
abc8061cd7 | ||
|
|
a30c4d6be5 | ||
|
|
0bd5b6ba5d | ||
|
|
4ea3a500e8 | ||
|
|
56925c1eed | ||
|
|
71e7269927 | ||
|
|
6babff1be1 | ||
|
|
fec000086f | ||
|
|
bddf4cedb4 | ||
|
|
91c62e208b | ||
|
|
1e0424a83f | ||
|
|
c74d29e020 | ||
|
|
eadfff9cd0 | ||
|
|
93e30a8542 | ||
|
|
85ccc663c8 | ||
|
|
dab02823ff | ||
|
|
4fccfbf51d | ||
|
|
b04e322df8 | ||
|
|
f550822943 | ||
|
|
564fd20f23 | ||
|
|
45e38a9a2d | ||
|
|
e51f1e0a82 | ||
|
|
b4b82906ea | ||
|
|
a254ac87f7 | ||
|
|
ae8727ad96 | ||
|
|
de823f0430 | ||
|
|
c6ced0b1ea | ||
|
|
e76a883053 | ||
|
|
f967e96aa5 | ||
|
|
c8713a7f38 | ||
|
|
73e274cf0b | ||
|
|
c75c3d0734 | ||
|
|
e74b253f64 | ||
|
|
45009aedff | ||
|
|
870ba65662 | ||
|
|
80ace33af1 | ||
|
|
f69455bdb7 | ||
|
|
4ed6dc276e | ||
|
|
3dd415697a | ||
|
|
f7ce368b28 | ||
|
|
168236780d | ||
|
|
549e9ccd0b | ||
|
|
21f31b403b | ||
|
|
3b42503bd3 | ||
|
|
95e15fc40c | ||
|
|
74e546c2ce | ||
|
|
0c0f97560e | ||
|
|
8392f6f5a6 | ||
|
|
9a5ede0e6d | ||
|
|
33417454ed | ||
|
|
d79237958f | ||
|
|
6ef06467b6 | ||
|
|
a85d2222eb | ||
|
|
2325d24d6f | ||
|
|
4397e07d7f | ||
|
|
f5a624cfb2 | ||
|
|
d2762fae73 | ||
|
|
22d7107305 | ||
|
|
e22df23bc9 | ||
|
|
200ef9a45e | ||
|
|
783194e253 | ||
|
|
204868d733 | ||
|
|
f61a710e9b | ||
|
|
457731700c | ||
|
|
3dcb08cca3 | ||
|
|
e3f1ff7d70 | ||
|
|
623b506691 | ||
|
|
6e5dbc0b83 | ||
|
|
9c40a22c50 | ||
|
|
9dbd1d9bda | ||
|
|
9693b017c4 | ||
|
|
670915ab36 | ||
|
|
8224913b01 | ||
|
|
2b577f9755 | ||
|
|
4556c9a055 | ||
|
|
679ce8b6ab | ||
|
|
59eeb60594 | ||
|
|
6c4f9b2c03 | ||
|
|
f6e2037a1b | ||
|
|
0c9d4a5de4 | ||
|
|
5006c9d6ea | ||
|
|
17a8c4f3d5 | ||
|
|
98cb1c2315 | ||
|
|
6cc5aa08e3 | ||
|
|
ebda3865dc | ||
|
|
fb171a459e | ||
|
|
027a249837 | ||
|
|
2a2bb7199e | ||
|
|
f64892d010 | ||
|
|
d023d7f3e4 | ||
|
|
ef96bfa98c | ||
|
|
b2ee46804b | ||
|
|
7c9c277cf8 | ||
|
|
3e376ebc74 | ||
|
|
102ea2da7a | ||
|
|
e1a2fbfaa6 | ||
|
|
c1aecf2b37 | ||
|
|
b2754e3036 | ||
|
|
abbb740753 | ||
|
|
7504224c2f | ||
|
|
57b990409a | ||
|
|
cff5e06d62 | ||
|
|
a543b409cb | ||
|
|
e57d2f4afa | ||
|
|
9da0173e05 | ||
|
|
a21b171d96 | ||
|
|
2569e7ed8e | ||
|
|
25d2eade8c | ||
|
|
43b949966d | ||
|
|
08205b96cd | ||
|
|
e2e63bbfc8 | ||
|
|
24d1a7dd67 | ||
|
|
9b4e46b625 | ||
|
|
e47a335dec | ||
|
|
d46f7023d5 | ||
|
|
19bef60752 | ||
|
|
5ff73657e1 | ||
|
|
fb8da04a18 | ||
|
|
3ee50a290f | ||
|
|
71ea05598f | ||
|
|
96d5c71e0d | ||
|
|
07318eb7aa | ||
|
|
2ef235dddd | ||
|
|
f7cc9865cc | ||
|
|
c323ce6cbb | ||
|
|
5d2ddc490a | ||
|
|
40463290a6 | ||
|
|
6b4faac2cb | ||
|
|
077a7de605 | ||
|
|
180bc06c06 | ||
|
|
72a97ecbd6 | ||
|
|
87a6ed3e46 | ||
|
|
95d8e777db | ||
|
|
d028ecfc55 | ||
|
|
225da821e4 | ||
|
|
7cd4023f5c | ||
|
|
2379832cc2 | ||
|
|
cb46e090dd | ||
|
|
a656f0f304 | ||
|
|
564fa313f1 | ||
|
|
47cd618bf4 | ||
|
|
ae709a1d9b | ||
|
|
0812d96068 | ||
|
|
86911933db | ||
|
|
69d5c139f6 | ||
|
|
405b9a19f9 | ||
|
|
c4a696cbc4 | ||
|
|
bb0e4997de | ||
|
|
bc1b908063 | ||
|
|
29c522484c | ||
|
|
5b6585b115 | ||
|
|
3715c69c02 | ||
|
|
3d3b457c92 | ||
|
|
e4f5d44658 | ||
|
|
fc7a25ca4d | ||
|
|
1d3a44a157 | ||
|
|
319b44f153 | ||
|
|
98a4144403 | ||
|
|
a819140c88 | ||
|
|
cba5c65608 | ||
|
|
eff4a3abcb | ||
|
|
2e0ccef3f5 | ||
|
|
429a157715 | ||
|
|
76065ee93f | ||
|
|
97def2240f | ||
|
|
15cd18a5c4 | ||
|
|
0b3b095eac | ||
|
|
38bda12c4b | ||
|
|
d2a34226a6 | ||
|
|
92dcb29010 | ||
|
|
d4cc869227 | ||
|
|
291ff86686 | ||
|
|
895ad62757 | ||
|
|
4acfa36b57 | ||
|
|
fe18256c25 | ||
|
|
39531eb4f0 | ||
|
|
28d73b44a6 | ||
|
|
3983a8a1ba | ||
|
|
08151e7e21 | ||
|
|
446927f947 | ||
|
|
5d72904849 | ||
|
|
ff1f076536 | ||
|
|
c56766ef85 | ||
|
|
eff8953207 | ||
|
|
2ba1ae7a23 |
184
ChangeLog.md
Normal file
184
ChangeLog.md
Normal file
@@ -0,0 +1,184 @@
|
||||
## Changes from most recent to oldest
|
||||
|
||||
**06 Jun 2024 - wpferguson**
|
||||
* fix fujifilm_ratings running on all images
|
||||
* added string library functions to sanitize windows io.popen and os.execute functions
|
||||
* added database maintenance script
|
||||
|
||||
**05 Jun 2024 - wpferguson**
|
||||
* added fix for executable_manager not being visible on windows
|
||||
|
||||
**30 May 2024 - kkotowicz**
|
||||
* open in explorer now uses applescript on macos to open multiple files
|
||||
|
||||
**20 May 2024 - wpferguson**
|
||||
* added string variable substitution to the string library
|
||||
|
||||
**16 May 2024 - wpferguson**
|
||||
* fix crash in script_manager
|
||||
|
||||
**15 May 2024 - wpferguson**
|
||||
* added metadata to scripts (name, author, purpose, help url)
|
||||
|
||||
**06 May 2024 - christian.sueltrop**
|
||||
* added passport_guide_germany script
|
||||
|
||||
**08 Apr 2024 - wpferguson**
|
||||
* made script_manager aware of library modules in other directories besides lib
|
||||
|
||||
**29 Mar 2024 - wpferguson**
|
||||
* updated examples/gui_action to use NaN instead of nan
|
||||
|
||||
**29 Mar 2024 - dterrahe**
|
||||
* add lua action script example
|
||||
|
||||
**28 Jan 2024 - wpferguson**
|
||||
* fix script_manager crash when script existed in top level lua directory
|
||||
|
||||
**24 Jan 2024 - wpferguson**
|
||||
* don't set lib visibility unless we are in lighttable mode
|
||||
|
||||
**15 Jan 2024 - MStraeten**
|
||||
* update x-touch.lua with support for primaries slider
|
||||
|
||||
**14 Jan 2024 - wpferguson**
|
||||
* added cycle_group_leader script
|
||||
* added hif_group_leader script
|
||||
* added jpg_group_leader script
|
||||
|
||||
**28 Oct 2023 - ddittmar**
|
||||
* Added select non existing image script
|
||||
|
||||
**17 Oct 2023 - wpferguson**
|
||||
* script_manager wrap username in quotes to handle spaces in username
|
||||
|
||||
**20 Sep 2023 - wpferguson**
|
||||
* script_manager explicitly set stopped scripts to false
|
||||
|
||||
**18 Aug 2023 - wpferguson**
|
||||
* swap the position of executable_manager and script_manager due to windows gtk bug
|
||||
|
||||
**17 Jul 2023 - wpferguson**
|
||||
* update check for update instructions
|
||||
* update flatpak readme
|
||||
|
||||
**16 Jul 2023 - wpferguson**
|
||||
* added script_data.show function to contrib/gpx_export
|
||||
|
||||
**15 Jul 2023 - wpferguson**
|
||||
* script_manager updates
|
||||
* added check_max_api_version for the case where the API no longer supports the required
|
||||
functions
|
||||
|
||||
**14 Jul 2023 - spaceChRis**
|
||||
* add tooltip to filter manager
|
||||
|
||||
**25 Mar 2023 - wpferguson**
|
||||
* Added script_manager darktable shortcut integration
|
||||
* Moved filename/path/extension string functions to string library
|
||||
|
||||
**06 Mar 2023 - wpferguson - Added option to disable script_manager update check**
|
||||
|
||||
**12 Jan 2022 - wpferguson - Documented contrib/rename_images.lua**
|
||||
|
||||
**28 Dec 2021 - wpferguson - Replaced deprecated _which_ command with _command -v_ in lib/dtutils/file.lua**
|
||||
|
||||
**12 Dec 2021 - wpferguson**
|
||||
* Added deprecated function to library
|
||||
* Added deprecation warning to contrib/rename-tags.lua
|
||||
|
||||
**22 Oct 2021 - wpferguson for Volker Bödker - make sure sequence is 4 digits in rename_images.lua**
|
||||
|
||||
**31 Aug 2021 - wpferguson - remove styles hiding from AutoGrouper.lua**
|
||||
|
||||
**02 Jul 2021 - wpferguson - merged API-7.0.0-dev branch to master**
|
||||
* API-7.0.0 is darktable 3.6
|
||||
* breaking changes
|
||||
* register_event argments changed
|
||||
* register_action arguments changed
|
||||
* register_selection arguments changed
|
||||
* register_event arguments changed
|
||||
* scripts updated to API-7.0.0 compatibility
|
||||
* script_manger updated to be API aware and check out the proper branch
|
||||
based on darktable API version
|
||||
|
||||
**01 Jul 2021 - wpferguson - created branch for API-6.1.0 - darktable 3.4**
|
||||
|
||||
**20 Jun 2021 - wpferguson - created branches for older API versions**
|
||||
* API-6.0.0 - darktable 3.2.1
|
||||
* API-5.0.2 - darktable 3.0
|
||||
* API-5.0.1 - darktable 2.6.1
|
||||
* API-5.0.0 - darktable 2.4
|
||||
* API-4.0.0 - darktable 2.2
|
||||
* API-3.0.0 - darktable 2.0
|
||||
|
||||
**19 Jun 2021 - wpferguson - fix issue 312, image_path_in_ui**
|
||||
|
||||
**02 Jun 2021 - wpferguson - fix contrib/quicktag**
|
||||
* set new entry field is_password to false so entry
|
||||
is visible to user while typing.
|
||||
|
||||
**19 Mar 2021 - wpferguson - fixed crash in contrib/HDRmerge.lua**
|
||||
* Made generated filename routine gracefully handle names that
|
||||
are not in the expected format.
|
||||
|
||||
**15 Mar 2021 - scheckmedia - updated contrib/photils.lua**
|
||||
* refactor print method
|
||||
* add option to apply selected tags from a single image to multiple images
|
||||
* add setting parameter to enable/disable the export of an image before tag suggestion
|
||||
|
||||
**25 Feb 2021 - wpferguson - added detached mode to contrib/gimp.lua**
|
||||
|
||||
* Added run_detached checkbox to the exporter GUI. Selecting run_detached
|
||||
let's GIMP keep running and accepting additional images. It does not return
|
||||
the edited images to darktable.
|
||||
|
||||
**24 Feb 2021 - Mark64 - make ext_editor lib visible in darkroom view**
|
||||
|
||||
**17 Feb 2021 - wpferguson - API 6.2.3 register_action changes**
|
||||
|
||||
* Added check for API version and supplied a name argument if the
|
||||
API version was greater than or equal to 6.2.3
|
||||
|
||||
**10 Feb 2021 - wpferguson - bugfix select_untagged**
|
||||
|
||||
* Fixed callback to return a list of images as expected instead of
|
||||
doing the selection in the callback
|
||||
|
||||
**10 Feb 2021 - wpferguson - bugfix API 6.2.1 compatibility**
|
||||
|
||||
* The inline check for API version didn't handle argument return
|
||||
correctly so added a transition library with a register_event function
|
||||
override to check the API version and process the arguments correctly.
|
||||
|
||||
**9 Feb 2021 - wpferguson - bugfix API 6.2.2 compatibility**
|
||||
|
||||
* The inline check for API version didn't handle argument return
|
||||
correctly so changed it to a full if/else block
|
||||
|
||||
**4 Reb 2021 - wpferguson - API 6.2.2 compatibililty**
|
||||
|
||||
* Added check for API version and supplied a name argument to register_selection
|
||||
if the API version was greater than or eqal to 6.2.2
|
||||
|
||||
**1 Feb 2021 - wpferguson - API 6.2.1 compatibility**
|
||||
|
||||
* Added check for API version and supplied a name argument to register_event
|
||||
if the API version was greater than or eqal to 6.2.1
|
||||
|
||||
**21 Jan 2021 - wpferguson - Modified dtutils function find_image_by_id**
|
||||
|
||||
* For users with API 6.2.0 or greater - Enabled use of new API function
|
||||
darktable.database_get_image() in find_image_by_id().
|
||||
|
||||
**19 Jan 2021 - schwerdf - Added dtutils library function find_image_by_id()**
|
||||
|
||||
* Added new library function to retrieve an image from the library based on it's ID instead
|
||||
of it's row number in the database
|
||||
|
||||
**10 Jan 2021 - chrisaga - copy_attach_detach_tags localization**
|
||||
|
||||
**7 Jan 2021 - dtorop - add contrib/fujifilm_dynamic_range**
|
||||
|
||||
* add a new contrib script, fujifilm_dynamic_range to adjust exposure
|
||||
based on the exposure bias camera setting
|
||||
280
README.md
280
README.md
@@ -1,10 +1,278 @@
|
||||
lua-scripts
|
||||
===========
|
||||
# Lua scripts
|
||||
|
||||
The Lua scripts in this repository are meant to be used together with darktable. Either copy them individually to `~/.config/darktable/lua` (you might have to create that folder) or just copy/symlink the whole repository there. That allows to update all your scripts with a simple call to `git pull`.
|
||||
## Description
|
||||
|
||||
To enable one of the scripts you have to add a line like `require "official/hello_world"` to your `~/.config/darktable/luarc` file which will enable the example script in `official/hello_world.lua` (note the lack of the `.lua` suffix).
|
||||
darktable can be customized and extended using the Lua programming language. This repository contains the collected
|
||||
efforts of the darktable developers, maintainers, contributors and community. The following sections list the scripts
|
||||
contained in the repository, whether they can be run by themselves (Standalone - Yes) or depend on other
|
||||
scripts (Standalone - No), what operating systems they are known to work on (L - Linux, M - MacOS, W - Windows), and their purpose.
|
||||
|
||||
Each script includes its own documentation and usage in its header, please refer to them.
|
||||
For the latest changes, see the [ChangeLog](ChangeLog.md)
|
||||
|
||||
In order to have your own scripts added here they have to be under a free license (GPL2+ will definitely work, others can be discussed). Scripts in the `official/` subfolder are maintained by the darktable community, those under `contrib/` are meant to have an "owner" who maintains them.
|
||||
### Official Scripts
|
||||
|
||||
These scripts are written primarily by the darktable developers and maintained by the authors and/or repository maintainers. They are located in the official/ directory.
|
||||
|
||||
Name|Standalone|OS |Purpose
|
||||
----|:--------:|:---:|-------
|
||||
[check_for_updates](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/check_for_updates)|Yes|LMW|Check for updates to darktable
|
||||
[copy_paste_metadata](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/copy_paste_metadata)|Yes|LMW|Copy and paste metadata, tags, ratings, and color labels between images
|
||||
[delete_long_tags](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/delete_long_tags)|Yes|LMW|Delete all tags longer than a specified length
|
||||
[delete_unused_tags](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/delete_unused_tags)|Yes|LMW|Delete tags that have no associated images
|
||||
[enfuse](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/enfuse)|No|L|Exposure blend several images (HDR)
|
||||
[generate_image_txt](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/generate_image_txt)|No|L|Generate txt sidecar files to be overlaid on zoomed images
|
||||
[image_path_in_ui](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/image_path_in_ui)|Yes|LMW|Plugin to display selected image path
|
||||
[import_filter_manager](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/import_filter_manager)|Yes|LMW|Manager for import filters
|
||||
[import_filters](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/import_filters)|No|LMW|Two import filters for use with import_filter_manager
|
||||
[save_selection](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/save_selection)|Yes|LMW|Provide save and restore from multiple selection buffers
|
||||
[selection_to_pdf](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/selection_to_pdf)|No|L|Generate a PDF file from the selected images
|
||||
|
||||
|
||||
### Contributed Scripts
|
||||
|
||||
These scripts are contributed by users. They are meant to have an "owner", i.e. the author, who maintains them. Over time the community has helped maintain these scripts, as well as the authors. They are located in the contrib/ directory.
|
||||
|
||||
Name|Standalone|OS |Purpose
|
||||
----|:--------:|:---:|-------
|
||||
[AutoGrouper](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/autogrouper)|Yes|LMW|Group images together by time
|
||||
[autostyle](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/autostyle)|Yes|LMW|Automatically apply styles on import
|
||||
[auto_tag_modified](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/auto_tag_modified)|Yes|LMW|Automatically tag images which have been manually modified in the darkroom
|
||||
change_group_leader|Yes|LMW|Change which image leads group
|
||||
[clear_GPS](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/clear_gps)|Yes|LMW|Reset GPS information for selected images
|
||||
[CollectHelper](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/collecthelper)|Yes|LMW|Add buttons to selected images module to manipulate the collection
|
||||
color_profile_manager|Yes|LMW|Manage darktable input and output color profiles
|
||||
[copy_attach_detach_tags](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/copy_attach_detach_tags)|Yes|LMW|Copy and paste tags from/to images
|
||||
[cr2hdr](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/cr2hdr)|Yes|L|Process image created with Magic Lantern Dual ISO
|
||||
cycle_group_leader|Yes|LMW|cycle through images of a group making each the group leader in turn
|
||||
dbmaint|Yes|LMW|find and remove database entries for missing film rolls and images
|
||||
[enfuseAdvanced](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/enfuseadvanced)|No|LMW|Merge multiple images into Dynamic Range Increase (DRI) or Depth From Focus (DFF) images
|
||||
[exportLUT](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/exportlut)|Yes|LMW|Create a LUT from a style and export it
|
||||
[ext_editor](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/ext_editor)|No|LW|Export pictures to collection and edit them with up to nine user-defined external editors
|
||||
[face_recognition](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/face_recognition)|No|LM|Identify and tag images using facial recognition
|
||||
[fujifilm_dynamic_range](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/fujifilm_dynamic_range)|No|LMW|Correct fujifilm exposure based on exposure bias camera setting
|
||||
[fujifilm_ratings](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/fujifilm_ratings)|No|LM|Support importing Fujifilm ratings
|
||||
[geoJSON_export](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/geojson_export)|No|L|Create a geo JSON script with thumbnails for use in ...
|
||||
[geoToolbox](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/geotoolbox)|No|LMW|A toolbox of geo functions
|
||||
[gimp](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/gimp)|No|LMW|Open an image in GIMP for editing and return the result
|
||||
[gpx_export](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/gpx_export)|No|LMW|Export a GPX track file from selected images GPS data
|
||||
harmonic_armature|Yes|LMW|provide a harmonic armature guide
|
||||
hif_group_leader|Yes|LMW|change the group leader in a raw+heif image pair to the heif image
|
||||
[HDRMerge](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/hdrmerge)|No|LMW|Combine the selected images into an HDR DNG and return the result
|
||||
[hugin](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/hugin)|No|LMW|Combine selected images into a panorama and return the result
|
||||
[image_stack](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/image_stack)|No|LMW|Combine a stack of images to remove noise or transient objects
|
||||
[image_time](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/image_time)|Yes|LMW|Adjust the EXIF image time
|
||||
jpg_group_leader|Yes|LMW|change the group leader for a raw+jpg pair to the jpg image
|
||||
[kml_export](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/kml_export)|No|L|Export photos with a KML file for usage in Google Earth
|
||||
[LabelsToTags](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/labelstotags)|Yes|LMW|Apply tags based on color labels and ratings
|
||||
[OpenInExplorer](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/openinexplorer)|No|LMW|Open the selected images in the system file manager
|
||||
[passport_guide](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/passport_guide)|Yes|LMW|Add passport cropping guide to darkroom crop tool
|
||||
passport_guide_germany|Yes|LMW|Add passport cropping guide for German passports to darkroom crop tool
|
||||
[pdf_slideshow](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/pdf_slideshow)|No|LM|Export images to a PDF slideshow
|
||||
[photils](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/photils)|No|LM|Automatic tag suggestions for your images
|
||||
[quicktag](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/quicktag)|Yes|LMW|Create shortcuts for quickly applying tags
|
||||
[rate_group](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/rate_group)|Yes|LMW|Apply or remove a star rating from grouped images
|
||||
[rename_images](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/rename_images)|Yes|LMW|Rename single or multiple images
|
||||
[rename-tags](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/rename-tags)|Yes|LMW|Change a tag name
|
||||
[RL_out_sharp](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/rl_out_sharp)|No|LW|Output sharpening using GMic (Richardson-Lucy algorithm)
|
||||
[select_non_existing](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/select_non_existing)|Yes|LMW|Enable selection of non-existing images in the the currently worked on images
|
||||
[select_untagged](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/select_untagged)|Yes|LMW|Enable selection of untagged images
|
||||
[slideshowMusic](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/slideshowMusic)|No|L|Play music during a slideshow
|
||||
[transfer_hierarchy](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/transfer_hierarchy)|Yes|LMW|Image move/copy preserving directory hierarchy
|
||||
[video_ffmpeg](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/video_ffmpeg)|No|LMW|Export video from darktable
|
||||
website_gallery_export|No|LMW|Export a website gallery for selected images
|
||||
|
||||
### Example Scripts
|
||||
|
||||
These scripts provide examples of how to use specific portions of the API. They run, but are meant for demonstration purposes only. They are located in the examples/ directory.
|
||||
|
||||
Name|Standalone|OS |Purpose
|
||||
----|:--------:|:---:|-------
|
||||
[api_version](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/api_version)|Yes|LMW|Print the current API version
|
||||
[darkroom_demo](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/darkroom_demo)|Yes|LMW|Demonstrate changing images in darkoom
|
||||
[gettextExample](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/gettextexample)|Yes|LM|How to use translation
|
||||
gui_actions|Yes|LMW|demonstrate controlling the GUI using darktable.gui.action calls
|
||||
[hello_world](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/hello_world)|Yes|LMW|Prints hello world when darktable starts
|
||||
[lighttable_demo](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/lighttable_demo)|Yes|LMW|Demonstrate controlling lighttable mode, zoom, sorting and filtering
|
||||
[moduleExample](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/moduleexample)|Yes|LMW|How to create a lighttable module
|
||||
[multi_os](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/multi_os)|No|LMW|How to create a cross platform script that calls an external executable
|
||||
[panels_demo](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/panels_demo)|Yes|LMW|Demonstrate hiding and showing darktable panels
|
||||
[preferenceExamples](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/preferenceexamples)|Yes|LMW|How to use preferences in a script
|
||||
[printExamples](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/printexamples)|Yes|LMW|How to use various print functions from a script
|
||||
[running_os](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/running_os)|Yes|LMW|Print out the running operating system
|
||||
x-touch|Yes|LMW|demonstrate how to use an x-touch mini MIDI controller to control the darktable GUI
|
||||
|
||||
### Tools
|
||||
|
||||
Tool scripts perform functions relating to the repository, such as generating documentation. They are located in the tools/ directory.
|
||||
|
||||
Name|Standalone|OS |Purpose
|
||||
----|:--------:|:---:|-------
|
||||
[executable_manager](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/tools/executable_manager)|Yes|LMW|Manage the external executables used by the lua scripts
|
||||
[gen_i18n_mo](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/tools/gen_i18n_mo)|No|LMW|Generate compiled translation files (.mo) from source files (.po)
|
||||
[get_lib_manpages](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/tools/get_lib_manpages)|No|LM|Retrieve the library documentation and output it in man page and PDF format
|
||||
[get_libdoc](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/tools/get_libdoc)|No|LMW|Retrieve the library documentation and output it as text
|
||||
[script_manager](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/tools/script_manager)|No|LMW|Manage (install, update, enable, disable) the lua scripts
|
||||
|
||||
### Related third-party projects
|
||||
|
||||
The following third-party projects are listed for information only. Think of this collection as an `awesome-darktable-lua-scripts` list. Use at your own risk!
|
||||
|
||||
* [trougnouf/dtMediaWiki](https://github.com/trougnouf/dtMediaWiki) – Wikimedia Commons export
|
||||
* [wpferguson/extra-dt-lua-scripts](https://github.com/wpferguson/extra-dt-lua-scripts)
|
||||
* [xxv/darktable-git-annex](https://github.com/xxv/darktable-git-annex) – git-annex integration
|
||||
* [theres/dt_backup](https://github.com/theres/dt_backup) – automatic backup on exit
|
||||
* [Sitwon/dt_fujifilm_ratings](https://github.com/Sitwon/dt_fujifilm_ratings) – Fujifilm Ratings import
|
||||
* [progo/leica-q-autocrop](https://github.com/progo/leica-q-autocrop) – crop in 35 mm or 50 mm equivalent
|
||||
* [BzKevin/OpenInPS-Darktable-PlugIn](https://github.com/BzKevin/OpenInPS-Darktable-PlugIn) – open in Adobe Photoshop
|
||||
* [johnnyrun/darktable_lua_gimp](https://github.com/johnnyrun/darktable_lua_gimp) – GIMP export
|
||||
* [arru/darktable-scripts](https://github.com/arru/darktable-scripts)
|
||||
* [nbremond77/darktable](https://github.com/nbremond77/darktable/tree/master/scripts)
|
||||
* [s5k6/dtscripts](https://github.com/s5k6/dtscripts)
|
||||
* [ChristianBirzer/darktable_extra_scripts](https://github.com/ChristianBirzer/darktable_extra_scripts)
|
||||
* [fjb2020/darktable-scripts](https://github.com//fjb2020/darktable-scripts)
|
||||
* [bastibe/Darktable-Film-Simulation-Panel](https://github.com/bastibe/Darktable-Film-Simulation-Panel)
|
||||
|
||||
## Download and Install
|
||||
|
||||
The recommended method of installation is using git to clone the repository. This ensures that all dependencies on other scripts
|
||||
are met as well as providing an easy update path. Single scripts listed as standalone may be downloaded and installed by themselves.
|
||||
|
||||
### snap packages
|
||||
|
||||
The snap version of darktable comes with lua included starting with version 2.4.3snap2.
|
||||
|
||||
Ensure git is installed on your system. If it isn't, use the package manager to install it. Then open a terminal and:
|
||||
|
||||
cd ~/snap/darktable/current
|
||||
git clone https://github.com/darktable-org/lua-scripts.git lua
|
||||
|
||||
### flatpak packages
|
||||
|
||||
Flatpak packages now use the internal lua interpreter.
|
||||
|
||||
|
||||
Ensure git is installed on your system. If it isn't, use the package manager to install it. Then open a terminal and:
|
||||
|
||||
cd ~/.var/app/org.darktable.Darktable/config/darktable
|
||||
git clone https://github.com/darktable-org/lua-scripts.git lua
|
||||
|
||||
### appimage packages
|
||||
|
||||
These packages run in their own environment and don't have access to a lua interpreter, therefore the scripts can't run. The packagers could enable the internal interpreter, or allow the package to link the interpreter from the operating system, or bundle a copy of lua with the package. If you use one of these packages and wish to use the lua scripts, please contact the package maintainer and suggest the above fixes.
|
||||
|
||||
### Linux and MacOS
|
||||
|
||||
Ensure git is installed on your system. If it isn't, use the package manager to install it. Then open a terminal and:
|
||||
|
||||
cd ~/.config/darktable/
|
||||
git clone https://github.com/darktable-org/lua-scripts.git lua
|
||||
|
||||
### Windows
|
||||
|
||||
Ensure git is installed on your system. Git can be obtained from https://gitforwindows.org/, as well as other places. If you use the gitforwindows.org distribution, install the Git Bash Shell also as it will aid in debugging the scripts if necessary. Then open a command prompt and run:
|
||||
|
||||
cd %LOCALAPPDATA%\darktable
|
||||
git clone https://github.com/darktable-org/lua-scripts.git lua
|
||||
|
||||
If you don't have %LOCALAPPDATA%\darktable you have to start dartable at least once, because the directory is created at the first start of darktable.
|
||||
|
||||
## Enabling
|
||||
|
||||
When darktable starts it looks for a file name `~/.config/darktable/luarc` (`%LOCALAPPDATA%\darktable\luarc` for windows) and reads it to see which scripts to include. The file is a plain text file with entries of the form `require "<directory>/<name>"` where directory is the directory containing the scripts, from the above list, and name is the name from the above list. To include GIMP the line would be `require "contrib/gimp"`.
|
||||
|
||||
The recommended way to enable and disable specific scripts is using the [script manager](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/tools/script_manager/) module. To use script manager do the following:
|
||||
|
||||
### Linux or MacOS
|
||||
|
||||
echo 'require "tools/script_manager"' > ~/.config/darktable/luarc
|
||||
|
||||
### Windows
|
||||
|
||||
echo require "tools/script_manager" > %LOCALAPPDATA%\darktable\luarc
|
||||
|
||||
### Snap
|
||||
|
||||
echo 'require "tools/script_manager"' > ~/snap/darktable/current/luarc
|
||||
|
||||
### Flatpak
|
||||
|
||||
echo 'require "tools/script_manager"' > ~/.var/app/org.darktable.Darktable/config/darktable/luarc
|
||||
|
||||
You can also create or add lines to the luarc file from the command line:
|
||||
|
||||
`echo 'require "contrib/gimp"' > ~/.config/darktable/luarc` to create the file with a gimp entry\
|
||||
or `echo 'require "contrib/hugin"' >> ~/.config/darktable/luarc` to add an entry for hugin.
|
||||
|
||||
On windows from a command prompt:
|
||||
|
||||
`echo require "contrib/gimp" > %LOCALAPPDATA%\darktable\luarc` to create the file with a gimp entry\
|
||||
or `echo require "contrib/hugin" >> %LOCALAPPDATA%\darktable\luarc` to add an entry for hugin.
|
||||
|
||||
## Disabling
|
||||
|
||||
To disable a script open the luarc file in your text editor and insert `--` at the start of the line containing the script you wish to disable, then save the file.
|
||||
|
||||
## Updating
|
||||
|
||||
To update the script repository, open a terminal or command prompt and do the following:
|
||||
|
||||
### Snap
|
||||
|
||||
cd ~/snap/darktable/current/lua
|
||||
git pull
|
||||
|
||||
|
||||
### Flatpak
|
||||
|
||||
cd ~/.var/app/org.darktable.Darktable/config/darktable/lua
|
||||
git pull
|
||||
|
||||
### Linux and MacOS
|
||||
|
||||
cd ~/.config/darktable/lua/
|
||||
git pull
|
||||
|
||||
### Windows
|
||||
|
||||
cd %LOCALAPPDATA%\darktable\lua
|
||||
git pull
|
||||
|
||||
## Documentation
|
||||
|
||||
The [Lua Scripts Manual](https://docs.darktable.org/lua/stable/lua.scripts.manual/) provides documentation
|
||||
for the scripts transcribed from the header comments. Each script also contains comments and usage instructions
|
||||
in the header comments.
|
||||
|
||||
The [Lua Scripts Library API Manual](https://docs.darktable.org/lua/stable/lua.scripts.api.manual/) provides
|
||||
documentation of the libraries and functions. Lua-script libraries documentation may also be generated using
|
||||
the tools in the tools/ directory.
|
||||
|
||||
More information about the scripting with Lua can be found in the darktable user manual:
|
||||
[Scripting with Lua](https://darktable.org.github.io/dtdocs/lua/)
|
||||
|
||||
The [Lua API Manual](https://docs.darktable.org/lua/stable/lua.api.manual/) provides docuemntation of the
|
||||
darktable Lua API.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Running darktable with Lua debugging enabled provides more information about what is occurring within the scripts.
|
||||
|
||||
### Snap
|
||||
|
||||
Open a terminal and start darktable with the command `snap run darktable -d lua`. This provides debugging information to give you insight into what is happening.
|
||||
|
||||
### Linux
|
||||
|
||||
Open a terminal and start darktable with the command `darktable -d lua`. This provides debugging information to give you insight into what is happening.
|
||||
|
||||
### MacOS
|
||||
|
||||
Open a terminal and start darktable with the command `/Applications/darktable.app/Contents/MacOS/darktable -d lua`. This provides debugging information to give you insight into what is happening.
|
||||
|
||||
### Windows
|
||||
|
||||
Open a command prompt. Start darktable with the command "C:\Program Files\darktable\bin\darktable" -d lua > log.txt. This provides debugging information to give you insight into what is happening.
|
||||
|
||||
## Contributing
|
||||
|
||||
In order to have your own scripts added here they have to be under a free license (GPL2+ will definitely work, others can be discussed).
|
||||
|
||||
225
contrib/AutoGrouper.lua
Normal file
225
contrib/AutoGrouper.lua
Normal file
@@ -0,0 +1,225 @@
|
||||
--[[AutoGrouper plugin for darktable
|
||||
|
||||
copyright (c) 2019 Kevin Ertel
|
||||
|
||||
darktable is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
darktable is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with darktable. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
|
||||
--[[About this Plugin
|
||||
This plugin adds the module "Auto Group" to darktable's lighttable view
|
||||
|
||||
----REQUIRED SOFTWARE----
|
||||
None
|
||||
|
||||
----USAGE----
|
||||
Install: (see here for more detail: https://github.com/darktable-org/lua-scripts )
|
||||
1) Copy this file in to your "lua/contrib" folder where all other scripts reside.
|
||||
2) Require this file in your luarc file, as with any other dt plug-in
|
||||
|
||||
Set a gap amount in second which will be used to determine when images should no
|
||||
longer be added to a group. If an image is more then the specified amount of time
|
||||
from the last image in the group it will not be added. Images without timestamps
|
||||
in exif data will be ignored.
|
||||
|
||||
There are two buttons. One allows the grouping to be performed only on the currently
|
||||
selected images, the other button performs grouping on the entire active collection
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
|
||||
du.check_min_api_version("7.0.0", "AutoGrouper")
|
||||
|
||||
local MOD = 'autogrouper'
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("auto group"),
|
||||
purpose = _("automatically group images by time interval"),
|
||||
author = "Kevin Ertel",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/AutoGrouper/"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
local Ag = {}
|
||||
Ag.module_installed = false
|
||||
Ag.event_registered = false
|
||||
|
||||
local GUI = {
|
||||
gap = {},
|
||||
selected = {},
|
||||
collection = {}
|
||||
}
|
||||
|
||||
|
||||
local function InRange(test, low, high) --tests if test value is within range of low and high (inclusive)
|
||||
if test >= low and test <= high then
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
local function CompTime(first, second) --compares the timestamps and returns true if first was taken before second
|
||||
first_time = first.exif_datetime_taken
|
||||
if string.match(first_time, '[0-9]') == nil then first_time = '9999:99:99 99:99:99' end
|
||||
first_time = tonumber(string.gsub(first_time, '[^0-9]*',''))
|
||||
second_time = second.exif_datetime_taken
|
||||
if string.match(second_time, '[0-9]') == nil then second_time = '9999:99:99 99:99:99' end
|
||||
second_time = tonumber(string.gsub(second_time, '[^0-9]*',''))
|
||||
return first_time < second_time
|
||||
end
|
||||
|
||||
local function SeperateTime(str) --seperates the timestamp into individual components for used with OS.time operations
|
||||
local cleaned = string.gsub(str, '[^%d]',':')
|
||||
cleaned = string.gsub(cleaned, '::*',':') --YYYY:MM:DD:hh:mm:ss
|
||||
local year = string.sub(cleaned,1,4)
|
||||
local month = string.sub(cleaned,6,7)
|
||||
local day = string.sub(cleaned,9,10)
|
||||
local hour = string.sub(cleaned,12,13)
|
||||
local min = string.sub(cleaned,15,16)
|
||||
local sec = string.sub(cleaned,18,19)
|
||||
return {year = year, month = month, day = day, hour = hour, min = min, sec = sec}
|
||||
end
|
||||
|
||||
local function GetTimeDiff(curr_image, prev_image) --returns the time difference (in sec.) from current image and the previous image
|
||||
local curr_time = SeperateTime(curr_image.exif_datetime_taken)
|
||||
local prev_time = SeperateTime(prev_image.exif_datetime_taken)
|
||||
return os.time(curr_time)-os.time(prev_time)
|
||||
end
|
||||
|
||||
local function main(on_collection)
|
||||
local images = {}
|
||||
if on_collection then
|
||||
local col_images = dt.collection
|
||||
for i,image in ipairs(col_images) do --copy images to a standard table, table.sort barfs on type dt_lua_singleton_image_collection
|
||||
table.insert(images,i,image)
|
||||
end
|
||||
else
|
||||
images = dt.gui.selection()
|
||||
end
|
||||
dt.preferences.write(MOD, 'active_gap', 'integer', GUI.gap.value)
|
||||
if #images < 2 then
|
||||
dt.print('please select at least 2 images')
|
||||
return
|
||||
end
|
||||
table.sort(images, function(first, second) return CompTime(first,second) end) --sort images by timestamp
|
||||
|
||||
for i, image in ipairs(images) do
|
||||
if i == 1 then
|
||||
prev_image = image
|
||||
image:make_group_leader()
|
||||
elseif string.match(image.exif_datetime_taken, '[%d]') ~= nil then --make sure current image has a timestamp, if so check if it is within the user specified gap value and add to group
|
||||
local curr_image = image
|
||||
if GetTimeDiff(curr_image, prev_image) <= GUI.gap.value then
|
||||
images[i]:group_with(images[i-1].group_leader)
|
||||
end
|
||||
prev_image = curr_image
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function install_module()
|
||||
if not Ag.module_installed then
|
||||
dt.print_log("installing module")
|
||||
dt.register_lib(
|
||||
MOD, -- Module name
|
||||
_('auto group'), -- name
|
||||
true, -- expandable
|
||||
true, -- resetable
|
||||
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 99}}, -- containers
|
||||
dt.new_widget("box"){
|
||||
orientation = "vertical",
|
||||
GUI.gap,
|
||||
GUI.selected,
|
||||
GUI.collection
|
||||
}
|
||||
)
|
||||
Ag.module_installed = true
|
||||
dt.print_log("module installed")
|
||||
dt.print_log("styles module visibility is " .. tostring(dt.gui.libs["styles"].visible))
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.gui.libs[MOD].visible = false
|
||||
end
|
||||
|
||||
local function restart()
|
||||
dt.gui.libs[MOD].visible = true
|
||||
end
|
||||
|
||||
-- GUI --
|
||||
temp = dt.preferences.read(MOD, 'active_gap', 'integer')
|
||||
if not InRange(temp, 1, 86400) then temp = 3 end
|
||||
GUI.gap = dt.new_widget('slider'){
|
||||
label = _('group gap [sec.]'),
|
||||
tooltip = _('minimum gap, in seconds, between groups'),
|
||||
soft_min = 1,
|
||||
soft_max = 60,
|
||||
hard_min = 1,
|
||||
hard_max = 86400,
|
||||
step = 1,
|
||||
digits = 0,
|
||||
value = temp,
|
||||
reset_callback = function(self)
|
||||
self.value = 3
|
||||
end
|
||||
}
|
||||
GUI.selected = dt.new_widget("button"){
|
||||
label = _('auto group: selected'),
|
||||
tooltip =_('auto group selected images'),
|
||||
clicked_callback = function() main(false) end
|
||||
}
|
||||
GUI.collection = dt.new_widget("button"){
|
||||
label = _('auto group: collection'),
|
||||
tooltip =_('auto group the entire collection'),
|
||||
clicked_callback = function() main(true) end
|
||||
}
|
||||
|
||||
if dt.gui.current_view().id == "lighttable" then
|
||||
install_module()
|
||||
else
|
||||
if not Ag.event_registered then
|
||||
dt.register_event(
|
||||
"AutoGrouper", "view-changed",
|
||||
function(event, old_view, new_view)
|
||||
if new_view.name == "lighttable" and old_view.name == "darkroom" then
|
||||
install_module()
|
||||
end
|
||||
end
|
||||
)
|
||||
Ag.event_registered = true
|
||||
end
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
script_data.destroy_method = "hide"
|
||||
script_data.restart = restart
|
||||
script_data.show = restart
|
||||
|
||||
return script_data
|
||||
258
contrib/CollectHelper.lua
Normal file
258
contrib/CollectHelper.lua
Normal file
@@ -0,0 +1,258 @@
|
||||
--[[Collect Helper plugin for darktable
|
||||
|
||||
copyright (c) 2019 Kevin Ertel
|
||||
|
||||
darktable is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
darktable is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with darktable. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
|
||||
--[[About this plugin
|
||||
This plugin adds the button(s) to the "Selected Images" module:
|
||||
1) Return to Previous Collection
|
||||
2) Collect on image's Folder
|
||||
3) Collect on image's Color Label(s)
|
||||
4) Collect on All (AND)
|
||||
|
||||
It also adds 3 preferences to the lua options dialog box which allow the user to activate/deactivate the 3 "Collect on" buttons.
|
||||
|
||||
Button Behavior:
|
||||
1) Return to Previous Collection - Will reset the collect parameters to the previously active settings
|
||||
2) Collect on image's Folder - Will change the collect parameters to be "Folder" with a value of the selected image's folder location
|
||||
3) Collect on image's Color Label(s) - Will change the collect parameter to be "Color" with a value of the selected images color labels, will apply multiple parameters with AND logic if multiple exist
|
||||
4) Collect on All (AND) - Will collect on all parameters activated by the preferences dialog, as such this button is redundant if you only have one of the two other options enabled
|
||||
|
||||
----REQUIRED SOFTWARE----
|
||||
NA
|
||||
|
||||
----USAGE----
|
||||
Install: (see here for more detail: https://github.com/darktable-org/lua-scripts )
|
||||
1) Copy this file in to your "lua/contrib" folder where all other scripts reside.
|
||||
2) Require this file in your luarc file, as with any other dt plug-in
|
||||
|
||||
Select the photo you wish to change you collection based on.
|
||||
In the "Selected Images" module click on "Collect on this Image"
|
||||
|
||||
----KNOWN ISSUES----
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local gettext = dt.gettext.gettext
|
||||
local previous = nil
|
||||
local all_active = false
|
||||
|
||||
du.check_min_api_version("7.0.0", "CollectHelper")
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("collection helper"),
|
||||
purpose = _("add collection helper buttons"),
|
||||
author = "Kevin Ertel",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/CollectHelper/"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
-- FUNCTION --
|
||||
local function CheckSingleImage(selection)
|
||||
if #selection ~= 1 then
|
||||
dt.print(_("please select a single image"))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function CheckHasColorLabel(selection)
|
||||
local ret = false
|
||||
for _,image in pairs(selection) do
|
||||
if image.red then ret = true end
|
||||
if image.blue then ret = true end
|
||||
if image.green then ret = true end
|
||||
if image.yellow then ret = true end
|
||||
if image.purple then ret = true end
|
||||
end
|
||||
return ret
|
||||
end
|
||||
local function PreviousCollection()
|
||||
if previous ~= nil then
|
||||
previous = dt.gui.libs.collect.filter(previous)
|
||||
end
|
||||
end
|
||||
|
||||
local function CollectOnFolder(all_rules, all_active)
|
||||
local images = dt.gui.selection()
|
||||
if CheckSingleImage(images) then
|
||||
return
|
||||
end
|
||||
local rules = {}
|
||||
local rule = dt.gui.libs.collect.new_rule()
|
||||
rule.mode = "DT_LIB_COLLECT_MODE_AND"
|
||||
rule.data = images[1].path
|
||||
rule.item = "DT_COLLECTION_PROP_FOLDERS"
|
||||
table.insert(rules, rule)
|
||||
if all_active then
|
||||
for _,active_rule in pairs(rules) do
|
||||
table.insert(all_rules, active_rule)
|
||||
end
|
||||
return all_rules
|
||||
else
|
||||
previous = dt.gui.libs.collect.filter(rules)
|
||||
end
|
||||
end
|
||||
|
||||
local function CollectOnColors(all_rules, all_active)
|
||||
local images = dt.gui.selection()
|
||||
if CheckSingleImage(images) then
|
||||
return
|
||||
end
|
||||
if not CheckHasColorLabel(images) then
|
||||
dt.print(_('select an image with an active color label'))
|
||||
return
|
||||
end
|
||||
for _,image in pairs(images) do
|
||||
local rules = {}
|
||||
if image.red then
|
||||
local red_rule = dt.gui.libs.collect.new_rule()
|
||||
red_rule.mode = "DT_LIB_COLLECT_MODE_AND"
|
||||
red_rule.data = "red"
|
||||
red_rule.item = "DT_COLLECTION_PROP_COLORLABEL"
|
||||
table.insert(rules, red_rule)
|
||||
end
|
||||
if image.blue then
|
||||
local blue_rule = dt.gui.libs.collect.new_rule()
|
||||
blue_rule.mode = "DT_LIB_COLLECT_MODE_AND"
|
||||
blue_rule.data = "blue"
|
||||
blue_rule.item = "DT_COLLECTION_PROP_COLORLABEL"
|
||||
table.insert(rules, blue_rule)
|
||||
end
|
||||
if image.green then
|
||||
local green_rule = dt.gui.libs.collect.new_rule()
|
||||
green_rule.mode = "DT_LIB_COLLECT_MODE_AND"
|
||||
green_rule.data = "green"
|
||||
green_rule.item = "DT_COLLECTION_PROP_COLORLABEL"
|
||||
table.insert(rules, green_rule)
|
||||
end
|
||||
if image.yellow then
|
||||
local yellow_rule = dt.gui.libs.collect.new_rule()
|
||||
yellow_rule.mode = "DT_LIB_COLLECT_MODE_AND"
|
||||
yellow_rule.data = "yellow"
|
||||
yellow_rule.item = "DT_COLLECTION_PROP_COLORLABEL"
|
||||
table.insert(rules, yellow_rule)
|
||||
end
|
||||
if image.purple then
|
||||
local purple_rule = dt.gui.libs.collect.new_rule()
|
||||
purple_rule.mode = "DT_LIB_COLLECT_MODE_AND"
|
||||
purple_rule.data = "purple"
|
||||
purple_rule.item = "DT_COLLECTION_PROP_COLORLABEL"
|
||||
table.insert(rules, purple_rule)
|
||||
end
|
||||
if all_active then
|
||||
for _,active_rule in pairs(rules) do
|
||||
table.insert(all_rules, active_rule)
|
||||
end
|
||||
return all_rules
|
||||
else
|
||||
previous = dt.gui.libs.collect.filter(rules)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function CollectOnAll_AND()
|
||||
local images = dt.gui.selection()
|
||||
if CheckSingleImage(images) then
|
||||
return
|
||||
end
|
||||
local rules = {}
|
||||
if dt.preferences.read('module_CollectHelper','folder','bool') then
|
||||
rules = CollectOnFolder(rules, true)
|
||||
end
|
||||
if dt.preferences.read('module_CollectHelper','colors','bool') then
|
||||
rules = CollectOnColors(rules, true)
|
||||
end
|
||||
previous = dt.gui.libs.collect.filter(rules)
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.gui.libs.image.destroy_action("CollectHelper_prev")
|
||||
if dt.preferences.read('module_CollectHelper','folder','bool') then
|
||||
dt.gui.libs.image.destroy_action("CollectHelper_folder")
|
||||
end
|
||||
if dt.preferences.read('module_CollectHelper','colors','bool') then
|
||||
dt.gui.libs.image.destroy_action("CollectHelper_labels")
|
||||
end
|
||||
if dt.preferences.read('module_CollectHelper','all_and','bool') then
|
||||
dt.gui.libs.image.destroy_action("CollectHelper_and")
|
||||
end
|
||||
end
|
||||
|
||||
-- GUI --
|
||||
dt.gui.libs.image.register_action(
|
||||
"CollectHelper_prev", _("collect: previous"),
|
||||
function() PreviousCollection() end,
|
||||
_("sets the collect parameters to be the previously active parameters")
|
||||
)
|
||||
if dt.preferences.read('module_CollectHelper','folder','bool') then
|
||||
dt.gui.libs.image.register_action(
|
||||
"CollectHelper_folder", _("collect: folder"),
|
||||
function() CollectOnFolder(_ , false) end,
|
||||
_("sets the collect parameters to be the selected images's folder")
|
||||
)
|
||||
end
|
||||
if dt.preferences.read('module_CollectHelper','colors','bool') then
|
||||
dt.gui.libs.image.register_action(
|
||||
"CollectHelper_labels", _("collect: color label(s)"),
|
||||
function() CollectOnColors(_ , false) end,
|
||||
_("sets the collect parameters to be the selected images's color label(s)")
|
||||
)
|
||||
end
|
||||
if dt.preferences.read('module_CollectHelper','all_and','bool') then
|
||||
dt.gui.libs.image.register_action(
|
||||
"CollectHelper_and", _("collect: all (AND)"),
|
||||
function() CollectOnAll_AND() end,
|
||||
_("sets the collect parameters based on all activated CollectHelper options")
|
||||
)
|
||||
end
|
||||
|
||||
-- PREFERENCES --
|
||||
dt.preferences.register("module_CollectHelper", "all_and", -- name
|
||||
"bool", -- type
|
||||
_('CollectHelper: all'), -- label
|
||||
_('creates a collect parameter set that utilizes all enabled CollectHelper types (and)'), -- tooltip
|
||||
true -- default
|
||||
)
|
||||
dt.preferences.register("module_CollectHelper", "colors", -- name
|
||||
"bool", -- type
|
||||
_('CollectHelper: color label(s)'), -- label
|
||||
_('enable the button that allows you to swap to a collection based on selected image\'s color label(s)'), -- tooltip
|
||||
true -- default
|
||||
)
|
||||
dt.preferences.register("module_CollectHelper", "folder", -- name
|
||||
"bool", -- type
|
||||
_('CollectHelper: folder'), -- label
|
||||
_('enable the button that allows you to swap to a collection based on selected image\'s folder location'), -- tooltip
|
||||
true -- default
|
||||
)
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
return script_data
|
||||
486
contrib/HDRMerge.lua
Normal file
486
contrib/HDRMerge.lua
Normal file
@@ -0,0 +1,486 @@
|
||||
--[[HDRMerge plugin for darktable
|
||||
|
||||
copyright (c) 2018 Kevin Ertel
|
||||
|
||||
darktable is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
darktable is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with darktable. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
|
||||
--[[About this Plugin
|
||||
This plugin adds the module 'HDRMerge' to darktable's lighttable view
|
||||
|
||||
----REQUIRED SOFTWARE----
|
||||
HDRMerge ver. 4.5 or greater
|
||||
|
||||
----USAGE----
|
||||
Install: (see here for more detail: https://github.com/darktable-org/lua-scripts )
|
||||
1) Copy this file in to your 'lua/contrib' folder where all other scripts reside.
|
||||
2) Require this file in your luarc file, as with any other dt plug-in
|
||||
On the initial startup go to darktable settings > lua options and set your executable paths and other preferences, then restart darktable
|
||||
|
||||
Select bracketed images and press the Run HDRMerge button. The resulting DNG will be auto-imported into darktable.
|
||||
Additional tags or style can be applied on auto import as well, if you desire.
|
||||
|
||||
Base Options:
|
||||
Select your desired BPS (bits per sample and Embedded Preview Size.
|
||||
|
||||
Batch Options:
|
||||
Select if you want to run in batch mode or not
|
||||
Select the gap, in seconds, between images for auto grouping in batch mode
|
||||
|
||||
See HDRMerge manual for further detail: http://jcelaya.github.io/hdrmerge/documentation/2014/07/11/user-manual.html
|
||||
|
||||
Auto-import Options:
|
||||
Select a style, whether you want tags to be copied from the original, and any additional tags you desire added when the new image is auto-imported
|
||||
]]
|
||||
|
||||
local dt = require 'darktable'
|
||||
local du = require "lib/dtutils"
|
||||
local df = require 'lib/dtutils.file'
|
||||
local dsys = require 'lib/dtutils.system'
|
||||
|
||||
du.check_min_api_version("7.0.0", "HDRmerge")
|
||||
|
||||
-- Tell gettext where to find the .mo file translating messages for a particular domain
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("HDR merge"),
|
||||
purpose = _("merge bracketed images into an HDR DNG image"),
|
||||
author = "Kevin Ertel",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/HDRmerge"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
|
||||
local mod = 'module_HDRMerge'
|
||||
local os_path_seperator = '/'
|
||||
if dt.configuration.running_os == 'windows' then os_path_seperator = '\\' end
|
||||
local CURR_API_STRING = dt.configuration.api_version_string
|
||||
|
||||
local temp
|
||||
local HDRM = { --HDRMerge Program Table
|
||||
name = 'HDRMerge',
|
||||
bin = '',
|
||||
first_run = true,
|
||||
install_error = false,
|
||||
arg_string = '',
|
||||
images_string = '',
|
||||
args = {
|
||||
bps = {text = '-b ', style = 'integer'},
|
||||
size = {text = '-p ', style = 'string'},
|
||||
batch = {text = '-B ', style = 'bool'},
|
||||
gap = {text = '-g ', style = 'integer'}
|
||||
}
|
||||
}
|
||||
local GUI = { --GUI Elements Table
|
||||
HDR = {
|
||||
bps ={},
|
||||
size ={},
|
||||
batch ={},
|
||||
gap ={}
|
||||
},
|
||||
Target = {
|
||||
style ={},
|
||||
copy_tags ={},
|
||||
add_tags ={}
|
||||
},
|
||||
run = {},
|
||||
stack = {},
|
||||
options = {},
|
||||
exes = {
|
||||
HDRMerge = {},
|
||||
update = {},
|
||||
}
|
||||
}
|
||||
|
||||
HDRM.module_installed = false
|
||||
HDRM.event_registered = false
|
||||
|
||||
|
||||
--Detect User Styles--
|
||||
local styles = dt.styles
|
||||
local styles_count = 1 -- 'none' = 1
|
||||
for _,i in pairs(dt.styles) do
|
||||
if type(i) == 'userdata' then styles_count = styles_count + 1 end
|
||||
end
|
||||
|
||||
local function InRange(test, low, high) --tests if test value is within range of low and high (inclusive)
|
||||
if test >= low and test <= high then
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
local function GetFileName(full_path) --Parses a full path (path/filename_identifier.extension) into individual parts
|
||||
--[[Input: Folder1/Folder2/Folder3/Img_0001.CR2
|
||||
|
||||
Returns:
|
||||
path: Folder1/Folder2/Folder3/
|
||||
filename: Img_0001
|
||||
identifier: 0001
|
||||
extension: .CR2
|
||||
|
||||
EX:
|
||||
path_1, file_1, id_1, ext_1 = GetFileName(full_path_1)
|
||||
]]
|
||||
local path = string.match(full_path, '.*[\\/]')
|
||||
local filename = string.gsub(string.match(full_path, '[%w-_]*%.') , '%.' , '' )
|
||||
local identifier = string.match(filename, '%d*$')
|
||||
local extension = string.match(full_path, '%.%w*')
|
||||
return path, filename, identifier, extension
|
||||
end
|
||||
|
||||
local function CleanSpaces(text) --removes spaces from the front and back of passed in text
|
||||
text = string.gsub(text,'^%s*','')
|
||||
text = string.gsub(text,'%s*$','')
|
||||
return text
|
||||
end
|
||||
|
||||
local function BuildExecuteCmd(prog_table) --creates a program command using elements of the passed in program table
|
||||
local result = CleanSpaces(prog_table.bin)..' '..CleanSpaces(prog_table.arg_string)..' '..CleanSpaces(prog_table.images_string)
|
||||
return result
|
||||
end
|
||||
|
||||
local function PreCall(prog_tbl) --looks to see if this is the first call, if so checks to see if program is installed properly
|
||||
for _,prog in pairs(prog_tbl) do
|
||||
if prog.first_run then
|
||||
prog.bin = df.check_if_bin_exists(prog.name)
|
||||
if not prog.bin then
|
||||
prog.install_error = true
|
||||
dt.preferences.write(mod, 'bin_exists', 'bool', false)
|
||||
else
|
||||
prog.bin = CleanSpaces(prog.bin)
|
||||
end
|
||||
prog.first_run = false
|
||||
end
|
||||
end
|
||||
if not dt.preferences.read(mod, 'bin_exists', 'bool') then
|
||||
GUI.stack.active = 2
|
||||
dt.print(_('please update you binary location'))
|
||||
end
|
||||
end
|
||||
|
||||
local function ExeUpdate(prog_tbl)
|
||||
dt.preferences.write(mod, 'bin_exists', 'bool', true)
|
||||
for _,prog in pairs(prog_tbl) do
|
||||
dt.preferences.write('executable_paths', prog.name, 'string', GUI.exes[prog.name].value)
|
||||
prog.bin = df.check_if_bin_exists(prog.name)
|
||||
if not prog.bin then
|
||||
prog.install_error = true
|
||||
dt.preferences.write(mod, 'bin_exists', 'bool', false)
|
||||
else
|
||||
prog.bin = CleanSpaces(prog.bin)
|
||||
end
|
||||
prog.first_run = false
|
||||
end
|
||||
if dt.preferences.read(mod, 'bin_exists', 'bool') then
|
||||
GUI.stack.active = 1
|
||||
dt.print(_('update successful'))
|
||||
else
|
||||
dt.print(_('update unsuccessful, please try again'))
|
||||
end
|
||||
end
|
||||
|
||||
local function UpdateActivePreference() --sliders & entry boxes do not have a click/changed callback, so their values must be saved to the active preference
|
||||
temp = GUI.HDR.gap.value
|
||||
dt.preferences.write(mod, 'active_gap', 'integer', temp)
|
||||
temp = GUI.Target.add_tags.text
|
||||
dt.preferences.write(mod, 'active_add_tags', 'string', temp)
|
||||
end
|
||||
|
||||
local function main()
|
||||
PreCall({HDRM}) --check if furst run then check if install OK
|
||||
if HDRM.install_error then
|
||||
dt.print_error('HDRMerge install issue')
|
||||
dt.print(_('HDRMerge install issue, please ensure the binary path is correct'))
|
||||
return
|
||||
end
|
||||
images = dt.gui.selection() --get selected images
|
||||
if #images < 2 then --ensure enough images selected
|
||||
dt.print(_('not enough images selected, select at least 2 images to merge'))
|
||||
return
|
||||
end
|
||||
|
||||
UpdateActivePreference() --save current gui elements to active preference so those values will be pre-loaded at next startup
|
||||
|
||||
--create image string and output path
|
||||
HDRM.images_string = ''
|
||||
local out_path = ''
|
||||
local smallest_id = math.huge
|
||||
local smallest_name = ''
|
||||
local largest_id = 0
|
||||
local source_raw = {}
|
||||
for _,image in pairs(images) do --loop to concat the images string, also track the image indexes for use in creating the final image name (eg; IMG_1034-1037.dng)
|
||||
local curr_image = image.path..os_path_seperator..image.filename
|
||||
HDRM.images_string = HDRM.images_string..df.sanitize_filename(curr_image)..' '
|
||||
out_path = image.path
|
||||
_unused, source_name, source_id = GetFileName(image.filename)
|
||||
source_id = tonumber(source_id) or 0
|
||||
if source_id < smallest_id then
|
||||
smallest_id = source_id
|
||||
smallest_name = source_name
|
||||
source_raw = image
|
||||
end
|
||||
if source_id > largest_id then largest_id = source_id end
|
||||
end
|
||||
out_path = out_path..os_path_seperator..smallest_name..'-'..largest_id..'.dng'
|
||||
out_path = df.create_unique_filename(out_path)
|
||||
|
||||
--create argument string
|
||||
HDRM.arg_string = HDRM.args.bps.text..GUI.HDR.bps.value..' '..HDRM.args.size.text..GUI.HDR.size.value..' '
|
||||
if GUI.HDR.batch.value then
|
||||
HDRM.arg_string = HDRM.arg_string..HDRM.args.batch.text..HDRM.args.gap.text..math.floor(GUI.HDR.gap.value)..' -a'
|
||||
else
|
||||
HDRM.arg_string = HDRM.arg_string..'-o '..df.sanitize_filename(out_path)
|
||||
end
|
||||
|
||||
-- create run command and execute
|
||||
local run_cmd = BuildExecuteCmd(HDRM)
|
||||
resp = dsys.external_command(run_cmd)
|
||||
|
||||
if resp == 0 and not GUI.HDR.batch.value then
|
||||
local imported = dt.database.import(out_path) -- import the new file
|
||||
if GUI.Target.style.selected > 1 then -- apply selected style
|
||||
local set_style = styles[GUI.Target.style.selected - 1]
|
||||
dt.styles.apply(set_style , imported)
|
||||
end
|
||||
if GUI.Target.copy_tags.value then -- copy tags from the original file (ignore 'darktable' generated tags)
|
||||
local all_tags = dt.tags.get_tags(source_raw)
|
||||
for _,tag in pairs(all_tags) do
|
||||
if string.match(tag.name, 'darktable|') == nil then dt.tags.attach(tag, imported) end
|
||||
end
|
||||
end
|
||||
local set_tag = GUI.Target.add_tags.text
|
||||
if set_tag ~= nil then -- add additional user-specified tags
|
||||
for tag in string.gmatch(set_tag, '[^,]+') do
|
||||
tag = CleanSpaces(tag)
|
||||
tag = dt.tags.create(tag)
|
||||
dt.tags.attach(tag, imported)
|
||||
end
|
||||
end
|
||||
dt.print(_('HDRMerge completed successfully'))
|
||||
else
|
||||
dt.print_error('HDRMerge failed')
|
||||
dt.print(_('HDRMerge failed'))
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
local function install_module()
|
||||
if not HDRM.module_installed then
|
||||
dt.register_lib( -- register HDRMerge module
|
||||
'HDRMerge_Lib', -- Module name
|
||||
_('HDRMerge'), -- name
|
||||
true, -- expandable
|
||||
true, -- resetable
|
||||
{[dt.gui.views.lighttable] = {'DT_UI_CONTAINER_PANEL_RIGHT_CENTER', 99}}, -- containers
|
||||
dt.new_widget('box'){
|
||||
orientation = 'vertical',
|
||||
GUI.stack
|
||||
}
|
||||
)
|
||||
HDRM.module_installed = true
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.gui.libs["HDRMerge_Lib"].visible = false
|
||||
end
|
||||
|
||||
local function restart()
|
||||
dt.gui.libs["HDRMerge_Lib"].visible = true
|
||||
end
|
||||
|
||||
-- GUI Elements --
|
||||
local lbl_hdr = dt.new_widget('section_label'){
|
||||
label = _('HDRMerge options')
|
||||
}
|
||||
temp = dt.preferences.read(mod, 'active_bps_ind', 'integer')
|
||||
if not InRange(temp, 1, 3) then temp = 3 end
|
||||
GUI.HDR.bps = dt.new_widget('combobox'){
|
||||
label = _('bits per sample'),
|
||||
tooltip =_('number of bits per sample in the output image'),
|
||||
selected = temp,
|
||||
'16','24','32',
|
||||
changed_callback = function(self)
|
||||
dt.preferences.write(mod, 'active_bps', 'integer', self.value)
|
||||
dt.preferences.write(mod, 'active_bps_ind', 'integer', self.selected)
|
||||
end,
|
||||
reset_callback = function(self)
|
||||
self.selected = 3
|
||||
dt.preferences.write(mod, 'active_bps', 'integer', self.value)
|
||||
dt.preferences.write(mod, 'active_bps_ind', 'integer', self.selected)
|
||||
end
|
||||
}
|
||||
temp = dt.preferences.read(mod, 'active_size_ind', 'integer')
|
||||
if not InRange(temp, 1, 3) then temp = 2 end
|
||||
GUI.HDR.size = dt.new_widget('combobox'){
|
||||
label = _('embedded preview size'),
|
||||
tooltip =_('size of the embedded preview in output image'),
|
||||
selected = temp,
|
||||
_('none'),_('half'),_('full'),
|
||||
changed_callback = function(self)
|
||||
dt.preferences.write(mod, 'active_size', 'string', self.value)
|
||||
dt.preferences.write(mod, 'active_size_ind', 'integer', self.selected)
|
||||
end,
|
||||
reset_callback = function(self)
|
||||
self.selected = 2
|
||||
dt.preferences.write(mod, 'active_size', 'string', self.value)
|
||||
dt.preferences.write(mod, 'active_size_ind', 'integer', self.selected)
|
||||
end
|
||||
}
|
||||
GUI.HDR.batch = dt.new_widget('check_button'){
|
||||
label = _('batch mode'),
|
||||
value = dt.preferences.read(mod, 'active_batch', 'bool'),
|
||||
tooltip = _('enable batch mode operation \nNOTE: resultant files will NOT be auto-imported'),
|
||||
clicked_callback = function(self)
|
||||
dt.preferences.write(mod, 'active_batch', 'bool', self.value)
|
||||
GUI.HDR.gap.sensitive = self.value
|
||||
end,
|
||||
reset_callback = function(self) self.value = false end
|
||||
}
|
||||
temp = dt.preferences.read(mod, 'active_gap', 'integer')
|
||||
if not InRange(temp, 1, 3600) then temp = 3 end
|
||||
GUI.HDR.gap = dt.new_widget('slider'){
|
||||
label = _('batch gap [sec.]'),
|
||||
tooltip = _('gap, in seconds, between batch mode groups'),
|
||||
soft_min = 1,
|
||||
soft_max = 30,
|
||||
hard_min = 1,
|
||||
hard_max = 3600,
|
||||
step = 1,
|
||||
digits = 0,
|
||||
value = temp,
|
||||
sensitive = GUI.HDR.batch.value,
|
||||
reset_callback = function(self)
|
||||
self.value = 3
|
||||
end
|
||||
}
|
||||
local lbl_import = dt.new_widget('section_label'){
|
||||
label = _('import options')
|
||||
}
|
||||
GUI.Target.style = dt.new_widget('combobox'){
|
||||
label = _('apply style on import'),
|
||||
tooltip = _('apply selected style on auto-import to newly created image'),
|
||||
selected = 1,
|
||||
_('none'),
|
||||
changed_callback = function(self)
|
||||
dt.preferences.write(mod, 'active_style', 'string', self.value)
|
||||
dt.preferences.write(mod, 'active_style_ind', 'integer', self.selected)
|
||||
end,
|
||||
reset_callback = function(self)
|
||||
self.selected = 1
|
||||
dt.preferences.write(mod, 'active_style', 'string', self.value)
|
||||
dt.preferences.write(mod, 'active_style_ind', 'integer', self.selected)
|
||||
end
|
||||
}
|
||||
for k=1, (styles_count-1) do
|
||||
GUI.Target.style[k+1] = styles[k].name
|
||||
end
|
||||
temp = dt.preferences.read(mod, 'active_style_ind', 'integer')
|
||||
if not InRange(temp, 1, styles_count) then temp = 1 end
|
||||
GUI.Target.style.selected = temp
|
||||
GUI.Target.copy_tags = dt.new_widget('check_button'){
|
||||
label = _('copy tags'),
|
||||
value = dt.preferences.read(mod, 'active_copy_tags', 'bool'),
|
||||
tooltip = _('copy tags from first source image'),
|
||||
clicked_callback = function(self) dt.preferences.write(mod, 'active_copy_tags', 'bool', self.value) end,
|
||||
reset_callback = function(self) self.value = true end
|
||||
}
|
||||
temp = dt.preferences.read(mod, 'active_add_tags', 'string')
|
||||
if temp == '' then temp = nil end
|
||||
GUI.Target.add_tags = dt.new_widget('entry'){
|
||||
tooltip = _('additional tags to be added on import, separate with commas, all spaces will be removed'),
|
||||
text = temp,
|
||||
placeholder = _('enter tags, separated by commas'),
|
||||
editable = true
|
||||
}
|
||||
GUI.run = dt.new_widget('button'){
|
||||
label = _('merge'),
|
||||
tooltip =_('run HDRMerge with the above settings'),
|
||||
clicked_callback = function() main() end
|
||||
}
|
||||
GUI.exes.HDRMerge = dt.new_widget('file_chooser_button'){
|
||||
title = _('select HDRmerge executable'),
|
||||
value = df.get_executable_path_preference(HDRM.name),
|
||||
is_directory = false
|
||||
}
|
||||
GUI.exes.update = dt.new_widget('button'){
|
||||
label = _('update'),
|
||||
tooltip =_('update the binary path with current value'),
|
||||
clicked_callback = function() ExeUpdate({HDRM}) end
|
||||
}
|
||||
GUI.options = dt.new_widget('box'){
|
||||
orientation = 'vertical',
|
||||
lbl_hdr,
|
||||
GUI.HDR.bps,
|
||||
GUI.HDR.size,
|
||||
GUI.HDR.batch,
|
||||
GUI.HDR.gap,
|
||||
lbl_import,
|
||||
GUI.Target.style,
|
||||
GUI.Target.copy_tags,
|
||||
GUI.Target.add_tags,
|
||||
GUI.run
|
||||
}
|
||||
local exes_box = dt.new_widget('box'){
|
||||
orientation = 'vertical',
|
||||
GUI.exes.HDRMerge,
|
||||
GUI.exes.update
|
||||
}
|
||||
GUI.stack = dt.new_widget('stack'){
|
||||
GUI.options,
|
||||
exes_box
|
||||
}
|
||||
if dt.preferences.read(mod, 'bin_exists', 'bool') then
|
||||
GUI.stack.active = 1
|
||||
else
|
||||
GUI.stack.active = 2
|
||||
end
|
||||
|
||||
if dt.gui.current_view().id == "lighttable" then
|
||||
install_module()
|
||||
else
|
||||
if not HDRM.event_registered then
|
||||
dt.register_event(
|
||||
"HDRmerge", "view-changed",
|
||||
function(event, old_view, new_view)
|
||||
if new_view.name == "lighttable" and old_view.name == "darkroom" then
|
||||
install_module()
|
||||
end
|
||||
end
|
||||
)
|
||||
HDRM.event_registered = true
|
||||
end
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
script_data.restart = restart
|
||||
script_data.destroy_method = "hide"
|
||||
script_data.show = restart
|
||||
|
||||
return script_data
|
||||
296
contrib/LabelsToTags.lua
Normal file
296
contrib/LabelsToTags.lua
Normal file
@@ -0,0 +1,296 @@
|
||||
--[[
|
||||
LABELS TO TAGS
|
||||
Allows the mass-application of tags using color labels and ratings
|
||||
as a guide.
|
||||
|
||||
AUTHOR
|
||||
August Schwerdfeger (august@schwerdfeger.name)
|
||||
|
||||
INSTALLATION
|
||||
* Copy this file into $CONFIGDIR/lua/, where CONFIGDIR
|
||||
is your darktable configuration directory
|
||||
* Add the following line in the file $CONFIGDIR/luarc:
|
||||
require "LabelsToTags"
|
||||
|
||||
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
None.
|
||||
|
||||
USAGE
|
||||
In your 'luarc' file or elsewhere, use the function
|
||||
'register_tag_mapping', defined in this module, to specify
|
||||
one or more tag mappings for use by the module.
|
||||
Any mappings so registered will be selectable, according
|
||||
to their given names, in the module's "mapping" combo box.
|
||||
|
||||
A mapping takes the form of a table mapping patterns to
|
||||
lists of tags. A pattern consists of 6 characters, of which
|
||||
the first five represent color labels and the last the rating.
|
||||
Each color label character may be '+', '-', or '*',
|
||||
indicating that for this pattern to match, the corresponding
|
||||
color label, respectively, must be on, must be off, or can be
|
||||
either. Similarly, the rating character may be a numeral
|
||||
between 0 and 5, "R" for rejected, or "*" for "any value."
|
||||
|
||||
An example call to 'register_tag_mapping' is provided in a
|
||||
comment at the end of this file.
|
||||
|
||||
When the "Start" button is pressed, the module will
|
||||
iterate over each selected image and check the state of
|
||||
that image's color labels and rating against each pattern
|
||||
defined in the selected mapping. For each pattern that
|
||||
matches, the corresponding tags will be added to the
|
||||
image. Any such tag not already existing in the database
|
||||
will be created.
|
||||
|
||||
LICENSE
|
||||
LGPLv2+
|
||||
|
||||
]]
|
||||
|
||||
local darktable = require("darktable")
|
||||
local du = require "lib/dtutils"
|
||||
|
||||
du.check_min_api_version("7.0.0", "LabelsToTags")
|
||||
|
||||
local gettext = darktable.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("labels to tags"),
|
||||
purpose = _("allows the mass-application of tags using color labels and ratings as a guide"),
|
||||
author = "August Schwerdfeger (august@schwerdfeger.name)",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/LabelsToTags"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
-- Lua 5.3 no longer has "unpack" but "table.unpack"
|
||||
unpack = unpack or table.unpack
|
||||
|
||||
local ltt = {}
|
||||
ltt.module_installed = false
|
||||
ltt.event_registered = false
|
||||
|
||||
local LIB_ID = _("LabelsToTags")
|
||||
|
||||
-- Helper functions: BEGIN
|
||||
|
||||
local function keySet(t)
|
||||
local rv = {}
|
||||
for k,_ in pairs(t) do
|
||||
table.insert(rv,k)
|
||||
end
|
||||
table.sort(rv)
|
||||
return(rv)
|
||||
end
|
||||
|
||||
local function generateLabelHash(img)
|
||||
local hash = ""
|
||||
hash = hash .. (img.red and "+" or "-")
|
||||
hash = hash .. (img.yellow and "+" or "-")
|
||||
hash = hash .. (img.green and "+" or "-")
|
||||
hash = hash .. (img.blue and "+" or "-")
|
||||
hash = hash .. (img.purple and "+" or "-")
|
||||
hash = hash .. (img.rating == -1 and "R" or tostring(img.rating))
|
||||
return(hash)
|
||||
end
|
||||
|
||||
local function hashMatch(hash,pattern)
|
||||
if #(hash) ~= #(pattern) then return(false) end
|
||||
for i = 0,#hash do
|
||||
if string.sub(hash,i,i) ~= string.sub(pattern,i,i) and
|
||||
string.sub(pattern,i,i) ~= "*" then
|
||||
return(false)
|
||||
end
|
||||
end
|
||||
return(true)
|
||||
end
|
||||
|
||||
-- Helper functions: END
|
||||
|
||||
|
||||
|
||||
|
||||
local initialAvailableMappings = {
|
||||
[_("colors")] = { ["+*****"] = { _("red") },
|
||||
["*+****"] = { _("yellow") },
|
||||
["**+***"] = { _("green") },
|
||||
["***+**"] = { _("blue") },
|
||||
["****+*"] = { _("purple") } },
|
||||
[_("single colors")] = { ["+----*"] = { _("red"), _("only red") },
|
||||
["-+---*"] = { _("yellow"), _("only yellow") },
|
||||
["--+--*"] = { _("green"), _("only green") },
|
||||
["---+-*"] = { _("blue"), _("only blue") },
|
||||
["----+*"] = { _("purple"), _("only purple") } },
|
||||
[_("ratings")] = { ["*****0"] = { _("no stars"), _("not rejected") },
|
||||
["*****1"] = { _("one star"), _("not rejected") },
|
||||
["*****2"] = { _("two stars"), _("not rejected") },
|
||||
["*****3"] = { _("three stars"), _("not rejected") },
|
||||
["*****4"] = { _("four stars"), _("not rejected") },
|
||||
["*****5"] = { _("five stars"), _("not rejected") },
|
||||
["*****R"] = { _("rejected") } }
|
||||
}
|
||||
|
||||
local availableMappings = {}
|
||||
|
||||
local function getAvailableMappings()
|
||||
if availableMappings == nil or next(availableMappings) == nil then
|
||||
return(initialAvailableMappings)
|
||||
else
|
||||
return(availableMappings)
|
||||
end
|
||||
end
|
||||
|
||||
local function getComboboxTooltip()
|
||||
if availableMappings == nil or next(availableMappings) == nil then
|
||||
return(_("no registered mappings -- using defaults"))
|
||||
else
|
||||
return(_("select a label-to-tag mapping"))
|
||||
end
|
||||
end
|
||||
|
||||
local mappingComboBox = darktable.new_widget("combobox"){
|
||||
label = _("mapping"),
|
||||
value = 1,
|
||||
tooltip = getComboboxTooltip(),
|
||||
reset_callback = function(selfC)
|
||||
if selfC == nil then
|
||||
return
|
||||
end
|
||||
i = 1
|
||||
for _,m in pairs(keySet(getAvailableMappings())) do
|
||||
selfC[i] = m
|
||||
i = i+1
|
||||
end
|
||||
n = #selfC
|
||||
for j = i,n do
|
||||
selfC[i] = nil
|
||||
end
|
||||
selfC.value = 1
|
||||
selfC.tooltip = getComboboxTooltip()
|
||||
end,
|
||||
unpack(keySet(getAvailableMappings()))
|
||||
}
|
||||
|
||||
local function doTagging(selfC)
|
||||
local job = darktable.gui.create_job(string.format(_("labels to tags (%d image%s)"), #(darktable.gui.action_images), (#(darktable.gui.action_images) == 1 and "" or "s")), true)
|
||||
job.percent = 0.0
|
||||
local pctIncrement = 1.0 / #(darktable.gui.action_images)
|
||||
|
||||
local availableMappings = getAvailableMappings()
|
||||
local memoizedTags = {}
|
||||
for _,img in ipairs(darktable.gui.action_images) do
|
||||
local tagsToApply = {}
|
||||
local hash = generateLabelHash(img)
|
||||
for k,v in pairs(availableMappings[mappingComboBox.value]) do
|
||||
if hashMatch(hash,k) then
|
||||
for _,tag in ipairs(v) do
|
||||
tagsToApply[tag] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
for k,_ in pairs(tagsToApply) do
|
||||
if memoizedTags[k] == nil then
|
||||
memoizedTags[k] = darktable.tags.create(k)
|
||||
end
|
||||
darktable.tags.attach(memoizedTags[k],img)
|
||||
end
|
||||
job.percent = job.percent + pctIncrement
|
||||
end
|
||||
job.valid = false
|
||||
end
|
||||
|
||||
ltt.my_widget = darktable.new_widget("box") {
|
||||
orientation = "vertical",
|
||||
mappingComboBox,
|
||||
darktable.new_widget("button") {
|
||||
label = _("start"),
|
||||
tooltip = _("tag all selected images"),
|
||||
clicked_callback = doTagging
|
||||
}
|
||||
}
|
||||
|
||||
local PATTERN_PATTERN = "^[+*-][+*-][+*-][+*-][+*-][0-5R*]$"
|
||||
|
||||
darktable.register_tag_mapping = function(name, mapping)
|
||||
if availableMappings[name] ~= nil then
|
||||
darktable.print_error("Tag mapping '" .. name .. "' already registered")
|
||||
return
|
||||
end
|
||||
for pattern,tags in pairs(mapping) do
|
||||
if string.match(pattern,PATTERN_PATTERN) == nil then
|
||||
darktable.print_error("In tag mapping '" .. name .. "': Pattern '" .. pattern .. "' invalid")
|
||||
return
|
||||
end
|
||||
for _,tag in ipairs(tags) do
|
||||
if type(tag) ~= "string" then
|
||||
darktable.print_error("In tag mapping '" .. name .. "': All tag mappings must be lists of strings")
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
availableMappings[name] = mapping
|
||||
mappingComboBox.reset_callback(mappingComboBox)
|
||||
end
|
||||
|
||||
local function install_module()
|
||||
if not ltt.module_installed then
|
||||
darktable.register_lib(LIB_ID,_("labels to tags"),true,true,{
|
||||
[darktable.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER",20},
|
||||
},ltt.my_widget,nil,nil)
|
||||
ltt.module_installed = true
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
darktable.gui.libs[LIB_ID].visible = false
|
||||
end
|
||||
|
||||
local function restart()
|
||||
darktable.gui.libs[LIB_ID].visible = true
|
||||
end
|
||||
|
||||
--[[
|
||||
darktable.register_tag_mapping("Example",
|
||||
{ ["+----*"] = { "Red", "Only red" },
|
||||
["-+---*"] = { "Yellow", "Only yellow" },
|
||||
["--+--*"] = { "Green", "Only green" },
|
||||
["---+-*"] = { "Blue", "Only blue" },
|
||||
["****+*"] = { "Purple" },
|
||||
["----+*"] = { "Only purple" },
|
||||
["*****1"] = { "One star" },
|
||||
["*****R"] = { "Rejected" } })
|
||||
]]
|
||||
|
||||
if darktable.gui.current_view().id == "lighttable" then
|
||||
install_module()
|
||||
else
|
||||
if not ltt.event_registered then
|
||||
darktable.register_event(
|
||||
LIB_ID, "view-changed",
|
||||
function(event, old_view, new_view)
|
||||
if new_view.name == "lighttable" and old_view.name == "darkroom" then
|
||||
install_module()
|
||||
end
|
||||
end
|
||||
)
|
||||
ltt.event_registered = true
|
||||
end
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
script_data.restart = restart
|
||||
script_data.destroy_method = "hide"
|
||||
script_data.show = restart
|
||||
|
||||
return script_data
|
||||
261
contrib/OpenInExplorer.lua
Normal file
261
contrib/OpenInExplorer.lua
Normal file
@@ -0,0 +1,261 @@
|
||||
--[[
|
||||
OpenInExplorer plugin for darktable
|
||||
|
||||
copyright (c) 2018 Kevin Ertel
|
||||
Update 2020 and macOS support by Volker Lenhardt
|
||||
Linux support 2020 by Bill Ferguson
|
||||
|
||||
darktable is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
darktable is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with darktable. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
|
||||
--[[About this plugin
|
||||
This plugin adds the module "OpenInExplorer" to darktable's lighttable view.
|
||||
|
||||
----REQUIRED SOFTWARE----
|
||||
Apple macOS, Microsoft Windows or Linux
|
||||
|
||||
----USAGE----
|
||||
Install: (see here for more detail: https://github.com/darktable-org/lua-scripts )
|
||||
1) Copy this file into your "lua/contrib" folder where all other scripts reside.
|
||||
2) Require this file in your luarc file, as with any other dt plug-in
|
||||
|
||||
Select the photo(s) you wish to find in your operating system's file manager and press "show in file explorer" in the "selected images" section.
|
||||
|
||||
- Nautilus (Linux), Explorer (Windows), and Finder (macOS prior to Mojave) will open one window for each selected image at the file's location. The file name will be highlighted.
|
||||
|
||||
- On macOS Mojave and Catalina the Finder will open one window for each different directory. In these windows only the last one of the corresponding files will be highlighted (bug or feature?).
|
||||
|
||||
- Dolphin (Linux) will open one window with tabs for the different directories. All the selected images' file names are highlighted in their respective directories.
|
||||
|
||||
As an alternative option you can choose to show the image file names as symbolic links in an arbitrary directory. Go to preferences|Lua options. This option is not available for Windows users as on Windows solely admins are allowed to create links.
|
||||
|
||||
- Pros: You do not clutter up your display with multiple windows. So there is no need to limit the number of selections.
|
||||
|
||||
- Cons: If you want to work with the files you are one step behind the original data.
|
||||
|
||||
----KNOWN ISSUES----
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
local dsys = require "lib/dtutils.system"
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
--Check API version
|
||||
du.check_min_api_version("7.0.0", "OpenInExplorer")
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("open in explorer"),
|
||||
purpose = _("open a selected file in the system file manager"),
|
||||
author = "Kevin Ertel",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/OpenInExplorer"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
local act_os = dt.configuration.running_os
|
||||
local PS = act_os == "windows" and "\\" or "/"
|
||||
|
||||
--Detect OS and quit if it is not supported.
|
||||
if act_os ~= "macos" and act_os ~= "windows" and act_os ~= "linux" then
|
||||
dt.print(_("OpenInExplorer plug-in only supports linux, macos, and windows at this time"))
|
||||
dt.print_error("OpenInExplorer plug-in only supports Linux, macOS, and Windows at this time")
|
||||
return
|
||||
end
|
||||
|
||||
local use_links, links_dir = false, ""
|
||||
if act_os ~= "windows" then
|
||||
use_links = dt.preferences.read("OpenInExplorer", "use_links", "bool")
|
||||
links_dir = dt.preferences.read("OpenInExplorer", "linked_image_files_dir", "string")
|
||||
end
|
||||
|
||||
--Check if the directory exists that was chosen for the file links. Return boolean.
|
||||
local function check_if_links_dir_exists()
|
||||
local dir_exists = true
|
||||
if not links_dir then
|
||||
--Just for paranoic reasons. I tried, but I couldn't devise a setting for a nil value.
|
||||
dt.print(_("no links directory selected\nplease check the dt preferences (lua options)"))
|
||||
dt.print_error("OpenInExplorer: No links directory selected")
|
||||
dir_exists = false
|
||||
elseif not df.check_if_file_exists(links_dir) then
|
||||
dt.print(string.format(_("links directory '%s' not found\nplease check the dt preferences (lua options)"), links_dir))
|
||||
dt.print_error(string.format("OpenInExplorer: Links directory '%s' not found", links_dir))
|
||||
dir_exists = false
|
||||
end
|
||||
return dir_exists
|
||||
end
|
||||
|
||||
--Format strings for the commands to open the corresponding OS's file manager.
|
||||
local open_dir = {}
|
||||
open_dir.windows = "explorer.exe /n, %s"
|
||||
open_dir.macos = "open %s"
|
||||
open_dir.linux = [[busctl --user call org.freedesktop.FileManager1 /org/freedesktop/FileManager1 org.freedesktop.FileManager1 ShowFolders ass 1 %s ""]]
|
||||
|
||||
local open_files = {}
|
||||
open_files.windows = "explorer.exe /select, %s"
|
||||
open_files.macos = "osascript -e 'tell application \"Finder\" to (reveal {%s}) activate'"
|
||||
open_files.linux = [[busctl --user call org.freedesktop.FileManager1 /org/freedesktop/FileManager1 org.freedesktop.FileManager1 ShowItems ass %d %s ""]]
|
||||
|
||||
reveal_file_osx_cmd = "\"%s\" as POSIX file"
|
||||
|
||||
--Call the osx Finder with each selected image selected.
|
||||
--For images in multiple folders, Finder will open a separate window for each
|
||||
local function call_list_of_files_osx(selected_images)
|
||||
local cmds = {}
|
||||
for _, image in pairs(selected_images) do
|
||||
current_image = image.path..PS..image.filename
|
||||
-- AppleScript needs double quoted strings, and the whole command is wrapped in single quotes.
|
||||
table.insert(cmds, string.format(reveal_file_osx_cmd, string.gsub(string.gsub(current_image, "\"", "\\\""), "'", "'\"'\"'")))
|
||||
end
|
||||
reveal_cmd = table.concat(cmds, ",")
|
||||
run_cmd = string.format(open_files.macos, reveal_cmd)
|
||||
dt.print_log("OSX run_cmd = "..run_cmd)
|
||||
dsys.external_command(run_cmd)
|
||||
end
|
||||
|
||||
--Call the file mangager for each selected image on Linux.
|
||||
--There is one call to busctl containing a list of all the image file names.
|
||||
local function call_list_of_files(selected_images)
|
||||
local current_image, file_uris, run_cmd = "", "", ""
|
||||
for _, image in pairs(selected_images) do
|
||||
current_image = image.path..PS..image.filename
|
||||
file_uris = file_uris .. df.sanitize_filename("file://" .. current_image) .. " "
|
||||
dt.print_log("file_uris is " .. file_uris)
|
||||
end
|
||||
run_cmd = string.format(open_files.linux, #selected_images, file_uris)
|
||||
dt.print_log("OpenInExplorer run_cmd = "..run_cmd)
|
||||
dsys.external_command(run_cmd)
|
||||
end
|
||||
|
||||
--Call the file manager for each selected image on Windows and macOS.
|
||||
local function call_file_by_file(selected_images)
|
||||
local current_image, run_cmd = "", ""
|
||||
for _, image in pairs(selected_images) do
|
||||
current_image = image.path..PS..image.filename
|
||||
run_cmd = string.format(open_files[act_os], df.sanitize_filename(current_image))
|
||||
dt.print_log("OpenInExplorer run_cmd = "..run_cmd)
|
||||
dsys.external_command(run_cmd)
|
||||
end
|
||||
end
|
||||
|
||||
--Create a link for each selected image, and finally call the file manager.
|
||||
local function set_links(selected_images)
|
||||
local current_image, link_target, run_cmd, k = "", "", "", nil
|
||||
for k, image in pairs(selected_images) do
|
||||
current_image = image.path..PS..image.filename
|
||||
link_target = df.create_unique_filename(links_dir .. PS .. image.filename)
|
||||
run_cmd = string.format("ln -s %s %s", df.sanitize_filename(current_image), df.sanitize_filename(link_target))
|
||||
--[[
|
||||
In case Windows will allow normal users to create soft links:
|
||||
if act_os == "windows" then
|
||||
run_cmd = string.format("mklink %s %s", df.sanitize_filename(link_target), df.sanitize_filename(current_image))
|
||||
end
|
||||
]]
|
||||
if dsys.external_command(run_cmd) ~= 0 then
|
||||
dt.print(_("failed to create links, missing rights?"))
|
||||
dt.print_error("OpenInExplorer: Failed to create links")
|
||||
return
|
||||
end
|
||||
end
|
||||
--The URI format is necessary only for the Linux busctl command.
|
||||
--But it is accepted by the Windows Explorer and macOS's Finder all the same.
|
||||
run_cmd = string.format(open_dir[act_os], df.sanitize_filename("file://"..links_dir))
|
||||
dt.print_log("OpenInExplorer run_cmd = "..run_cmd)
|
||||
dsys.external_command(run_cmd)
|
||||
end
|
||||
|
||||
--The working function that starts the particular task.
|
||||
local function open_in_fmanager()
|
||||
local images = dt.gui.selection()
|
||||
if #images == 0 then
|
||||
dt.print(_("please select an image"))
|
||||
else
|
||||
if use_links and not check_if_links_dir_exists() then
|
||||
return
|
||||
end
|
||||
if #images > 15 and not use_links then
|
||||
dt.print(_("please select fewer images (max. 15)"))
|
||||
elseif use_links then
|
||||
set_links(images)
|
||||
else
|
||||
if act_os == "linux" then
|
||||
call_list_of_files(images)
|
||||
elseif act_os == "macos" then
|
||||
call_list_of_files_osx(images)
|
||||
else
|
||||
call_file_by_file(images)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.gui.libs.image.destroy_action("OpenInExplorer")
|
||||
dt.destroy_event("OpenInExplorer", "shortcut")
|
||||
if act_os ~= "windows" then
|
||||
dt.preferences.destroy("OpenInExplorer", "linked_image_files_dir")
|
||||
end
|
||||
dt.preferences.destroy("OpenInExplorer", "use_links")
|
||||
end
|
||||
|
||||
|
||||
-- GUI --
|
||||
dt.gui.libs.image.register_action(
|
||||
"OpenInExplorer", _("show in file explorer"),
|
||||
function() open_in_fmanager() end,
|
||||
_("open the file manager at the selected image's location")
|
||||
)
|
||||
|
||||
|
||||
if act_os ~= "windows" then
|
||||
dt.preferences.register("OpenInExplorer", "linked_image_files_dir", -- name
|
||||
"directory", -- type
|
||||
_("OpenInExplorer: linked files directory"), -- label
|
||||
_("directory to store the links to the file names, requires restart to take effect"), -- tooltip
|
||||
"Links to image files", -- default
|
||||
dt.new_widget("file_chooser_button"){
|
||||
title = _("select directory"),
|
||||
is_directory = true,
|
||||
}
|
||||
)
|
||||
dt.preferences.register("OpenInExplorer", "use_links", -- name
|
||||
"bool", -- type
|
||||
_("OpenInExplorer: use links"), -- label
|
||||
_("use links instead of multiple windows, requires restart to take effect"), -- tooltip
|
||||
false, -- default
|
||||
""
|
||||
)
|
||||
end
|
||||
|
||||
dt.register_event(
|
||||
"OpenInExplorer", "shortcut",
|
||||
function(event, shortcut) open_in_fmanager() end,
|
||||
"OpenInExplorer"
|
||||
)
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
return script_data
|
||||
272
contrib/RL_out_sharp.lua
Normal file
272
contrib/RL_out_sharp.lua
Normal file
@@ -0,0 +1,272 @@
|
||||
--[[
|
||||
Richardson-Lucy output sharpening for darktable using GMic
|
||||
|
||||
darktable is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
darktable is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with darktable. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
|
||||
--[[
|
||||
DESCRIPTION
|
||||
RL_out_sharp.lua - Richardson-Lucy output sharpening using GMic
|
||||
|
||||
This script provides a new target storage "RL output sharpen".
|
||||
Images exported will be sharpened using GMic (RL deblur algorithm)
|
||||
|
||||
REQUIRED SOFTWARE
|
||||
GMic command line interface (CLI) https://gmic.eu/download.shtml
|
||||
|
||||
USAGE
|
||||
* require this script from main lua file
|
||||
* in lua preferences, select the GMic cli executable
|
||||
* from "export selected", choose "RL output sharpen"
|
||||
* configure output folder
|
||||
* configure RL parameters with sliders
|
||||
* configure temp files format and quality, jpg 8bpp (good quality)
|
||||
and tif 16bpp (best quality) are supported
|
||||
* configure other export options (size, etc.)
|
||||
* export, images will be first exported in the temp format, then sharpened
|
||||
* sharpened images will be stored in jpg format in the output folder
|
||||
|
||||
EXAMPLE
|
||||
set sigma = 0.7, iterations = 10, jpeg output quality = 95,
|
||||
to correct blur due to image resize for web usage
|
||||
|
||||
CAVEATS
|
||||
MAC compatibility not tested
|
||||
Although Darktable can handle file names containing spaces, GMic cli cannot,
|
||||
so if you want to use this script please make sure that your images do not
|
||||
have spaces in the file name and path
|
||||
|
||||
BUGS, COMMENTS, SUGGESTIONS
|
||||
send to Marco Carrarini, marco.carrarini@gmail.com
|
||||
|
||||
CHANGES
|
||||
* 20200308 - initial version
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
local dtsys = require "lib/dtutils.system"
|
||||
|
||||
-- module name
|
||||
local MODULE_NAME = "RL_out_sharp"
|
||||
|
||||
-- check API version
|
||||
du.check_min_api_version("7.0.0", MODULE_NAME)
|
||||
|
||||
-- translation
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("RL output sharpening"),
|
||||
purpose = _("Richardson-Lucy output sharpening using GMic"),
|
||||
author = "Marco Carrarini <marco.carrarini@gmail.com>",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/RL_out_sharp"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
-- OS compatibility
|
||||
local PS = dt.configuration.running_os == "windows" and "\\" or "/"
|
||||
|
||||
-- initialize module preferences
|
||||
if not dt.preferences.read(MODULE_NAME, "initialized", "bool") then
|
||||
dt.preferences.write(MODULE_NAME, "sigma", "string", "0.7")
|
||||
dt.preferences.write(MODULE_NAME, "iterations", "string", "10")
|
||||
dt.preferences.write(MODULE_NAME, "jpg_quality", "string", "95")
|
||||
dt.preferences.write(MODULE_NAME, "initialized", "bool", true)
|
||||
end
|
||||
|
||||
-- preserve original image metadata in the output image -----------------------
|
||||
local function preserve_metadata(original, sharpened)
|
||||
local exiftool = df.check_if_bin_exists("exiftool")
|
||||
|
||||
if exiftool then
|
||||
dtsys.external_command("exiftool -overwrite_original_in_place -tagsFromFile " .. original .. " " .. sharpened)
|
||||
else
|
||||
dt.print_log(MODULE_NAME .. " exiftool not found, metadata not preserved")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- setup export ---------------------------------------------------------------
|
||||
local function setup_export(storage, img_format, image_table, high_quality, extra_data)
|
||||
-- set 16bpp if format is tif
|
||||
if img_format.extension == "tif" then
|
||||
img_format.bpp = 16
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- temp export formats: jpg and tif are supported -----------------------------
|
||||
local function supported(storage, img_format)
|
||||
return (img_format.extension == "jpg") or (img_format.extension == "tif")
|
||||
end
|
||||
|
||||
|
||||
-- export and sharpen images --------------------------------------------------
|
||||
local function export2RL(storage, image_table, extra_data)
|
||||
|
||||
local temp_name, new_name, run_cmd, result
|
||||
local input_file, output_file, options
|
||||
|
||||
-- read parameters
|
||||
local gmic = dt.preferences.read(MODULE_NAME, "gmic_exe", "string")
|
||||
if gmic == "" then
|
||||
dt.print(_("GMic executable not configured"))
|
||||
return
|
||||
end
|
||||
gmic = df.sanitize_filename(gmic)
|
||||
local output_folder = output_folder_selector.value
|
||||
local sigma_str = string.gsub(string.format("%.2f", sigma_slider.value), ",", ".")
|
||||
local iterations_str = string.format("%.0f", iterations_slider.value)
|
||||
local jpg_quality_str = string.format("%.0f", jpg_quality_slider.value)
|
||||
|
||||
-- save preferences
|
||||
dt.preferences.write(MODULE_NAME, "sigma", "string", sigma_str)
|
||||
dt.preferences.write(MODULE_NAME, "iterations", "string", iterations_str)
|
||||
dt.preferences.write(MODULE_NAME, "jpg_quality", "string", jpg_quality_str)
|
||||
|
||||
local gmic_operation = " -deblur_richardsonlucy "..sigma_str..","..iterations_str..",1"
|
||||
|
||||
local i = 0
|
||||
for image, temp_name in pairs(image_table) do
|
||||
|
||||
i = i + 1
|
||||
dt.print(string.format(_("sharpening image %d ..."), i))
|
||||
-- create unique filename
|
||||
new_name = output_folder..PS..df.get_basename(temp_name)..".jpg"
|
||||
new_name = df.create_unique_filename(new_name)
|
||||
|
||||
-- build the GMic command string
|
||||
input_file = df.sanitize_filename(temp_name)
|
||||
output_file = df.sanitize_filename(new_name)
|
||||
options = " cut 0,255 round "
|
||||
if df.get_filetype(temp_name) == "tif" then options = " -/ 256"..options end
|
||||
|
||||
run_cmd = gmic.." "..input_file..gmic_operation..options.."o "..output_file..","..jpg_quality_str
|
||||
|
||||
result = dtsys.external_command(run_cmd)
|
||||
if result ~= 0 then
|
||||
dt.print(_("sharpening error"))
|
||||
return
|
||||
end
|
||||
|
||||
-- copy metadata from input_file to output_file
|
||||
preserve_metadata(input_file, output_file)
|
||||
|
||||
-- delete temp image
|
||||
os.remove(temp_name)
|
||||
|
||||
end
|
||||
|
||||
dt.print(_("finished exporting"))
|
||||
end
|
||||
|
||||
-- script_manager integration
|
||||
|
||||
local function destroy()
|
||||
dt.destroy_storage("exp2RL")
|
||||
end
|
||||
|
||||
-- new widgets ----------------------------------------------------------------
|
||||
|
||||
output_folder_selector = dt.new_widget("file_chooser_button"){
|
||||
title = _("select output folder"),
|
||||
tooltip = _("select output folder"),
|
||||
value = dt.preferences.read(MODULE_NAME, "output_folder", "string"),
|
||||
is_directory = true,
|
||||
changed_callback = function(self)
|
||||
dt.preferences.write(MODULE_NAME, "output_folder", "string", self.value)
|
||||
end
|
||||
}
|
||||
|
||||
sigma_slider = dt.new_widget("slider"){
|
||||
label = _("sigma"),
|
||||
tooltip = _("controls the width of the blur that's applied"),
|
||||
soft_min = 0.3,
|
||||
soft_max = 2.0,
|
||||
hard_min = 0.0,
|
||||
hard_max = 3.0,
|
||||
step = 0.05,
|
||||
digits = 2,
|
||||
value = 1.0
|
||||
}
|
||||
|
||||
iterations_slider = dt.new_widget("slider"){
|
||||
label = _("iterations"),
|
||||
tooltip = _("increase for better sharpening, but slower"),
|
||||
soft_min = 0,
|
||||
soft_max = 100,
|
||||
hard_min = 0,
|
||||
hard_max = 100,
|
||||
step = 5,
|
||||
digits = 0,
|
||||
value = 10.0
|
||||
}
|
||||
|
||||
jpg_quality_slider = dt.new_widget("slider"){
|
||||
label = _("output jpg quality"),
|
||||
tooltip = _("quality of the output jpg file"),
|
||||
soft_min = 70,
|
||||
soft_max = 100,
|
||||
hard_min = 70,
|
||||
hard_max = 100,
|
||||
step = 2,
|
||||
digits = 0,
|
||||
value = 95.0
|
||||
}
|
||||
|
||||
local storage_widget = dt.new_widget("box"){
|
||||
orientation = "vertical",
|
||||
output_folder_selector,
|
||||
sigma_slider,
|
||||
iterations_slider,
|
||||
jpg_quality_slider
|
||||
}
|
||||
|
||||
-- register new storage -------------------------------------------------------
|
||||
dt.register_storage("exp2RL", _("RL output sharpen"), nil, export2RL, supported, save_preferences, storage_widget)
|
||||
|
||||
-- register the new preferences -----------------------------------------------
|
||||
dt.preferences.register(MODULE_NAME, "gmic_exe", "file",
|
||||
_("executable for GMic CLI"),
|
||||
_("select executable for GMic command line version") , "")
|
||||
|
||||
-- set sliders to the last used value at startup ------------------------------
|
||||
sigma_slider.value = dt.preferences.read(MODULE_NAME, "sigma", "float")
|
||||
iterations_slider.value = dt.preferences.read(MODULE_NAME, "iterations", "float")
|
||||
jpg_quality_slider.value = dt.preferences.read(MODULE_NAME, "jpg_quality", "float")
|
||||
|
||||
-- script_manager integration
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
return script_data
|
||||
|
||||
-- end of script --------------------------------------------------------------
|
||||
|
||||
-- vim: shiftwidth=2 expandtab tabstop=2 cindent syntax=lua
|
||||
-- kate: hl Lua;
|
||||
132
contrib/auto_snapshot.lua
Normal file
132
contrib/auto_snapshot.lua
Normal file
@@ -0,0 +1,132 @@
|
||||
--[[
|
||||
|
||||
auto_snapshot.lua - automatically take a snapshot when an image is loaded in darkroom
|
||||
|
||||
Copyright (C) 2024 Bill Ferguson <wpferguson@gamil.com>.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
auto_snapshot -
|
||||
|
||||
automatically take a snapshot when an image is loaded in darkroom
|
||||
|
||||
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
None
|
||||
|
||||
USAGE
|
||||
* start the script from script_manager
|
||||
* open an image in darkroom
|
||||
|
||||
BUGS, COMMENTS, SUGGESTIONS
|
||||
Bill Ferguson <wpferguson@gamil.com>
|
||||
|
||||
CHANGES
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local log = require "lib/dtutils.log"
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- C O N S T A N T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local MODULE <const> = "auto_snapshot"
|
||||
local DEFAULT_LOG_LEVEL <const> = log.error
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- A P I C H E C K
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
du.check_min_api_version("7.0.0", MODULE) -- choose the minimum version that contains the features you need
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- I 1 8 N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- S C R I P T M A N A G E R I N T E G R A T I O N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("auto snapshot"), -- name of script
|
||||
purpose = _("automatically take a snapshot when an image is loaded in darkroom"), -- purpose of script
|
||||
author = "Bill Ferguson <wpferguson@gamil.com>", -- your name and optionally e-mail address
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/auto_snapshot/" -- URL to help/documentation
|
||||
}
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- L O G L E V E L
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
log.log_level(DEFAULT_LOG_LEVEL)
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- N A M E S P A C E
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local auto_snapshot = {}
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- P R E F E R E N C E S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
dt.preferences.register(MODULE, "always_create_snapshot", "bool", "auto_snapshot - " .. _("always automatically create_snapshot"),
|
||||
_("create a snapshot even if the image is altered"), false)
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- D A R K T A B L E I N T E G R A T I O N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function destroy()
|
||||
dt.destroy_event(MODULE, "darkroom-image-loaded")
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- E V E N T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
dt.register_event(MODULE, "darkroom-image-loaded",
|
||||
function(event, clean, image)
|
||||
local always = dt.preferences.read(MODULE, "always_create_snapshot", "bool")
|
||||
if clean and always then
|
||||
dt.gui.libs.snapshots.take_snapshot()
|
||||
elseif clean and not image.is_altered then
|
||||
dt.gui.libs.snapshots.take_snapshot()
|
||||
end
|
||||
|
||||
end
|
||||
)
|
||||
|
||||
|
||||
return script_data
|
||||
129
contrib/auto_tag_modified.lua
Normal file
129
contrib/auto_tag_modified.lua
Normal file
@@ -0,0 +1,129 @@
|
||||
--[[
|
||||
|
||||
auto_tag_modified.lua - automatically tag an image when it has been manually modified in darkroom
|
||||
|
||||
Copyright (C) 2025 Michael Reiger <michael@rauschpfeife.net>, modeled after auto_snapshot.lua by Bill Ferguson <wpferguson@gamil.com>.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
auto_tag_modified -
|
||||
|
||||
automatically tag an image when it has been manually modified in the darkroom, that is if its history has changed at the time it is closed from the darkroom.
|
||||
(This will not catch applying styles from the light table though.)
|
||||
|
||||
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
None
|
||||
|
||||
USAGE
|
||||
* start the script from script_manager
|
||||
* open an image in darkroom, and close it after making an operation that changes the history
|
||||
|
||||
BUGS, COMMENTS, SUGGESTIONS
|
||||
Michael Reiger <michael@rauschpfeife.net>
|
||||
|
||||
CHANGES
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local log = require "lib/dtutils.log"
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- C O N S T A N T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local MODULE <const> = "auto_tag_modified"
|
||||
local DEFAULT_TAG_NAME <const> = "darktable manually modified"
|
||||
local DEFAULT_LOG_LEVEL <const> = log.error
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- A P I C H E C K
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
du.check_min_api_version("7.0.0", MODULE) -- choose the minimum version that contains the features you need
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- I 1 8 N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- S C R I P T M A N A G E R I N T E G R A T I O N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("auto tag modified"), -- name of script
|
||||
purpose = _("automatically tag an image when it has been manually modified in darkroom"), -- purpose of script
|
||||
author = "Michael Reiger <michael@rauschpfeife.net>", -- your name and optionally e-mail address
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/auto_tag_modified/" -- URL to help/documentation
|
||||
}
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- L O G L E V E L
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
log.log_level(DEFAULT_LOG_LEVEL)
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- N A M E S P A C E
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local auto_tag_modified = {}
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- P R E F E R E N C E S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
dt.preferences.register(MODULE, "auto_modified_tag_name", "string", "auto_tag_modified - " .. _("tag name to use for auto tagging modified images"),
|
||||
_("tag name to use for auto tagging modified images"), DEFAULT_TAG_NAME)
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- D A R K T A B L E I N T E G R A T I O N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function destroy()
|
||||
dt.destroy_event(MODULE, "darkroom-image-history-changed")
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- E V E N T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
dt.register_event(MODULE, "darkroom-image-history-changed",
|
||||
function(event, image)
|
||||
local tag_name = dt.preferences.read(MODULE, "auto_modified_tag_name", "string")
|
||||
local tag = dt.tags.create(tag_name)
|
||||
dt.tags.attach(tag, image)
|
||||
end
|
||||
)
|
||||
|
||||
return script_data
|
||||
@@ -28,6 +28,8 @@ USAGE
|
||||
> exiftool -AutoDynamicRange XE021351.RAF
|
||||
Auto Dynamic Range : 200%
|
||||
|
||||
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
* exiftool
|
||||
|
||||
LICENSE
|
||||
GPLv2
|
||||
@@ -35,91 +37,41 @@ GPLv2
|
||||
]]
|
||||
|
||||
local darktable = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local filelib = require "lib/dtutils.file"
|
||||
local syslib = require "lib/dtutils.system"
|
||||
|
||||
du.check_min_api_version("7.0.0", "autostyle")
|
||||
|
||||
-- Forward declare the functions
|
||||
local autostyle_apply_one_image,autostyle_apply_one_image_event,autostyle_apply,exiftool_attribute,capture
|
||||
local gettext = darktable.gettext.gettext
|
||||
|
||||
-- Tested it with darktable 1.6.1 and darktable git from 2014-01-25
|
||||
darktable.configuration.check_version(...,{2,0,2},{2,1,0})
|
||||
|
||||
-- Receive the event triggered
|
||||
function autostyle_apply_one_image_event(event,image)
|
||||
autostyle_apply_one_image(image)
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- Apply the style to an image, if it matches the tag condition
|
||||
function autostyle_apply_one_image (image)
|
||||
-- We need the tag, the value and the style_name provided from the configuration string
|
||||
local tag,value,style_name=string.match(darktable.preferences.read("autostyle","exif_tag","string"),"(%g+)%s*=%s*(%g+)%s*=>%s*(%g+)")
|
||||
-- return data structure for script_manager
|
||||
|
||||
-- check they all exist (correct syntax)
|
||||
if (not tag) then
|
||||
darktable.print("EXIF TAG not found in " .. darktable.preferences.read("autostyle","exif_tag","string"))
|
||||
return
|
||||
end
|
||||
if (not value) then
|
||||
darktable.print("value to match not found in " .. darktable.preferences.read("autostyle","exif_tag","string"))
|
||||
return
|
||||
end
|
||||
if (not style_name) then
|
||||
darktable.print("style name not found in " .. darktable.preferences.read("autostyle","exif_tag","string"))
|
||||
return
|
||||
end
|
||||
local script_data = {}
|
||||
|
||||
-- First find the style (we have its name)
|
||||
local styles= darktable.styles
|
||||
local style
|
||||
for _,s in ipairs(styles) do
|
||||
if s.name == style_name then
|
||||
style=s
|
||||
end
|
||||
end
|
||||
if (not style) then
|
||||
darktable.print("style not found for autostyle: " .. style_name)
|
||||
end
|
||||
local have_not_printed_config_message = true
|
||||
|
||||
-- Apply the style to image, if it is tagged
|
||||
local ok,auto_dr_attr= pcall(exiftool_attribute,image.path .. '/' .. image.filename,tag)
|
||||
-- If the lookup fails, stop here
|
||||
if (not ok) then
|
||||
return
|
||||
end
|
||||
if auto_dr_attr==value then
|
||||
-- darktable.print("Image " .. image.filename .. ": autostyle automatically applied " .. darktable.preferences.read("autostyle","exif_tag","string") )
|
||||
darktable.styles.apply(style,image)
|
||||
-- else
|
||||
-- darktable.print("Image " .. image.filename .. ": autostyle not applied, exif tag " .. darktable.preferences.read("autostyle","exif_tag","string") .. " not matched: " .. auto_dr_attr)
|
||||
end
|
||||
end
|
||||
script_data.metadata = {
|
||||
name = _("auto style"),
|
||||
purpose = _("automatically apply a style based on image EXIF tag"),
|
||||
author = "Marc Cousin <cousinmarc@gmail.com>",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/autostyle/"
|
||||
}
|
||||
|
||||
|
||||
function autostyle_apply( shortcut)
|
||||
local images = darktable.gui.action_images
|
||||
for _,image in pairs(images) do
|
||||
autostyle_apply_one_image(image)
|
||||
end
|
||||
end
|
||||
|
||||
-- Retrieve the attribute through exiftool
|
||||
function exiftool_attribute(path,attr)
|
||||
local cmd="exiftool -" .. attr .. " '" ..path.. "'";
|
||||
local exifresult=get_stdout(cmd)
|
||||
local attribute=string.match(exifresult,": (.*)")
|
||||
if (attribute == nil) then
|
||||
darktable.print( "Could not find the attribute " .. attr .. " using the command: <" .. cmd .. ">")
|
||||
-- Raise an error to the caller
|
||||
error( "Could not find the attribute " .. attr .. " using the command: <" .. cmd .. ">");
|
||||
end
|
||||
return attribute
|
||||
end
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
-- run command and retrieve stdout
|
||||
function get_stdout(cmd)
|
||||
local function get_stdout(cmd)
|
||||
-- Open the command, for reading
|
||||
local fd = assert(io.popen(cmd, 'r'))
|
||||
-- yield to other lua threads until data is ready to be read
|
||||
coroutine.yield("FILE_READABLE",fd)
|
||||
darktable.control.read(fd)
|
||||
-- slurp the whole file
|
||||
local data = assert(fd:read('*a'))
|
||||
|
||||
@@ -133,12 +85,116 @@ function get_stdout(cmd)
|
||||
return data
|
||||
end
|
||||
|
||||
-- Retrieve the attribute through exiftool
|
||||
local function exiftool_attribute(path, attr)
|
||||
local cmd = "exiftool -" .. attr .. " '" .. path .. "'";
|
||||
local exifresult = get_stdout(cmd)
|
||||
local attribute = string.match(exifresult, ": (.*)")
|
||||
if (attribute == nil) then
|
||||
darktable.print_error( "Could not find the attribute " .. attr .. " using the command: <" .. cmd .. ">")
|
||||
-- Raise an error to the caller
|
||||
error( "Could not find the attribute " .. attr .. " using the command: <" .. cmd .. ">");
|
||||
end
|
||||
-- darktable.print_error("Returning attribute: " .. attribute)
|
||||
return attribute
|
||||
end
|
||||
|
||||
-- Apply the style to an image, if it matches the tag condition
|
||||
local function autostyle_apply_one_image (image)
|
||||
|
||||
local pref = darktable.preferences.read("autostyle", "exif_tag", "string")
|
||||
|
||||
if pref and string.len(pref) >= 6 then
|
||||
-- We need the tag, the value and the style_name provided from the configuration string
|
||||
local tag, value, style_name = string.match(pref, "(%g+)%s*=%s*([%g ]-)%s*=>%s*(%g+)")
|
||||
|
||||
-- check they all exist (correct syntax)
|
||||
if (not tag) then
|
||||
darktable.print(string.format(_("EXIF tag not found in %s"), pref))
|
||||
return 0
|
||||
end
|
||||
if (not value) then
|
||||
darktable.print(string.format(_("value to match not found in %s"), pref))
|
||||
return 0
|
||||
end
|
||||
if (not style_name) then
|
||||
darktable.print(string.format(_("style name not found in %s"), pref))
|
||||
return 0
|
||||
end
|
||||
if not filelib.check_if_bin_exists("exiftool") then
|
||||
darktable.print(_("can't find exiftool"))
|
||||
return 0
|
||||
end
|
||||
|
||||
|
||||
-- First find the style (we have its name)
|
||||
local styles = darktable.styles
|
||||
local style
|
||||
for _, s in ipairs(styles) do
|
||||
if s.name == style_name then
|
||||
style = s
|
||||
end
|
||||
end
|
||||
if (not style) then
|
||||
darktable.print(string.format(_("style not found for autostyle: %s"), style_name))
|
||||
return 0
|
||||
end
|
||||
|
||||
-- Apply the style to image, if it is tagged
|
||||
local ok, auto_dr_attr = pcall(exiftool_attribute, image.path .. '/' .. image.filename,tag)
|
||||
--darktable.print_error("dr_attr:" .. auto_dr_attr)
|
||||
-- If the lookup fails, stop here
|
||||
if (not ok) then
|
||||
darktable.print(string.format(_("couldn't get attribute %s from exiftool's output"), auto_dr_attr))
|
||||
return 0
|
||||
end
|
||||
if auto_dr_attr == value then
|
||||
darktable.print_log("Image " .. image.filename .. ": autostyle automatically applied " .. pref)
|
||||
darktable.styles.apply(style,image)
|
||||
return 1
|
||||
else
|
||||
darktable.print_log("Image " .. image.filename .. ": autostyle not applied, exif tag " .. pref .. " not matched: " .. auto_dr_attr)
|
||||
return 0
|
||||
end
|
||||
elseif have_not_printed_config_message then
|
||||
have_not_printed_config_message = false
|
||||
darktable.print(string.format(_("%s is not configured, please configure the preference in Lua options"), script_data.metadata.name))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- Receive the event triggered
|
||||
local function autostyle_apply_one_image_event(event,image)
|
||||
autostyle_apply_one_image(image)
|
||||
end
|
||||
|
||||
local function autostyle_apply(shortcut)
|
||||
local images = darktable.gui.action_images
|
||||
local images_processed = 0
|
||||
local images_submitted = 0
|
||||
for _,image in pairs(images) do
|
||||
images_submitted = images_submitted + 1
|
||||
images_processed = images_processed + autostyle_apply_one_image(image)
|
||||
end
|
||||
darktable.print(string.format(_("applied auto style to %d out of %d image(s)"), images_processed, images_submitted))
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
darktable.destroy_event("autostyle", "shortcut")
|
||||
darktable.destroy_event("autostyle", "post-import-image")
|
||||
end
|
||||
|
||||
-- Registering events
|
||||
darktable.register_event("shortcut",autostyle_apply,
|
||||
"Apply your chosen style from exiftool tags")
|
||||
darktable.register_event("autostyle", "shortcut", autostyle_apply,
|
||||
_("apply your chosen style from exiftool tags"))
|
||||
|
||||
darktable.preferences.register("autostyle","exif_tag","string","Autostyle: EXIF_tag=value=>style","apply a style automatically if an EXIF_tag matches value. Find the tag with exiftool","")
|
||||
darktable.preferences.register("autostyle", "exif_tag", "string",
|
||||
string.format("%s: EXIF_tag=value=>style", script_data.metadata.name),
|
||||
_("apply a style automatically if an EXIF tag matches value, find the tag with exiftool"), "")
|
||||
|
||||
darktable.register_event("post-import-image",autostyle_apply_one_image_event)
|
||||
darktable.register_event("autostyle", "post-import-image",
|
||||
autostyle_apply_one_image_event)
|
||||
|
||||
|
||||
script_data.destroy = destroy
|
||||
return script_data
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
--[[
|
||||
This file is part of darktable,
|
||||
Copyright 2014 by Tobias Jakobs.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
darktable calc distance script
|
||||
|
||||
This script calcs the distance between two images using the GPS data in the metadata
|
||||
|
||||
USAGE
|
||||
* require this script from your main lua file
|
||||
* register a shortcut
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
dt.configuration.check_version(...,{2,0,1})
|
||||
|
||||
local function calcDistance()
|
||||
local sel_images = dt.gui.selection()
|
||||
|
||||
local lat1 = 0;
|
||||
local lon1 = 0;
|
||||
local lat2 = 0;
|
||||
local lon2 = 0;
|
||||
local i = 0;
|
||||
|
||||
local sel_images = dt.gui.selection()
|
||||
|
||||
for _,image in ipairs(sel_images) do
|
||||
if ((image.longitude and image.latitude) and
|
||||
(image.longitude ~= 0 and image.latitude ~= 90) -- Sometimes the north-pole but most likely just wrong data
|
||||
) then
|
||||
|
||||
i = i + 1;
|
||||
|
||||
if (i == 1) then
|
||||
lat1 = image.latitude;
|
||||
lon1 = image.longitude;
|
||||
end
|
||||
|
||||
lat2 = image.latitude;
|
||||
lon2 = image.longitude;
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
-- I used code from here:
|
||||
-- http://stackoverflow.com/questions/27928/how-do-i-calculate-distance-between-two-latitude-longitude-points
|
||||
|
||||
local earthRadius = 6371; -- Radius of the earth in km
|
||||
local dLat = math.rad(lat2-lat1); -- deg2rad below
|
||||
local dLon = math.rad(lon2-lon1);
|
||||
local a =
|
||||
math.sin(dLat/2) * math.sin(dLat/2) +
|
||||
math.cos(math.rad(lat1)) * math.cos(math.rad(lat2)) *
|
||||
math.sin(dLon/2) * math.sin(dLon/2)
|
||||
;
|
||||
local angle = 2 * math.atan2(math.sqrt(a), math.sqrt(1-a));
|
||||
local distance = earthRadius * angle; -- Distance in km
|
||||
|
||||
dt.print(distance.." km")
|
||||
|
||||
|
||||
end
|
||||
|
||||
-- Register
|
||||
dt.register_event("shortcut",calcDistance,"Calculate the distance from latitude and longitude in km")
|
||||
205
contrib/change_group_leader.lua
Normal file
205
contrib/change_group_leader.lua
Normal file
@@ -0,0 +1,205 @@
|
||||
--[[
|
||||
change group leader
|
||||
|
||||
copyright (c) 2020, 2022 Bill Ferguson <wpferguson@gmail.com>
|
||||
copyright (c) 2021 Angel Angelov
|
||||
|
||||
darktable is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
darktable is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with darktable. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
|
||||
--[[
|
||||
CHANGE GROUP LEADER
|
||||
automatically change the leader of raw+jpg paired image groups
|
||||
|
||||
INSTALLATION
|
||||
* copy this file in $CONFIGDIR/lua/ where CONFIGDIR is your darktable configuration directory
|
||||
* add the following line in the file $CONFIGDIR/luarc
|
||||
require "change_group_leader"
|
||||
|
||||
USAGE
|
||||
* in lighttable mode, select the image groups you wish to process,
|
||||
select whether you want to set the leader to "jpg" or "raw",
|
||||
and click "Execute"
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local MODULE = "change_group_leader"
|
||||
|
||||
du.check_min_api_version("3.0.0", MODULE)
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("change group leader"),
|
||||
purpose = _("automatically change the leader of raw+jpg paired image groups"),
|
||||
author = "Bill Ferguson <wpferguson@gmail.com>",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/change_group_leader"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
-- create a namespace to contain persistent data and widgets
|
||||
local chg_grp_ldr = {}
|
||||
|
||||
local cgl = chg_grp_ldr
|
||||
|
||||
cgl.widgets = {}
|
||||
|
||||
cgl.event_registered = false
|
||||
cgl.module_installed = false
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- F U N C T I O N S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function install_module()
|
||||
if not cgl.module_installed then
|
||||
dt.register_lib(
|
||||
MODULE, -- Module name
|
||||
_("change group leader"), -- Visible name
|
||||
true, -- expandable
|
||||
false, -- resetable
|
||||
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 700}}, -- containers
|
||||
cgl.widgets.box,
|
||||
nil,-- view_enter
|
||||
nil -- view_leave
|
||||
)
|
||||
cgl.module_installed = true
|
||||
end
|
||||
end
|
||||
|
||||
local function find_group_leader(images, mode)
|
||||
for _, img in ipairs(images) do
|
||||
dt.print_log("checking image " .. img.id .. " named " .. img.filename)
|
||||
local found = false
|
||||
if mode == "jpg" then
|
||||
if string.match(string.lower(img.filename), "jpg$") then
|
||||
dt.print_log("jpg matched image " .. img.filename)
|
||||
found = true
|
||||
end
|
||||
elseif mode == "raw" then
|
||||
if img.is_raw and img.duplicate_index == 0 then
|
||||
dt.print_log("found raw " .. img.filename)
|
||||
found = true
|
||||
end
|
||||
elseif mode == "non-raw" then
|
||||
if img.is_ldr then
|
||||
dt.print_log("found ldr " .. img.filename)
|
||||
found = true
|
||||
end
|
||||
else
|
||||
dt.print_error(MODULE .. ": unrecognized mode " .. mode)
|
||||
return
|
||||
end
|
||||
if found then
|
||||
dt.print_log("making " .. img.filename .. " group leader")
|
||||
img:make_group_leader()
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function process_image_groups(images)
|
||||
if #images < 1 then
|
||||
dt.print(_("no images selected"))
|
||||
dt.print_log(MODULE .. "no images seletected, returning...")
|
||||
else
|
||||
local mode = cgl.widgets.mode.value
|
||||
for _,img in ipairs(images) do
|
||||
dt.print_log("checking image " .. img.id)
|
||||
local group_images = img:get_group_members()
|
||||
if group_images == 1 then
|
||||
dt.print_log("only one image in group for image " .. image.id)
|
||||
else
|
||||
find_group_leader(group_images, mode)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- S C R I P T M A N A G E R I N T E G R A T I O N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function destroy()
|
||||
dt.gui.libs[MODULE].visible = false
|
||||
end
|
||||
|
||||
local function restart()
|
||||
dt.gui.libs[MODULE].visible = true
|
||||
end
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- W I D G E T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
cgl.widgets.mode = dt.new_widget("combobox"){
|
||||
label = _("select new group leader"),
|
||||
tooltip = _("select type of image to be group leader"),
|
||||
selected = 1,
|
||||
"jpg", "raw", "non-raw",
|
||||
}
|
||||
|
||||
cgl.widgets.execute = dt.new_widget("button"){
|
||||
label = _("execute"),
|
||||
clicked_callback = function()
|
||||
process_image_groups(dt.gui.action_images)
|
||||
end
|
||||
}
|
||||
|
||||
cgl.widgets.box = dt.new_widget("box"){
|
||||
orientation = "vertical",
|
||||
cgl.widgets.mode,
|
||||
cgl.widgets.execute,
|
||||
}
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- D A R K T A B L E I N T E G R A T I O N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
if dt.gui.current_view().id == "lighttable" then
|
||||
install_module()
|
||||
else
|
||||
if not cgl.event_registered then
|
||||
dt.register_event(
|
||||
"view-changed",
|
||||
function(event, old_view, new_view)
|
||||
if new_view.name == "lighttable" and old_view.name == "darkroom" then
|
||||
install_module()
|
||||
end
|
||||
end
|
||||
)
|
||||
cgl.event_registered = true
|
||||
end
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
script_data.restart = restart
|
||||
script_data.destroy_method = "hide"
|
||||
script_data.show = restart
|
||||
|
||||
return script_data
|
||||
97
contrib/clear_GPS.lua
Normal file
97
contrib/clear_GPS.lua
Normal file
@@ -0,0 +1,97 @@
|
||||
--[[
|
||||
|
||||
clear_GPS.lua - plugin for Darktable
|
||||
|
||||
Copyright (C) 2016 Bill Ferguson <wpferguson@gmail.com>.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
clear_GPS - clear GPS data from selected image(s)
|
||||
|
||||
This shortcut removes the GPS coordinate data from the selected images.
|
||||
|
||||
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
* None
|
||||
|
||||
USAGE
|
||||
* require this script from your main lua file
|
||||
* select an image or images
|
||||
* click the shortcut, clear GPS data
|
||||
|
||||
BUGS, COMMENTS, SUGGESTIONS
|
||||
* Send to Bill Ferguson, wpferguson@gmail.com
|
||||
|
||||
CHANGES
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("clear GPS info"),
|
||||
purpose = _("remove GPS data from selected image(s)"),
|
||||
author = "Bill Ferguson <wpferguson@gmail.com>",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/clear_gps/"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
-- not a number
|
||||
local NaN = 0/0
|
||||
|
||||
du.check_min_api_version("7.0.0", "clear_GPS")
|
||||
|
||||
local function clear_GPS(images)
|
||||
for _, image in ipairs(images) do
|
||||
-- set the location information to Not a Number (NaN) so it displays correctly
|
||||
image.elevation = NaN
|
||||
image.latitude = NaN
|
||||
image.longitude = NaN
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.destroy_event("clear_GPS", "shortcut")
|
||||
dt.gui.libs.image.destroy_action("clear_GPS")
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
dt.gui.libs.image.register_action(
|
||||
"clear_GPS", _("clear GPS data"),
|
||||
function(event, images) clear_GPS(images) end,
|
||||
_("clear GPS data from selected images")
|
||||
)
|
||||
|
||||
dt.register_event(
|
||||
"clear_GPS", "shortcut",
|
||||
function(event, shortcut) clear_GPS(dt.gui.action_images) end,
|
||||
_("clear GPS data from selected images")
|
||||
)
|
||||
|
||||
return script_data
|
||||
377
contrib/color_profile_manager.lua
Normal file
377
contrib/color_profile_manager.lua
Normal file
@@ -0,0 +1,377 @@
|
||||
--[[
|
||||
|
||||
color_profile_manager.lua - manage external darktable color profiles
|
||||
|
||||
Copyright (C) 2021 Bill Ferguson <wpferguson@gmail.com>.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
color_profile_manager - manage external darktable color profiles
|
||||
|
||||
This script provides a tool to manage input and output external color
|
||||
profiles used by darktable. Color profiles can be added or removed
|
||||
to/from the correct directories so that darktable can find and use
|
||||
them.
|
||||
|
||||
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
* None
|
||||
|
||||
USAGE
|
||||
* require this script from your main lua file
|
||||
* select the input or output set of color profiles
|
||||
* a list of profiles is displayed. Click the check box beside the
|
||||
profile name to select it for removal. Click the "remove profile"
|
||||
button to remove the profile.
|
||||
* use the file selector to select a color profile to add to the currently
|
||||
selected set (input or output)
|
||||
* click the "add profile" button to add the profile to the selected set
|
||||
|
||||
BUGS, COMMENTS, SUGGESTIONS
|
||||
* Send to Bill Ferguson, wpferguson@gmail.com or raise an issue on
|
||||
https://github.com/dakrtable-org/lua-scripts
|
||||
]]
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
local dtsys = require "lib/dtutils.system"
|
||||
|
||||
du.check_min_api_version("7.0.0", "color_profile_manager")
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- L O C A L I Z A T I O N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- C O N S T A N T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local MODULE_NAME = "color_profile_manager"
|
||||
local PS = dt.configuration.running_os == "windows" and "\\" or "/"
|
||||
local CS = dt.configuration.running_os == "windows" and "&" or ";"
|
||||
local DIR_CMD = dt.configuration.running_os == "windows" and "dir /b" or "ls"
|
||||
local COLOR_IN_DIR = dt.configuration.config_dir .. PS .. "color" .. PS .. "in"
|
||||
local COLOR_OUT_DIR = dt.configuration.config_dir .. PS .. "color" .. PS .. "out"
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- N A M E S P A C E
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local cpm = {}
|
||||
cpm.widgets = {}
|
||||
cpm.widgets.profile_box = dt.new_widget("box"){
|
||||
orientation = "vertical",
|
||||
}
|
||||
cpm.module_installed = false
|
||||
cpm.event_registered = false
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- F U N C T I O N S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function check_for_directories()
|
||||
if not df.test_file(COLOR_IN_DIR, "d") then
|
||||
dt.print_log("didn't find " .. COLOR_IN_DIR)
|
||||
return false
|
||||
elseif not df.test_file(COLOR_OUT_DIR, "d") then
|
||||
dt.print_log("didn't find " .. COLOR_OUT_DIR)
|
||||
return false
|
||||
else
|
||||
dt.print_log("both directories exist")
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
local function add_directories()
|
||||
df.mkdir(COLOR_IN_DIR)
|
||||
df.mkdir(COLOR_OUT_DIR)
|
||||
end
|
||||
|
||||
local function list_profiles(dir)
|
||||
local files = {}
|
||||
local p = io.popen(DIR_CMD .. " " .. dir)
|
||||
if p then
|
||||
for line in p:lines() do
|
||||
table.insert(files, line)
|
||||
end
|
||||
end
|
||||
p:close()
|
||||
return files
|
||||
end
|
||||
|
||||
local function add_profile(file, dir)
|
||||
df.file_copy(file, dir)
|
||||
dt.print(string.format(_("added color profile %s to %s"), file, dir))
|
||||
dt.print_log("color profile " .. file .. " added to " .. dir)
|
||||
end
|
||||
|
||||
local function remove_profile(file, dir)
|
||||
os.remove(dir .. PS .. file)
|
||||
dt.print(string.format(_("removed color profile %s from %s"), file, dir))
|
||||
dt.print_log("color profile " .. file .. " removed from " .. dir)
|
||||
end
|
||||
|
||||
local function clear_profile_box()
|
||||
for i = #cpm.widgets.profile_box, 1, -1 do
|
||||
cpm.widgets.profile_box[i] = nil
|
||||
end
|
||||
end
|
||||
|
||||
local function add_profile_callback()
|
||||
local profile_dir = COLOR_IN_DIR
|
||||
if cpm.widgets.profile_set.selected == 2 then
|
||||
profile_dir = COLOR_OUT_DIR
|
||||
end
|
||||
|
||||
local new_profile = cpm.widgets.profile_selector.value
|
||||
if string.lower(df.get_filetype(new_profile)) ~= "icm" and string.lower(df.get_filetype(new_profile)) ~= "icc" then
|
||||
dt.print(_("ERROR: color profile must be an icc or icm file"))
|
||||
dt.print_error(new_profile .. " selected and isn't an icc or icm file")
|
||||
return
|
||||
end
|
||||
|
||||
-- set selector value to directory that new profile came from
|
||||
-- in case there are more
|
||||
cpm.widgets.profile_selector.value = df.get_path(cpm.widgets.profile_selector.value)
|
||||
add_profile(new_profile, profile_dir)
|
||||
local files = list_profiles(profile_dir)
|
||||
local widgets = {}
|
||||
|
||||
local profile_ptr = 1
|
||||
for i, file in ipairs(files) do
|
||||
if #cpm.widgets.profile_box == 0 or cpm.widgets.profile_box[profile_ptr].label ~= file then
|
||||
table.insert(widgets, dt.new_widget("check_button"){value = false, label = file})
|
||||
else
|
||||
table.insert(widgets, cpm.widgets.profile_box[profile_ptr])
|
||||
profile_ptr = profile_ptr + 1
|
||||
if profile_ptr > #cpm.widgets.profile_box then
|
||||
profile_ptr = #cpm.widgets.profile_box
|
||||
end
|
||||
end
|
||||
end
|
||||
clear_profile_box()
|
||||
for _, widget in ipairs(widgets) do
|
||||
table.insert(cpm.widgets.profile_box, widget)
|
||||
end
|
||||
if not cpm.widgets.remove_box.visible then
|
||||
cpm.widgets.remove_box.visible = true
|
||||
end
|
||||
end
|
||||
|
||||
local function remove_profile_callback()
|
||||
local widgets_to_keep = {}
|
||||
local profile_dir = COLOR_IN_DIR
|
||||
if cpm.widgets.profile_set.selected == 2 then
|
||||
profile_dir = COLOR_OUT_DIR
|
||||
end
|
||||
|
||||
for _, widget in ipairs(cpm.widgets.profile_box) do
|
||||
if widget.value == true then
|
||||
remove_profile(widget.label, profile_dir)
|
||||
else
|
||||
table.insert(widgets_to_keep, widget)
|
||||
end
|
||||
end
|
||||
clear_profile_box()
|
||||
for _, widget in ipairs(widgets_to_keep) do
|
||||
table.insert(cpm.widgets.profile_box, widget)
|
||||
end
|
||||
if #cpm.widgets.profile_box == 0 then
|
||||
cpm.widgets.remove_box.visible = false
|
||||
else
|
||||
cpm.widgets.remove_box.visible = true
|
||||
end
|
||||
end
|
||||
|
||||
local function list_profile_callback(choice)
|
||||
local list_dir = COLOR_IN_DIR
|
||||
if choice == 2 then
|
||||
list_dir = COLOR_OUT_DIR
|
||||
end
|
||||
|
||||
local files = list_profiles(list_dir)
|
||||
|
||||
if #files == 0 then
|
||||
cpm.widgets.remove_box.visible = false
|
||||
else
|
||||
cpm.widgets.remove_box.visible = true
|
||||
end
|
||||
|
||||
clear_profile_box()
|
||||
|
||||
for i, file in ipairs(files) do
|
||||
cpm.widgets.profile_box[i] = dt.new_widget("check_button"){value = false, label = file}
|
||||
end
|
||||
end
|
||||
|
||||
local function install_module()
|
||||
if not cpm.module_installed then
|
||||
dt.register_lib(
|
||||
MODULE_NAME, -- Module name
|
||||
_("color profile manager"), -- Visible name
|
||||
true, -- expandable
|
||||
false, -- resetable
|
||||
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_LEFT_CENTER", 0}}, -- containers
|
||||
cpm.widgets.main_widget,
|
||||
nil,-- view_enter
|
||||
nil -- view_leave
|
||||
)
|
||||
cpm.module_installed = true
|
||||
dt.control.sleep(500)
|
||||
if not cpm.initialized then
|
||||
cpm.widgets.profile_set.visible = false
|
||||
cpm.widgets.add_box.visible = false
|
||||
cpm.widgets.remove_box.visible = false
|
||||
else
|
||||
cpm.widgets.profile_set.selected = 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.gui.libs[MODULE_NAME].visible = false
|
||||
return
|
||||
end
|
||||
|
||||
local function restart()
|
||||
dt.gui.libs[MODULE_NAME].visible = true
|
||||
return
|
||||
end
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- W I D G E T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
cpm.initialized = check_for_directories()
|
||||
dt.print_log("cpm.initialized is " .. tostring(cpm.initialized))
|
||||
|
||||
if not cpm.initialized then
|
||||
dt.print_log("creating init_box")
|
||||
cpm.widgets.init_box = dt.new_widget("box"){
|
||||
orientation = "vertical",
|
||||
dt.new_widget("button"){
|
||||
label = _("initialize color profiles"),
|
||||
tooltip = _("create the directory structure to contain the color profiles"),
|
||||
clicked_callback = function(this)
|
||||
add_directories()
|
||||
cpm.initialized = true
|
||||
cpm.widgets.profile_set.visible = true
|
||||
cpm.widgets.add_box.visible = true
|
||||
cpm.widgets.remove_box.visible = false
|
||||
cpm.widgets.init_box.visible = false
|
||||
cpm.widgets.profile_set.selected = 1
|
||||
end
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
cpm.widgets.profile_set = dt.new_widget("combobox"){
|
||||
label = _("select profile set"),
|
||||
tooltip = _("select input or output profiles"),
|
||||
visible = cpm.initialized and false or true,
|
||||
changed_callback = function(this)
|
||||
if cpm.initialized then
|
||||
list_profile_callback(this.selected)
|
||||
end
|
||||
end,
|
||||
_("input"), _("output"),
|
||||
}
|
||||
|
||||
cpm.widgets.profile_selector = dt.new_widget("file_chooser_button"){
|
||||
title = _("select color profile to add"),
|
||||
tooltip = _("select the .icc or .icm file to add"),
|
||||
is_directory = false
|
||||
}
|
||||
|
||||
cpm.widgets.add_box = dt.new_widget("box"){
|
||||
orientation = "vertical",
|
||||
visible = cpm.initialized and true or false,
|
||||
dt.new_widget("section_label"){label = _("add profile")},
|
||||
cpm.widgets.profile_selector,
|
||||
dt.new_widget("button"){
|
||||
label = _("add selected color profile"),
|
||||
tooltip = _("add selected file to profiles"),
|
||||
clicked_callback = function(this)
|
||||
add_profile_callback()
|
||||
end
|
||||
}
|
||||
}
|
||||
|
||||
cpm.widgets.remove_box = dt.new_widget("box"){
|
||||
orientation = "vertical",
|
||||
visible = cpm.initialized and true or false,
|
||||
dt.new_widget("section_label"){label = _("remove profile")},
|
||||
cpm.widgets.profile_box,
|
||||
dt.new_widget("button"){
|
||||
label = _("remove selected profile(s)"),
|
||||
tooltip = _("remove the checked profile(s)"),
|
||||
clicked_callback = function(this)
|
||||
remove_profile_callback()
|
||||
end
|
||||
}
|
||||
}
|
||||
|
||||
local main_widgets = {}
|
||||
|
||||
if not cpm.initialized then
|
||||
table.insert(main_widgets, cpm.widgets.init_box)
|
||||
end
|
||||
table.insert(main_widgets, cpm.widgets.profile_set)
|
||||
table.insert(main_widgets, cpm.widgets.remove_box)
|
||||
table.insert(main_widgets, cpm.widgets.add_box)
|
||||
|
||||
cpm.widgets.main_widget = dt.new_widget("box"){
|
||||
orientation = "vertical",
|
||||
table.unpack(main_widgets)
|
||||
}
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- M A I N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
if dt.gui.current_view().id == "lighttable" then
|
||||
install_module()
|
||||
else
|
||||
if not cpm.event_registered then
|
||||
dt.register_event(
|
||||
MODULE_NAME, "view-changed",
|
||||
function(event, old_view, new_view)
|
||||
if new_view.name == "lighttable" and old_view.name == "darkroom" then
|
||||
install_module()
|
||||
end
|
||||
end
|
||||
)
|
||||
cpm.event_registered = true
|
||||
end
|
||||
end
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("color profile manager"),
|
||||
purpose = _("manage external darktable color profiles"),
|
||||
author = "Bill Ferguson <wpferguson@gmail.com>",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/color_profile_manager"
|
||||
}
|
||||
|
||||
script_data.destroy = destroy
|
||||
script_data.restart = restart
|
||||
script_data.destroy_method = "hide"
|
||||
script_data.show = restart
|
||||
|
||||
return script_data
|
||||
@@ -1,6 +1,7 @@
|
||||
--[[
|
||||
This file is part of darktable,
|
||||
copyright 2014-2015 by Christian Kanzian.
|
||||
Copyright 2014-2016 by Christian Kanzian
|
||||
Copyright 2016 by Holger Klemm
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@@ -17,8 +18,9 @@
|
||||
]]
|
||||
|
||||
--[[
|
||||
(MULTI-)COPY ATTACH DETACH TAG
|
||||
A simple script that will create three shortcuts to copy, attach and detach image tags.
|
||||
(MULTI-)COPY ATTACH DETACH REPLACE TAGS
|
||||
This script that will create four shortcuts and add a modul in lighttable mode to copy,
|
||||
paste, replace and remove tags from images.
|
||||
|
||||
INSTALATION
|
||||
* copy this file in $CONFIGDIR/lua/ where CONFIGDIR is your darktable configuration directory
|
||||
@@ -27,17 +29,53 @@ INSTALATION
|
||||
USAGE
|
||||
* set the shortcuts for copy, attach and detach in the preferences dialog
|
||||
* <your shortcut1> copy will create a list of tags from all selected images
|
||||
* <your shortcut2> attaches copied tags to selected images, whereby
|
||||
* <your shortcut2> paste copied tags to selected images, whereby
|
||||
darktable internal tags starting with 'darktable|' will not be touched
|
||||
* <your shortcut3> detach removes all expect darktable internal tags from selected images
|
||||
|
||||
* <your shortcut3> removes all expect darktable internal tags from selected images
|
||||
* <your shortcut4> replaces all tags expect darktable internals
|
||||
* A module reset will empty the clipboard
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
dt.configuration.check_version(...,{2,0,0})
|
||||
local du = require "lib/dtutils"
|
||||
local debug = require "darktable.debug"
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
du.check_min_api_version("7.0.0", "copy_attach_detach_tags")
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("copy attach detach tags"),
|
||||
purpose = _("shortcuts to copy, paste, replace, or remove tags from images"),
|
||||
author = "Christian Kanzian",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/copy_attach_detach_tags"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
local cadt = {}
|
||||
cadt.module_installed = false
|
||||
cadt.event_registered = false
|
||||
cadt.widget_table = {}
|
||||
|
||||
|
||||
local image_tags = {}
|
||||
|
||||
|
||||
local taglist_label = dt.new_widget("label"){selectable = true, ellipsize = "middle", halign = "start"}
|
||||
taglist_label.label = ""
|
||||
|
||||
local function mcopy_tags()
|
||||
local sel_images = dt.gui.action_images
|
||||
local tag_list_tmp = {}
|
||||
@@ -65,7 +103,21 @@ local function mcopy_tags()
|
||||
end
|
||||
end
|
||||
|
||||
dt.print("Image tags copied ...")
|
||||
dt.print(_('image tags copied ...'))
|
||||
|
||||
--create UI tag list
|
||||
local taglist = ""
|
||||
|
||||
for _,tag in ipairs(image_tags) do
|
||||
if taglist == "" then
|
||||
taglist = tostring(tag)
|
||||
else
|
||||
taglist = taglist.."\n"..tostring(tag)
|
||||
end
|
||||
end
|
||||
|
||||
taglist_label.label = taglist
|
||||
|
||||
return(image_tags)
|
||||
end
|
||||
|
||||
@@ -73,7 +125,8 @@ local function mcopy_tags()
|
||||
local function attach_tags()
|
||||
|
||||
if next(image_tags) == nil then
|
||||
dt.print("No tags to attached, please copy tags first.")
|
||||
dt.print(_('no tag to attach, please copy tags first.'))
|
||||
return true
|
||||
end
|
||||
|
||||
local sel_images = dt.gui.action_images
|
||||
@@ -96,12 +149,9 @@ local function attach_tags()
|
||||
end
|
||||
end
|
||||
end
|
||||
dt.print("Tags attached ...")
|
||||
dt.print(_('tags attached ...'))
|
||||
end
|
||||
|
||||
|
||||
|
||||
|
||||
local function detach_tags()
|
||||
local sel_images = dt.gui.action_images
|
||||
|
||||
@@ -115,24 +165,159 @@ local function detach_tags()
|
||||
end
|
||||
end
|
||||
end
|
||||
dt.print("Tags removed from image(s).")
|
||||
dt.print(_('tags removed from image(s).'))
|
||||
end
|
||||
|
||||
local function replace_tags()
|
||||
detach_tags()
|
||||
attach_tags()
|
||||
dt.print(_('tags replaced'))
|
||||
end
|
||||
|
||||
local function install_module()
|
||||
if not cadt.module_installed then
|
||||
dt.register_lib("tagging_addon",_('tagging addon'),true,true,{
|
||||
[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER",500}
|
||||
},
|
||||
dt.new_widget("box") {
|
||||
-- orientation = "vertical",
|
||||
reset_callback = function()
|
||||
taglist_label.label = ""
|
||||
image_tags = {}
|
||||
end,
|
||||
table.unpack(cadt.widget_table),
|
||||
},
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
cadt.module_installed = true
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.destroy_event("cadt_ct", "shortcut")
|
||||
dt.destroy_event("cadt_at", "shortcut")
|
||||
dt.destroy_event("cadt_dt", "shortcut")
|
||||
dt.destroy_event("cadt_rt", "shortcut")
|
||||
dt.gui.libs["tagging_addon"].visible = false
|
||||
end
|
||||
|
||||
local function restart()
|
||||
-- shortcut for copy
|
||||
dt.register_event("shortcut",
|
||||
dt.register_event("cadt_ct", "shortcut",
|
||||
mcopy_tags,
|
||||
"copy tags from selected image(s)")
|
||||
_('copy tags from selected image(s)'))
|
||||
|
||||
-- shortcut for attach
|
||||
dt.register_event("shortcut",
|
||||
dt.register_event("cadt_at", "shortcut",
|
||||
attach_tags,
|
||||
"attach tags to selected image(s)")
|
||||
_('paste tags to selected image(s)'))
|
||||
|
||||
-- shortcut for detaching tags
|
||||
dt.register_event("shortcut",
|
||||
dt.register_event("cadt_dt", "shortcut",
|
||||
detach_tags,
|
||||
"detach tags to selected image(s)")
|
||||
_('remove tags from selected image(s)'))
|
||||
|
||||
-- shortcut for replace tags
|
||||
dt.register_event("cadt_rt", "shortcut",
|
||||
replace_tags,
|
||||
_('replace tags from selected image(s)'))
|
||||
dt.gui.libs["tagging_addon"].visible = true
|
||||
end
|
||||
|
||||
local function show()
|
||||
dt.gui.libs["tagging_addon"].visible = true
|
||||
end
|
||||
|
||||
-- create modul Tagging addons
|
||||
taglist_label.reset_callback = mcopy_tags
|
||||
|
||||
-- create buttons and elements
|
||||
local taglabel = dt.new_widget("label") {
|
||||
label = _('tag clipboard'),
|
||||
selectable = false,
|
||||
ellipsize = "middle",
|
||||
halign = "start"}
|
||||
|
||||
local box1 = dt.new_widget("box"){
|
||||
orientation = "horizontal",
|
||||
dt.new_widget("button") {
|
||||
label = _('multi copy tags'),
|
||||
tooltip = _('copy tags from selected image(s)'),
|
||||
clicked_callback = mcopy_tags},
|
||||
dt.new_widget("button") {
|
||||
tooltip = _('paste tags to selected image(s)'),
|
||||
label = _('paste tags'),
|
||||
clicked_callback = attach_tags}
|
||||
}
|
||||
|
||||
local box2 = dt.new_widget("box"){
|
||||
orientation = "horizontal",
|
||||
dt.new_widget("button") {
|
||||
label = _('replace tags'),
|
||||
tooltip = _('replace tags from selected image(s)'),
|
||||
clicked_callback = replace_tags},
|
||||
dt.new_widget("button") {
|
||||
label = _('remove all tags'),
|
||||
tooltip = _('remove tags from selected image(s)'),
|
||||
clicked_callback = detach_tags}
|
||||
}
|
||||
|
||||
local sep = dt.new_widget("separator"){}
|
||||
|
||||
-- pack elements into widget table for a nicer layout
|
||||
|
||||
cadt.widget_table[1] = box1
|
||||
cadt.widget_table[#cadt.widget_table+1] = box2
|
||||
|
||||
cadt.widget_table[#cadt.widget_table+1] = sep
|
||||
cadt.widget_table[#cadt.widget_table+1] = taglabel
|
||||
cadt.widget_table[#cadt.widget_table+1] = taglist_label
|
||||
|
||||
|
||||
-- create modul
|
||||
if dt.gui.current_view().id == "lighttable" then
|
||||
install_module()
|
||||
else
|
||||
if not cadt.event_registered then
|
||||
dt.register_event(
|
||||
"cadt", "view-changed",
|
||||
function(event, old_view, new_view)
|
||||
if new_view.name == "lighttable" and old_view.name == "darkroom" then
|
||||
install_module()
|
||||
end
|
||||
end
|
||||
)
|
||||
cadt.event_registered = true
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- shortcut for copy
|
||||
dt.register_event("cadt_ct", "shortcut",
|
||||
mcopy_tags,
|
||||
_('copy tags from selected image(s)'))
|
||||
|
||||
-- shortcut for attach
|
||||
dt.register_event("cadt_at", "shortcut",
|
||||
attach_tags,
|
||||
_('paste tags to selected image(s)'))
|
||||
|
||||
-- shortcut for detaching tags
|
||||
dt.register_event("cadt_dt", "shortcut",
|
||||
detach_tags,
|
||||
_('remove tags from selected image(s)'))
|
||||
|
||||
-- shortcut for replace tags
|
||||
dt.register_event("cadt_rt", "shortcut",
|
||||
replace_tags,
|
||||
_('replace tags from selected image(s)'))
|
||||
|
||||
script_data.destroy = destroy
|
||||
script_data.restart = restart
|
||||
script_data.destroy_method = "hide"
|
||||
script_data.show = show
|
||||
|
||||
return script_data
|
||||
-- vim: shiftwidth=2 expandtab tabstop=2 cindent syntax=lua
|
||||
-- kate: tab-indents: off; indent-width 2; replace-tabs on; remove-trailing-space on;
|
||||
|
||||
@@ -33,9 +33,31 @@ USAGE
|
||||
]]
|
||||
|
||||
local darktable = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
|
||||
-- Tested with darktable 1.6.2
|
||||
darktable.configuration.check_version(...,{2,0,2})
|
||||
du.check_min_api_version("7.0.0", "cr2hdr")
|
||||
|
||||
local gettext = darktable.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("cr2hdr"),
|
||||
purpose = _("process Magic Lantern dual ISO images"),
|
||||
author = "Till Theato <theato@ttill.de>",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/cr2hdr"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
local queue = {}
|
||||
local processed_files = {}
|
||||
@@ -60,7 +82,7 @@ end
|
||||
local function convert_image(image)
|
||||
if string.sub(image.filename, -3) == "CR2" then
|
||||
local filename = image.path .. "/" .. image.filename
|
||||
local result = coroutine.yield("RUN_COMMAND", "cr2hdr " .. filename)
|
||||
local result = darktable.control.execute( "cr2hdr " .. filename)
|
||||
local out_filename = string.gsub(filename, ".CR2", ".DNG")
|
||||
local file = io.open(out_filename)
|
||||
if file then
|
||||
@@ -78,7 +100,7 @@ end
|
||||
local function convert_images()
|
||||
if next(queue) == nil then return end
|
||||
|
||||
job = darktable.gui.create_job("Dual ISO conversion", true, stop_conversion)
|
||||
job = darktable.gui.create_job(_("dual ISO conversion"), true, stop_conversion)
|
||||
for key,image in pairs(queue) do
|
||||
if job.valid then
|
||||
job.percent = (key-1)/#queue
|
||||
@@ -89,7 +111,7 @@ local function convert_images()
|
||||
end
|
||||
local success_count = 0
|
||||
for _ in pairs(processed_files) do success_count = success_count + 1 end
|
||||
darktable.print("Dual ISO conversion successful on " .. success_count .. "/" .. #queue .. " images.")
|
||||
darktable.print(string.format(_("dual ISO conversion successful on %d/%d images."), success_count, #queue))
|
||||
job.valid = false
|
||||
processed_files = {}
|
||||
queue = {}
|
||||
@@ -106,8 +128,21 @@ local function convert_action_images(shortcut)
|
||||
convert_images()
|
||||
end
|
||||
|
||||
darktable.register_event("shortcut", convert_action_images, "Run cr2hdr (Magic Lantern DualISO converter) on selected images")
|
||||
darktable.register_event("post-import-image", file_imported)
|
||||
darktable.register_event("post-import-film", film_imported)
|
||||
local function destroy()
|
||||
darktable.destroy_event("cr2hdr", "shortcut")
|
||||
darktable.destroy_event("cr2hdr", "post-import-image")
|
||||
darktable.destroy_event("cr2hdr", "post-import-film")
|
||||
end
|
||||
|
||||
darktable.preferences.register("cr2hdr", "onimport", "bool", "Invoke on import", "If true then cr2hdr will try to proccess every file during importing. Warning: cr2hdr is quite slow even in figuring out on whether the file is Dual ISO or not.", false)
|
||||
darktable.register_event("cr2hdr", "shortcut",
|
||||
convert_action_images, _("run cr2hdr (Magic Lantern DualISO converter) on selected images"))
|
||||
darktable.register_event("cr2hdr", "post-import-image",
|
||||
file_imported)
|
||||
darktable.register_event("cr2hdr", "post-import-film",
|
||||
film_imported)
|
||||
|
||||
darktable.preferences.register("cr2hdr", "onimport", "bool", _("invoke on import"), _("if true then cr2hdr will try to proccess every file during importing\nwarning: cr2hdr is quite slow even in figuring out on whether the file is dual ISO or not."), false)
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
return script_data
|
||||
|
||||
162
contrib/cycle_group_leader.lua
Normal file
162
contrib/cycle_group_leader.lua
Normal file
@@ -0,0 +1,162 @@
|
||||
--[[
|
||||
|
||||
cycle_group_leader.lua - change image group leader
|
||||
|
||||
Copyright (C) 2024 Bill Ferguson <wpferguson@gmail.com>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
cycle_group_leader - change image grouip leader
|
||||
|
||||
cycle_group_leader changes the group leader to the next
|
||||
image in the group. If the end of the group is reached
|
||||
then the next image is wrapped around to the first image.
|
||||
|
||||
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
None
|
||||
|
||||
USAGE
|
||||
* enable with script_manager
|
||||
* assign a key to the shortcut
|
||||
|
||||
BUGS, COMMENTS, SUGGESTIONS
|
||||
Bill Ferguson <wpferguson@gmail.com>
|
||||
|
||||
CHANGES
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- C O N S T A N T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local MODULE = "cycle_group_leader"
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- A P I C H E C K
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
du.check_min_api_version("7.0.0", MODULE)
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- I 1 8 N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- S C R I P T M A N A G E R I N T E G R A T I O N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("cycle group leader"),
|
||||
purpose = _("change image group leader"),
|
||||
author = "Bill Ferguson <wpferguson@gmail.com>",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/cycle_group_leader"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- F U N C T I O N S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function toggle_global_toolbox_grouping()
|
||||
dt.gui.libs.global_toolbox.grouping = false
|
||||
dt.gui.libs.global_toolbox.grouping = true
|
||||
end
|
||||
|
||||
local function hinter_msg(msg)
|
||||
dt.print_hinter(msg)
|
||||
dt.control.sleep(1500)
|
||||
dt.print_hinter(" ")
|
||||
end
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- M A I N P R O G R A M
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function cycle_group_leader(image)
|
||||
local group_images = image:get_group_members()
|
||||
if #group_images < 2 then
|
||||
hinter_msg(_("no images to cycle through in group"))
|
||||
return
|
||||
else
|
||||
local position = nil
|
||||
for i, img in ipairs(group_images) do
|
||||
if image == img then
|
||||
position = i
|
||||
end
|
||||
end
|
||||
|
||||
if position == #group_images then
|
||||
position = 1
|
||||
else
|
||||
position = position + 1
|
||||
end
|
||||
|
||||
new_leader = group_images[position]
|
||||
new_leader:make_group_leader()
|
||||
dt.gui.selection({new_leader})
|
||||
|
||||
if dt.gui.libs.global_toolbox.grouping then
|
||||
-- toggle the grouping to make the new leader show
|
||||
toggle_global_toolbox_grouping()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- D A R K T A B L E I N T E G R A T I O N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function destroy()
|
||||
-- put things to destroy (events, storages, etc) here
|
||||
dt.destroy_event(MODULE, "shortcut")
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- E V E N T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
dt.register_event(MODULE, "shortcut",
|
||||
function(event, shortcut)
|
||||
-- ignore the film roll, it contains all the images, not just the imported
|
||||
local images = dt.gui.selection()
|
||||
if #images < 1 then
|
||||
dt.print(_("no image selected, please select an image and try again"))
|
||||
else
|
||||
cycle_group_leader(images[1])
|
||||
end
|
||||
end,
|
||||
_("cycle group leader")
|
||||
)
|
||||
|
||||
return script_data
|
||||
486
contrib/darktable-api/api.lua
Normal file
486
contrib/darktable-api/api.lua
Normal file
@@ -0,0 +1,486 @@
|
||||
-- Route handlers. Each function receives a request table and returns:
|
||||
-- status (int), body (string), content_type (string|nil), extra_headers (table|nil)
|
||||
local json = require "json"
|
||||
local auth = require "auth"
|
||||
local db = require "db"
|
||||
local preview = require "preview"
|
||||
local config = require "config"
|
||||
local modules = require "modules"
|
||||
local socket = require "socket"
|
||||
|
||||
-- ── Refresh queue (IPC with the darktable bridge plugin) ──────────────────────
|
||||
-- The bridge plugin running inside darktable polls this file and calls
|
||||
-- image:drop_cache() + image:generate_cache() for each queued image ID.
|
||||
--
|
||||
-- We append atomically via a .tmp + rename idiom to avoid partial reads:
|
||||
-- 1. Write IDs to <queue>.tmp.<pid>
|
||||
-- 2. Append that file's contents to the main queue (or rename if queue absent)
|
||||
-- In practice, for low-frequency refresh calls a simple append is fine because
|
||||
-- the bridge reads and renames the queue file atomically.
|
||||
local function enqueue_refresh(image_id)
|
||||
local qf = config.refresh_queue
|
||||
local f, ferr = io.open(qf, "a")
|
||||
if not f then
|
||||
return false, "cannot open refresh queue " .. qf .. ": " .. (ferr or "?")
|
||||
end
|
||||
f:write(tostring(image_id) .. "\n")
|
||||
f:close()
|
||||
return true
|
||||
end
|
||||
|
||||
local function enqueue_export(image_id)
|
||||
local qf = config.export_queue
|
||||
local f, ferr = io.open(qf, "a")
|
||||
if not f then
|
||||
return false, "cannot open export queue " .. qf .. ": " .. (ferr or "?")
|
||||
end
|
||||
f:write(tostring(image_id) .. "\n")
|
||||
f:close()
|
||||
return true
|
||||
end
|
||||
|
||||
local function export_path(image_id)
|
||||
return config.export_dir .. "/" .. tostring(image_id) .. ".jpg"
|
||||
end
|
||||
|
||||
local M = {}
|
||||
|
||||
-- ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
local function ok(data, ct)
|
||||
return 200, json.encode(data), ct or "application/json; charset=utf-8"
|
||||
end
|
||||
|
||||
local function err(status, code, detail)
|
||||
local body = json.encode({ error = code, detail = detail })
|
||||
return status, body, "application/json; charset=utf-8"
|
||||
end
|
||||
|
||||
-- Parse application/x-www-form-urlencoded or application/json body.
|
||||
local function parse_body(req)
|
||||
local ct = (req.headers["content-type"] or ""):lower()
|
||||
if ct:find("application/json", 1, true) then
|
||||
return json.decode(req.body or "{}")
|
||||
end
|
||||
-- form-encoded
|
||||
local fields = {}
|
||||
for k, v in (req.body or ""):gmatch("([^&=]+)=([^&]*)") do
|
||||
k = k:gsub("+", " "):gsub("%%(%x%x)", function(h) return string.char(tonumber(h,16)) end)
|
||||
v = v:gsub("+", " "):gsub("%%(%x%x)", function(h) return string.char(tonumber(h,16)) end)
|
||||
fields[k] = v
|
||||
end
|
||||
return fields
|
||||
end
|
||||
|
||||
-- Authenticate the request and check an optional scope.
|
||||
-- Returns: payload on success.
|
||||
-- Returns: nil, status, body, ct on failure (caller does "return s,b,ct").
|
||||
local function require_auth(req, scope)
|
||||
local payload, aerr = auth.authenticate(req)
|
||||
if not payload then
|
||||
return nil, err(401, "unauthorized", aerr)
|
||||
end
|
||||
if scope and not auth.require_scope(payload, scope) then
|
||||
return nil, err(403, "forbidden", "scope '" .. scope .. "' required")
|
||||
end
|
||||
return payload
|
||||
end
|
||||
|
||||
-- ── Public endpoints ──────────────────────────────────────────────────────────
|
||||
|
||||
-- GET /health — liveness check, no auth required
|
||||
function M.health(req)
|
||||
return ok({ status = "ok", timestamp = os.time() })
|
||||
end
|
||||
|
||||
-- ── OAuth 2.0 ─────────────────────────────────────────────────────────────────
|
||||
-- POST /oauth/token
|
||||
-- Accepts: application/x-www-form-urlencoded or application/json
|
||||
-- Required fields: grant_type=client_credentials, client_id, client_secret
|
||||
-- Optional: scope (space-separated, must be subset of client's scopes)
|
||||
--
|
||||
-- Returns: { access_token, token_type, expires_in, scope }
|
||||
function M.oauth_token(req)
|
||||
local fields = parse_body(req)
|
||||
if not fields then
|
||||
return err(400, "invalid_request", "could not parse request body")
|
||||
end
|
||||
|
||||
if fields.grant_type ~= "client_credentials" then
|
||||
return err(400, "unsupported_grant_type",
|
||||
"only 'client_credentials' is supported")
|
||||
end
|
||||
|
||||
-- Also accept Basic Auth header for client credentials
|
||||
local client_id, client_secret = fields.client_id, fields.client_secret
|
||||
if not client_id then
|
||||
local basic = (req.headers["authorization"] or ""):match("^[Bb]asic%s+(.+)$")
|
||||
if basic then
|
||||
local decoded = require("base64").decode(basic)
|
||||
client_id, client_secret = decoded:match("^([^:]+):(.+)$")
|
||||
end
|
||||
end
|
||||
|
||||
if not client_id or not client_secret then
|
||||
return err(400, "invalid_request", "client_id and client_secret are required")
|
||||
end
|
||||
|
||||
local ok_flag, client_scopes = auth.validate_client(client_id, client_secret)
|
||||
if not ok_flag then
|
||||
return err(401, "invalid_client", client_scopes)
|
||||
end
|
||||
|
||||
-- Restrict to requested scopes if provided
|
||||
local granted = client_scopes
|
||||
if fields.scope and fields.scope ~= "" then
|
||||
local requested = {}
|
||||
for s in fields.scope:gmatch("%S+") do requested[s] = true end
|
||||
granted = {}
|
||||
for _, s in ipairs(client_scopes) do
|
||||
if requested[s] then granted[#granted+1] = s end
|
||||
end
|
||||
if #granted == 0 then
|
||||
return err(400, "invalid_scope", "none of the requested scopes are available")
|
||||
end
|
||||
end
|
||||
|
||||
local token = auth.issue_token(client_id, granted)
|
||||
return ok({
|
||||
access_token = token,
|
||||
token_type = "Bearer",
|
||||
expires_in = config.token_ttl,
|
||||
scope = table.concat(granted, " "),
|
||||
})
|
||||
end
|
||||
|
||||
-- ── Images ────────────────────────────────────────────────────────────────────
|
||||
-- GET /api/v1/images
|
||||
-- Query params: rating_min, rating_max, rating, film_id, from, to,
|
||||
-- color_label, is_raw, is_hdr, search, tag,
|
||||
-- limit (1-500, default 50), offset, sort_by, sort_dir
|
||||
function M.list_images(req)
|
||||
local payload, s, b, ct = require_auth(req, "images:read")
|
||||
if not payload then return s, b, ct end
|
||||
|
||||
local result = db.list_images(req.params)
|
||||
return ok(result)
|
||||
end
|
||||
|
||||
-- GET /api/v1/images/:id
|
||||
function M.get_image(req)
|
||||
local payload, s, b, ct = require_auth(req, "images:read")
|
||||
if not payload then return s, b, ct end
|
||||
|
||||
local id = tonumber(req.path_params.id)
|
||||
if not id then return err(400, "invalid_id", "id must be an integer") end
|
||||
|
||||
local img = db.get_image(id)
|
||||
if not img then return err(404, "not_found", "image " .. id .. " not found") end
|
||||
|
||||
return ok(img)
|
||||
end
|
||||
|
||||
-- ── Previews ──────────────────────────────────────────────────────────────────
|
||||
-- GET /api/v1/images/:id/preview[?size=thumb|small|medium|large|xlarge|0-5]
|
||||
-- Returns JPEG data directly (Content-Type: image/jpeg).
|
||||
function M.get_preview(req)
|
||||
local payload, s, b, ct = require_auth(req, "previews:read")
|
||||
if not payload then return s, b, ct end
|
||||
|
||||
local id = tonumber(req.path_params.id)
|
||||
if not id then return err(400, "invalid_id", "id must be an integer") end
|
||||
|
||||
local img = db.get_image(id)
|
||||
if not img then return err(404, "not_found", "image " .. id .. " not found") end
|
||||
|
||||
local data, ct, redirect = preview.get(id, img.path, img.filename, req.params.size)
|
||||
|
||||
if data then
|
||||
return 200, data, ct
|
||||
end
|
||||
|
||||
if redirect then
|
||||
-- Attempt to serve the local file directly rather than issuing a redirect,
|
||||
-- since file:// URLs are not useful to HTTP clients.
|
||||
local file_data = preview.read_local(redirect)
|
||||
if file_data then
|
||||
local ext = (img.filename or ""):match("%.([^%.]+)$") or ""
|
||||
local mime = ext:lower():match("^png$") and "image/png" or "image/jpeg"
|
||||
return 200, file_data, mime
|
||||
end
|
||||
end
|
||||
|
||||
-- No cached preview exists. Enqueue a full-pipeline thumbnail generation
|
||||
-- so the bridge renders one; the client should retry after a few seconds.
|
||||
enqueue_refresh(id)
|
||||
return 202, json.encode({
|
||||
status = "generating",
|
||||
image_id = id,
|
||||
retry_after = 4,
|
||||
message = "thumbnail is being generated; retry in a few seconds",
|
||||
}), "application/json; charset=utf-8", { ["Retry-After"] = "4" }
|
||||
end
|
||||
|
||||
-- ── Tags ──────────────────────────────────────────────────────────────────────
|
||||
-- GET /api/v1/tags[?search=&limit=&offset=]
|
||||
function M.list_tags(req)
|
||||
local payload, s, b, ct = require_auth(req, "tags:read")
|
||||
if not payload then return s, b, ct end
|
||||
|
||||
local tags = db.list_tags(req.params)
|
||||
return ok({ items = tags })
|
||||
end
|
||||
|
||||
-- ── Film rolls ────────────────────────────────────────────────────────────────
|
||||
-- GET /api/v1/films[?search=&limit=&offset=]
|
||||
function M.list_films(req)
|
||||
local payload, s, b, ct = require_auth(req, "films:read")
|
||||
if not payload then return s, b, ct end
|
||||
|
||||
local films = db.list_films(req.params)
|
||||
return ok({ items = films })
|
||||
end
|
||||
|
||||
-- ── Cache refresh ─────────────────────────────────────────────────────────────
|
||||
-- POST /api/v1/images/:id/refresh
|
||||
-- Enqueues the image for thumbnail regeneration via the darktable bridge.
|
||||
-- The bridge calls image:drop_cache() + image:generate_cache(true, …), which
|
||||
-- re-renders through darktable's full processing pipeline so that development
|
||||
-- settings (tone curves, exposure, colour grading, etc.) are reflected.
|
||||
-- Requires scope: cache:refresh
|
||||
function M.refresh_cache(req)
|
||||
local payload, s, b, ct = require_auth(req, "cache:refresh")
|
||||
if not payload then return s, b, ct end
|
||||
|
||||
local id = tonumber(req.path_params.id)
|
||||
if not id then return err(400, "invalid_id", "id must be an integer") end
|
||||
|
||||
local img = db.get_image(id)
|
||||
if not img then return err(404, "not_found", "image " .. id .. " not found") end
|
||||
|
||||
local queued, qerr = enqueue_refresh(id)
|
||||
if not queued then
|
||||
return err(500, "queue_error", qerr)
|
||||
end
|
||||
|
||||
return 202, json.encode({
|
||||
status = "accepted",
|
||||
image_id = id,
|
||||
filename = img.filename,
|
||||
queue = config.refresh_queue,
|
||||
message = "cache refresh enqueued; darktable bridge will re-render thumbnails shortly",
|
||||
}), "application/json; charset=utf-8"
|
||||
end
|
||||
|
||||
-- ── Development modules ───────────────────────────────────────────────────────
|
||||
-- GET /api/v1/images/:id/modules
|
||||
-- Returns every operation in the history stack (active entry per operation).
|
||||
-- Response includes decoded params for known modules; base64 blob for others.
|
||||
-- Requires scope: images:read
|
||||
function M.list_modules(req)
|
||||
local payload, s, b, ct = require_auth(req, "images:read")
|
||||
if not payload then return s, b, ct end
|
||||
|
||||
local id = tonumber(req.path_params.id)
|
||||
if not id then return err(400, "invalid_id", "id must be an integer") end
|
||||
|
||||
local img = db.get_image(id)
|
||||
if not img then return err(404, "not_found", "image " .. id .. " not found") end
|
||||
|
||||
local mods = db.list_modules(id)
|
||||
return ok({ image_id = id, items = mods })
|
||||
end
|
||||
|
||||
-- GET /api/v1/images/:id/modules/:op
|
||||
-- Returns the active history entry for one operation.
|
||||
-- Requires scope: images:read
|
||||
function M.get_module(req)
|
||||
local payload, s, b, ct = require_auth(req, "images:read")
|
||||
if not payload then return s, b, ct end
|
||||
|
||||
local id = tonumber(req.path_params.id)
|
||||
local op = req.path_params.op
|
||||
if not id then return err(400, "invalid_id", "id must be an integer") end
|
||||
|
||||
local img = db.get_image(id)
|
||||
if not img then return err(404, "not_found", "image " .. id .. " not found") end
|
||||
|
||||
local mod = db.get_module(id, op)
|
||||
if not mod then
|
||||
return err(404, "module_not_found",
|
||||
"operation '" .. op .. "' not in history for image " .. id)
|
||||
end
|
||||
return ok(mod)
|
||||
end
|
||||
|
||||
-- PATCH /api/v1/images/:id/modules/:op
|
||||
-- Body (JSON):
|
||||
-- {
|
||||
-- "enabled": true|false, -- optional: change enabled state
|
||||
-- "params": { field: value, … } -- optional: named params for known modules
|
||||
-- "params_b64": "<base64>" -- alternative: raw binary for unknown modules
|
||||
-- }
|
||||
-- Query param: ?preview=thumb|small|medium|large|xlarge
|
||||
-- If given, waits up to 30 s for the thumbnail to update and returns the JPEG.
|
||||
-- Otherwise returns 204 No Content.
|
||||
-- Requires scope: cache:refresh
|
||||
function M.patch_module(req)
|
||||
local payload, s, b, ct = require_auth(req, "cache:refresh")
|
||||
if not payload then return s, b, ct end
|
||||
|
||||
local id = tonumber(req.path_params.id)
|
||||
local op = req.path_params.op
|
||||
if not id then return err(400, "invalid_id", "id must be an integer") end
|
||||
|
||||
local img = db.get_image(id)
|
||||
if not img then return err(404, "not_found", "image " .. id .. " not found") end
|
||||
|
||||
local mod = db.get_module(id, op)
|
||||
if not mod then
|
||||
return err(404, "module_not_found",
|
||||
"operation '" .. op .. "' not in history for image " .. id)
|
||||
end
|
||||
|
||||
-- Parse request body
|
||||
local body = parse_body(req)
|
||||
if not body then
|
||||
return err(400, "invalid_body", "could not parse JSON body")
|
||||
end
|
||||
|
||||
local new_enabled = body.enabled -- may be nil (leave unchanged)
|
||||
local new_blob = nil
|
||||
|
||||
if body.params_b64 then
|
||||
-- Caller provided raw base64 params (for unknown modules)
|
||||
local ok_dec, decoded = pcall(require("base64").decode, body.params_b64)
|
||||
if not ok_dec then
|
||||
return err(400, "invalid_params_b64", "base64 decode failed")
|
||||
end
|
||||
new_blob = decoded
|
||||
elseif body.params then
|
||||
if mod.params then
|
||||
local existing_blob = db.get_module_blob(id, op)
|
||||
if not existing_blob then
|
||||
return err(500, "db_error", "could not read existing params blob")
|
||||
end
|
||||
new_blob = modules.encode(op, mod.module_version, existing_blob, body.params)
|
||||
if not new_blob then
|
||||
return err(400, "unsupported_module",
|
||||
"named-param editing is not supported for operation '" .. op ..
|
||||
"'; use params_b64 with a raw binary blob instead")
|
||||
end
|
||||
else
|
||||
-- Unknown module, caller must supply params_b64
|
||||
return err(400, "params_required",
|
||||
"operation '" .. op .. "' requires params_b64 (binary blob) for editing")
|
||||
end
|
||||
end
|
||||
|
||||
-- Persist to history table
|
||||
local ok_write, werr = db.set_module_params(id, op, new_enabled, new_blob)
|
||||
if not ok_write then
|
||||
return err(500, "db_write_error", werr)
|
||||
end
|
||||
|
||||
-- Enqueue a thumbnail refresh so the bridge re-renders with the new params.
|
||||
enqueue_refresh(id)
|
||||
|
||||
-- Optional synchronous preview response
|
||||
local preview_size = req.params.preview
|
||||
if preview_size and preview_size ~= "" then
|
||||
-- Read the thumbnail before the refresh so we can detect when it changes.
|
||||
local old_data = preview.get(id, img.path, img.filename, preview_size)
|
||||
local deadline = os.time() + 30
|
||||
local new_data
|
||||
while os.time() < deadline do
|
||||
socket.select(nil, nil, 0.5)
|
||||
local candidate = preview.get(id, img.path, img.filename, preview_size)
|
||||
if candidate and candidate ~= old_data then
|
||||
new_data = candidate
|
||||
break
|
||||
end
|
||||
end
|
||||
if new_data then
|
||||
return 200, new_data, "image/jpeg"
|
||||
end
|
||||
-- Timeout — return what we have (may be stale) with a warning header
|
||||
local fallback = preview.get(id, img.path, img.filename, preview_size)
|
||||
if fallback then
|
||||
return 200, fallback, "image/jpeg",
|
||||
{ ["X-Preview-Warning"] = "regeneration-timeout; thumbnail may be stale" }
|
||||
end
|
||||
return err(504, "preview_timeout",
|
||||
"thumbnail regeneration timed out after 30 s; retry the preview endpoint")
|
||||
end
|
||||
|
||||
return 204, "", nil
|
||||
end
|
||||
|
||||
-- ── Full-resolution export ────────────────────────────────────────────────────
|
||||
-- POST /api/v1/images/:id/export
|
||||
-- Removes any stale export file, enqueues the bridge job, returns 202.
|
||||
-- The client should poll GET /full until it receives a 200.
|
||||
-- Requires scope: cache:refresh
|
||||
function M.trigger_export(req)
|
||||
local payload, s, b, ct = require_auth(req, "cache:refresh")
|
||||
if not payload then return s, b, ct end
|
||||
|
||||
local id = tonumber(req.path_params.id)
|
||||
if not id then return err(400, "invalid_id", "id must be an integer") end
|
||||
|
||||
local img = db.get_image(id)
|
||||
if not img then return err(404, "not_found", "image " .. id .. " not found") end
|
||||
|
||||
-- Remove stale export so the client can detect when the fresh one lands.
|
||||
os.remove(export_path(id))
|
||||
|
||||
local queued, qerr = enqueue_export(id)
|
||||
if not queued then return err(500, "queue_error", qerr) end
|
||||
|
||||
return 202, json.encode({
|
||||
status = "accepted",
|
||||
image_id = id,
|
||||
filename = img.filename,
|
||||
message = "export enqueued; poll GET /api/v1/images/" .. id .. "/full",
|
||||
}), "application/json; charset=utf-8"
|
||||
end
|
||||
|
||||
-- GET /api/v1/images/:id/full
|
||||
-- Serves the full-resolution JPEG previously exported via POST /export.
|
||||
-- If no export exists yet, enqueues one and returns 202 so the client can retry.
|
||||
-- Requires scope: previews:read
|
||||
function M.get_full(req)
|
||||
local payload, s, b, ct = require_auth(req, "previews:read")
|
||||
if not payload then return s, b, ct end
|
||||
|
||||
local id = tonumber(req.path_params.id)
|
||||
if not id then return err(400, "invalid_id", "id must be an integer") end
|
||||
|
||||
local img = db.get_image(id)
|
||||
if not img then return err(404, "not_found", "image " .. id .. " not found") end
|
||||
|
||||
local path = export_path(id)
|
||||
local f = io.open(path, "rb")
|
||||
if f then
|
||||
local data = f:read("*a"); f:close()
|
||||
local stem = (img.filename or "export"):match("^(.+)%.[^.]+$") or (img.filename or "export")
|
||||
local disposition = string.format('attachment; filename="%s.jpg"', stem)
|
||||
return 200, data, "image/jpeg", { ["Content-Disposition"] = disposition }
|
||||
end
|
||||
|
||||
-- Not yet exported — enqueue and tell the client to retry.
|
||||
local queued, qerr = enqueue_export(id)
|
||||
if not queued then
|
||||
return err(500, "queue_error", qerr)
|
||||
end
|
||||
|
||||
return 202, json.encode({
|
||||
status = "queued",
|
||||
image_id = id,
|
||||
filename = img.filename,
|
||||
retry_after = 5,
|
||||
message = "export enqueued; retry in a few seconds",
|
||||
}), "application/json; charset=utf-8", { ["Retry-After"] = "5" }
|
||||
end
|
||||
|
||||
return M
|
||||
96
contrib/darktable-api/auth.lua
Normal file
96
contrib/darktable-api/auth.lua
Normal file
@@ -0,0 +1,96 @@
|
||||
-- OAuth 2.0 Client Credentials grant + HS256 JWT
|
||||
-- Requires: LuaJIT FFI with libcrypto (OpenSSL)
|
||||
local ffi = require "ffi"
|
||||
local base64 = require "base64"
|
||||
local json = require "json"
|
||||
local config = require "config"
|
||||
|
||||
-- ── OpenSSL HMAC-SHA256 via FFI ───────────────────────────────────────────────
|
||||
|
||||
ffi.cdef[[
|
||||
void *EVP_sha256(void);
|
||||
unsigned char *HMAC(const void *evp_md, const void *key, int key_len,
|
||||
const unsigned char *data, size_t data_len,
|
||||
unsigned char *md, unsigned int *md_len);
|
||||
]]
|
||||
|
||||
-- libcrypto ships as part of OpenSSL; path varies by distro but the soname is standard
|
||||
local crypto = ffi.load("crypto")
|
||||
|
||||
local function hmac_sha256(key, data)
|
||||
local digest = ffi.new("unsigned char[32]")
|
||||
local dlen = ffi.new("unsigned int[1]")
|
||||
crypto.HMAC(crypto.EVP_sha256(), key, #key, data, #data, digest, dlen)
|
||||
return ffi.string(digest, 32)
|
||||
end
|
||||
|
||||
-- ── JWT helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
local HEADER_B64 = base64.urlencode(json.encode({ alg = "HS256", typ = "JWT" }))
|
||||
|
||||
local function jwt_sign(payload_tbl)
|
||||
local payload = base64.urlencode(json.encode(payload_tbl))
|
||||
local signing = HEADER_B64 .. "." .. payload
|
||||
local sig = base64.urlencode(hmac_sha256(config.jwt_secret, signing))
|
||||
return signing .. "." .. sig
|
||||
end
|
||||
|
||||
local function jwt_verify(token)
|
||||
local h, p, s = token:match("^([^.]+)%.([^.]+)%.([^.]+)$")
|
||||
if not h then return nil, "malformed token" end
|
||||
|
||||
local expected = base64.urlencode(hmac_sha256(config.jwt_secret, h .. "." .. p))
|
||||
if s ~= expected then return nil, "invalid signature" end
|
||||
|
||||
local payload, err = json.decode(base64.urldecode(p))
|
||||
if not payload then return nil, "invalid payload: " .. (err or "?") end
|
||||
|
||||
if os.time() > (payload.exp or 0) then return nil, "token expired" end
|
||||
|
||||
return payload
|
||||
end
|
||||
|
||||
-- ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Validate client_id / client_secret pair; returns true + scopes or false + error.
|
||||
function M.validate_client(client_id, client_secret)
|
||||
local client = config.clients[client_id]
|
||||
if not client then return false, "unknown client" end
|
||||
if client.secret ~= client_secret then return false, "invalid client secret" end
|
||||
return true, client.scopes
|
||||
end
|
||||
|
||||
-- Issue an access token for a validated client.
|
||||
function M.issue_token(client_id, scopes)
|
||||
local now = os.time()
|
||||
return jwt_sign({
|
||||
iss = "darktable-api",
|
||||
sub = client_id,
|
||||
scope = table.concat(scopes, " "),
|
||||
iat = now,
|
||||
exp = now + config.token_ttl,
|
||||
})
|
||||
end
|
||||
|
||||
-- Extract and verify a Bearer token from a request.
|
||||
-- Returns payload table on success, or nil + error string on failure.
|
||||
function M.authenticate(req)
|
||||
local auth = (req.headers or {})["authorization"] or ""
|
||||
local token = auth:match("^[Bb]earer%s+(.+)$")
|
||||
if not token then return nil, "missing Bearer token" end
|
||||
return jwt_verify(token)
|
||||
end
|
||||
|
||||
-- Check that the authenticated payload contains a required scope.
|
||||
function M.require_scope(payload, scope)
|
||||
if not payload then return false end
|
||||
local scopes = payload.scope or ""
|
||||
for s in scopes:gmatch("%S+") do
|
||||
if s == scope then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
return M
|
||||
52
contrib/darktable-api/base64.lua
Normal file
52
contrib/darktable-api/base64.lua
Normal file
@@ -0,0 +1,52 @@
|
||||
-- Base64 and Base64url encode/decode (pure Lua, no dependencies)
|
||||
local M = {}
|
||||
|
||||
local CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
local LOOKUP = {}
|
||||
for i = 1, #CHARS do LOOKUP[CHARS:sub(i,i)] = i - 1 end
|
||||
|
||||
function M.encode(s)
|
||||
local out = {}
|
||||
for i = 1, #s, 3 do
|
||||
local a, b, c = s:byte(i, i + 2)
|
||||
b, c = b or 0, c or 0
|
||||
local n = a * 65536 + b * 256 + c
|
||||
out[#out+1] = CHARS:sub(math.floor(n / 262144) % 64 + 1, math.floor(n / 262144) % 64 + 1)
|
||||
out[#out+1] = CHARS:sub(math.floor(n / 4096) % 64 + 1, math.floor(n / 4096) % 64 + 1)
|
||||
out[#out+1] = CHARS:sub(math.floor(n / 64) % 64 + 1, math.floor(n / 64) % 64 + 1)
|
||||
out[#out+1] = CHARS:sub( n % 64 + 1, n % 64 + 1)
|
||||
end
|
||||
local rem = #s % 3
|
||||
if rem == 1 then out[#out-1], out[#out] = "=", "=" end
|
||||
if rem == 2 then out[#out] = "=" end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
function M.decode(s)
|
||||
s = s:gsub("[^A-Za-z0-9+/=]", "")
|
||||
local out = {}
|
||||
for i = 1, #s, 4 do
|
||||
local a = LOOKUP[s:sub(i, i )] or 0
|
||||
local b = LOOKUP[s:sub(i+1, i+1)] or 0
|
||||
local c = LOOKUP[s:sub(i+2, i+2)] or 0
|
||||
local d = LOOKUP[s:sub(i+3, i+3)] or 0
|
||||
local n = a * 262144 + b * 4096 + c * 64 + d
|
||||
out[#out+1] = string.char(math.floor(n / 65536))
|
||||
if s:sub(i+2, i+2) ~= "=" then out[#out+1] = string.char(math.floor(n / 256) % 256) end
|
||||
if s:sub(i+3, i+3) ~= "=" then out[#out+1] = string.char(n % 256) end
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- URL-safe Base64 (RFC 4648 §5): replace +/ with -_ and strip padding
|
||||
function M.urlencode(s)
|
||||
return M.encode(s):gsub("+", "-"):gsub("/", "_"):gsub("=", "")
|
||||
end
|
||||
|
||||
function M.urldecode(s)
|
||||
s = s:gsub("-", "+"):gsub("_", "/")
|
||||
local pad = (4 - #s % 4) % 4
|
||||
return M.decode(s .. string.rep("=", pad))
|
||||
end
|
||||
|
||||
return M
|
||||
33
contrib/darktable-api/bruno/Health Check.bru
Normal file
33
contrib/darktable-api/bruno/Health Check.bru
Normal file
@@ -0,0 +1,33 @@
|
||||
meta {
|
||||
name: Health Check
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/health
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 200", function() {
|
||||
expect(res.status).to.equal(200);
|
||||
});
|
||||
|
||||
test("status is ok", function() {
|
||||
expect(res.body.status).to.equal("ok");
|
||||
});
|
||||
|
||||
test("has timestamp", function() {
|
||||
expect(res.body.timestamp).to.be.a("number");
|
||||
expect(res.body.timestamp).to.be.above(0);
|
||||
});
|
||||
}
|
||||
|
||||
docs {
|
||||
## Health Check
|
||||
|
||||
Liveness probe — no authentication required.
|
||||
Returns `{"status":"ok","timestamp":<unix>}`.
|
||||
}
|
||||
33
contrib/darktable-api/bruno/auth/Get Token (Bad Secret).bru
Normal file
33
contrib/darktable-api/bruno/auth/Get Token (Bad Secret).bru
Normal file
@@ -0,0 +1,33 @@
|
||||
meta {
|
||||
name: Get Token (Bad Secret)
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/oauth/token
|
||||
body: form-urlencoded
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:form-urlencoded {
|
||||
grant_type: client_credentials
|
||||
client_id: {{clientId}}
|
||||
client_secret: wrong-secret
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 401", function() {
|
||||
expect(res.status).to.equal(401);
|
||||
});
|
||||
|
||||
test("error is invalid_client", function() {
|
||||
expect(res.body.error).to.equal("invalid_client");
|
||||
});
|
||||
}
|
||||
|
||||
docs {
|
||||
## Negative test — wrong client secret
|
||||
|
||||
Verifies the server rejects bad credentials with 401.
|
||||
}
|
||||
64
contrib/darktable-api/bruno/auth/Get Token.bru
Normal file
64
contrib/darktable-api/bruno/auth/Get Token.bru
Normal file
@@ -0,0 +1,64 @@
|
||||
meta {
|
||||
name: Get Token
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/oauth/token
|
||||
body: form-urlencoded
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:form-urlencoded {
|
||||
grant_type: client_credentials
|
||||
client_id: {{clientId}}
|
||||
client_secret: {{clientSecret}}
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
if (res.status === 200) {
|
||||
bru.setVar("accessToken", res.body.access_token);
|
||||
console.log("Token stored. Expires in", res.body.expires_in, "seconds.");
|
||||
console.log("Scopes:", res.body.scope);
|
||||
} else {
|
||||
console.error("Token request failed:", res.status, res.body);
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 200", function() {
|
||||
expect(res.status).to.equal(200);
|
||||
});
|
||||
|
||||
test("has access_token", function() {
|
||||
expect(res.body.access_token).to.be.a("string");
|
||||
expect(res.body.access_token.length).to.be.above(0);
|
||||
});
|
||||
|
||||
test("token_type is Bearer", function() {
|
||||
expect(res.body.token_type).to.equal("Bearer");
|
||||
});
|
||||
|
||||
test("scope includes images:read", function() {
|
||||
expect(res.body.scope).to.include("images:read");
|
||||
});
|
||||
|
||||
test("scope includes cache:refresh", function() {
|
||||
expect(res.body.scope).to.include("cache:refresh");
|
||||
});
|
||||
|
||||
test("expires_in is 3600", function() {
|
||||
expect(res.body.expires_in).to.equal(3600);
|
||||
});
|
||||
}
|
||||
|
||||
docs {
|
||||
## OAuth 2.0 — Client Credentials Grant
|
||||
|
||||
Exchanges `client_id` + `client_secret` for a short-lived Bearer JWT.
|
||||
The post-response script stores the token in `{{accessToken}}` so all
|
||||
other requests pick it up automatically.
|
||||
|
||||
Run this request first before calling any protected endpoint.
|
||||
}
|
||||
6
contrib/darktable-api/bruno/bruno.json
Normal file
6
contrib/darktable-api/bruno/bruno.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "Darktable API",
|
||||
"type": "collection",
|
||||
"ignore": []
|
||||
}
|
||||
12
contrib/darktable-api/bruno/environments/local.bru
Normal file
12
contrib/darktable-api/bruno/environments/local.bru
Normal file
@@ -0,0 +1,12 @@
|
||||
vars {
|
||||
baseUrl: http://127.0.0.1:7645
|
||||
clientId: darktable-client
|
||||
clientSecret: changeme
|
||||
accessToken:
|
||||
imageId: 1
|
||||
}
|
||||
|
||||
vars:secret [
|
||||
clientSecret,
|
||||
accessToken
|
||||
]
|
||||
12
contrib/darktable-api/bruno/environments/remote.bru
Normal file
12
contrib/darktable-api/bruno/environments/remote.bru
Normal file
@@ -0,0 +1,12 @@
|
||||
vars {
|
||||
baseUrl: http://192.168.1.134:7645
|
||||
clientId: darktable-client
|
||||
clientSecret: changeme
|
||||
accessToken:
|
||||
imageId: 1
|
||||
}
|
||||
|
||||
vars:secret [
|
||||
clientSecret,
|
||||
accessToken
|
||||
]
|
||||
54
contrib/darktable-api/bruno/films/List Films.bru
Normal file
54
contrib/darktable-api/bruno/films/List Films.bru
Normal file
@@ -0,0 +1,54 @@
|
||||
meta {
|
||||
name: List Films
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/api/v1/films
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{accessToken}}
|
||||
}
|
||||
|
||||
params:query {
|
||||
limit: 50
|
||||
offset: 0
|
||||
~search: 2018
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 200", function() {
|
||||
expect(res.status).to.equal(200);
|
||||
});
|
||||
|
||||
test("has items array", function() {
|
||||
expect(res.body.items).to.be.an("array");
|
||||
});
|
||||
|
||||
test("each film has id, folder, image_count", function() {
|
||||
res.body.items.forEach(function(film) {
|
||||
expect(film.id).to.be.a("number");
|
||||
expect(film.folder).to.be.a("string");
|
||||
expect(film.image_count).to.be.a("number");
|
||||
});
|
||||
});
|
||||
|
||||
test("image counts are positive", function() {
|
||||
res.body.items.forEach(function(film) {
|
||||
expect(film.image_count).to.be.above(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
docs {
|
||||
## List Films
|
||||
|
||||
Returns all film rolls (import folders) with their image counts, sorted by folder path.
|
||||
|
||||
Use `?search=` to filter by folder path substring.
|
||||
Use `?limit` and `?offset` for pagination (max 2000 per page).
|
||||
}
|
||||
52
contrib/darktable-api/bruno/images/Export Image.bru
Normal file
52
contrib/darktable-api/bruno/images/Export Image.bru
Normal file
@@ -0,0 +1,52 @@
|
||||
meta {
|
||||
name: Export Image
|
||||
type: http
|
||||
seq: 6
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/api/v1/images/{{imageId}}/export
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{accessToken}}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 202 Accepted", function() {
|
||||
expect(res.status).to.equal(202);
|
||||
});
|
||||
|
||||
test("status is accepted", function() {
|
||||
expect(res.body.status).to.equal("accepted");
|
||||
});
|
||||
|
||||
test("image_id matches request", function() {
|
||||
expect(res.body.image_id).to.equal(Number(bru.getVar("imageId")));
|
||||
});
|
||||
|
||||
test("has filename", function() {
|
||||
expect(res.body.filename).to.be.a("string");
|
||||
});
|
||||
|
||||
test("has export path", function() {
|
||||
expect(res.body.export).to.be.a("string");
|
||||
});
|
||||
}
|
||||
|
||||
docs {
|
||||
## Export Image (Full Resolution)
|
||||
|
||||
Enqueues a full-resolution JPEG export processed through darktable's complete
|
||||
development pipeline — tone curves, exposure, colour grading, noise reduction,
|
||||
and all other active modules are applied.
|
||||
|
||||
Returns **202 Accepted** immediately. The darktable bridge plugin writes the
|
||||
result to the path shown in `export` (default: `/tmp/dt-api-exports/<id>.jpg`).
|
||||
|
||||
Poll **GET /api/v1/images/:id/full** to retrieve the image once ready.
|
||||
|
||||
Requires scope: `cache:refresh`
|
||||
}
|
||||
57
contrib/darktable-api/bruno/images/Get Full Image.bru
Normal file
57
contrib/darktable-api/bruno/images/Get Full Image.bru
Normal file
@@ -0,0 +1,57 @@
|
||||
meta {
|
||||
name: Get Full Image
|
||||
type: http
|
||||
seq: 7
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/api/v1/images/{{imageId}}/full
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{accessToken}}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 200 or 202", function() {
|
||||
expect([200, 202]).to.include(res.status);
|
||||
});
|
||||
|
||||
test("if 200, content-type is image/jpeg", function() {
|
||||
if (res.status === 200) {
|
||||
expect(res.headers["content-type"]).to.equal("image/jpeg");
|
||||
}
|
||||
});
|
||||
|
||||
test("if 200, body is non-empty", function() {
|
||||
if (res.status === 200) {
|
||||
expect(res.body.length).to.be.above(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("if 202, export was queued", function() {
|
||||
if (res.status === 202) {
|
||||
expect(res.body.status).to.equal("queued");
|
||||
expect(res.body.retry_after).to.be.a("number");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
docs {
|
||||
## Get Full-Resolution Image
|
||||
|
||||
Returns the full-resolution JPEG developed through darktable's complete
|
||||
processing pipeline (all active development modules applied).
|
||||
|
||||
### Workflow
|
||||
1. POST `/api/v1/images/:id/export` — enqueue the export (returns 202)
|
||||
2. Wait ~2–10 s (depending on complexity and hardware)
|
||||
3. GET `/api/v1/images/:id/full` — download the result (200 with JPEG body)
|
||||
|
||||
If no export exists yet this endpoint auto-enqueues one and returns **202**
|
||||
with a `Retry-After: 5` header. Keep retrying until you receive **200**.
|
||||
|
||||
Requires scope: `previews:read`
|
||||
}
|
||||
31
contrib/darktable-api/bruno/images/Get Image (Not Found).bru
Normal file
31
contrib/darktable-api/bruno/images/Get Image (Not Found).bru
Normal file
@@ -0,0 +1,31 @@
|
||||
meta {
|
||||
name: Get Image (Not Found)
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/api/v1/images/999999
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{accessToken}}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 404", function() {
|
||||
expect(res.status).to.equal(404);
|
||||
});
|
||||
|
||||
test("error is not_found", function() {
|
||||
expect(res.body.error).to.equal("not_found");
|
||||
});
|
||||
}
|
||||
|
||||
docs {
|
||||
## Get Image — Not Found
|
||||
|
||||
Verifies a non-existent image ID returns 404.
|
||||
}
|
||||
72
contrib/darktable-api/bruno/images/Get Image.bru
Normal file
72
contrib/darktable-api/bruno/images/Get Image.bru
Normal file
@@ -0,0 +1,72 @@
|
||||
meta {
|
||||
name: Get Image
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/api/v1/images/{{imageId}}
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{accessToken}}
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
if (res.status === 200) {
|
||||
// Store this image's ID for use by preview / refresh requests
|
||||
bru.setVar("imageId", res.body.id);
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 200", function() {
|
||||
expect(res.status).to.equal(200);
|
||||
});
|
||||
|
||||
test("has correct id", function() {
|
||||
expect(res.body.id).to.equal(Number(bru.getVar("imageId")));
|
||||
});
|
||||
|
||||
test("has filename", function() {
|
||||
expect(res.body.filename).to.be.a("string");
|
||||
expect(res.body.filename.length).to.be.above(0);
|
||||
});
|
||||
|
||||
test("has path", function() {
|
||||
expect(res.body.path).to.be.a("string");
|
||||
});
|
||||
|
||||
test("has rating in valid range", function() {
|
||||
expect(res.body.rating).to.be.at.least(-1);
|
||||
expect(res.body.rating).to.be.at.most(5);
|
||||
});
|
||||
|
||||
test("has tags array", function() {
|
||||
expect(res.body.tags).to.be.an("array");
|
||||
});
|
||||
|
||||
test("has color_labels array", function() {
|
||||
expect(res.body.color_labels).to.be.an("array");
|
||||
});
|
||||
|
||||
test("has metadata object", function() {
|
||||
expect(res.body.metadata).to.be.an("object");
|
||||
});
|
||||
|
||||
test("has boolean flags", function() {
|
||||
expect(res.body.is_raw).to.be.a("boolean");
|
||||
expect(res.body.is_hdr).to.be.a("boolean");
|
||||
expect(res.body.is_ldr).to.be.a("boolean");
|
||||
});
|
||||
}
|
||||
|
||||
docs {
|
||||
## Get Image
|
||||
|
||||
Returns full detail for a single image including tags, color labels, and metadata.
|
||||
|
||||
Set `{{imageId}}` in the environment (or pick up from "List Images" run).
|
||||
}
|
||||
67
contrib/darktable-api/bruno/images/Get Preview.bru
Normal file
67
contrib/darktable-api/bruno/images/Get Preview.bru
Normal file
@@ -0,0 +1,67 @@
|
||||
meta {
|
||||
name: Get Preview
|
||||
type: http
|
||||
seq: 4
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/api/v1/images/{{imageId}}/preview
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{accessToken}}
|
||||
}
|
||||
|
||||
params:query {
|
||||
size: small
|
||||
~size: medium
|
||||
~size: large
|
||||
~size: thumb
|
||||
~size: xlarge
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 200 or 404", function() {
|
||||
expect([200, 404]).to.include(res.status);
|
||||
});
|
||||
|
||||
test("if 200, content-type is image/jpeg or image/png", function() {
|
||||
if (res.status === 200) {
|
||||
expect(res.headers["content-type"]).to.match(/image\/(jpeg|png)/);
|
||||
}
|
||||
});
|
||||
|
||||
test("if 200, body is non-empty", function() {
|
||||
if (res.status === 200) {
|
||||
expect(res.body.length).to.be.above(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("if 404, no_preview error returned", function() {
|
||||
if (res.status === 404) {
|
||||
expect(res.body.error).to.equal("no_preview");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
docs {
|
||||
## Get Preview
|
||||
|
||||
Returns a JPEG thumbnail from darktable's mipmap cache.
|
||||
|
||||
### Size options
|
||||
| Value | Approx. long edge |
|
||||
|---|---|
|
||||
| `thumb` | ~135 px |
|
||||
| `small` | ~270 px |
|
||||
| `medium` | ~540 px (default) |
|
||||
| `large` | ~1080 px |
|
||||
| `xlarge` | ~2160 px |
|
||||
| `0`–`5` | Raw mip level |
|
||||
|
||||
Only one `size` param is active at a time — uncomment the desired one above.
|
||||
Falls back to lower mip levels automatically if the requested size isn't cached.
|
||||
Returns 404 if no cached thumbnail exists at any level.
|
||||
}
|
||||
27
contrib/darktable-api/bruno/images/List Images (No Auth).bru
Normal file
27
contrib/darktable-api/bruno/images/List Images (No Auth).bru
Normal file
@@ -0,0 +1,27 @@
|
||||
meta {
|
||||
name: List Images (No Auth)
|
||||
type: http
|
||||
seq: 6
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/api/v1/images
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 401 without token", function() {
|
||||
expect(res.status).to.equal(401);
|
||||
});
|
||||
|
||||
test("error is unauthorized", function() {
|
||||
expect(res.body.error).to.equal("unauthorized");
|
||||
});
|
||||
}
|
||||
|
||||
docs {
|
||||
## List Images — No Auth
|
||||
|
||||
Verifies that protected endpoints return 401 when no Bearer token is supplied.
|
||||
}
|
||||
87
contrib/darktable-api/bruno/images/List Images.bru
Normal file
87
contrib/darktable-api/bruno/images/List Images.bru
Normal file
@@ -0,0 +1,87 @@
|
||||
meta {
|
||||
name: List Images
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/api/v1/images
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{accessToken}}
|
||||
}
|
||||
|
||||
params:query {
|
||||
limit: 10
|
||||
offset: 0
|
||||
sort_by: datetime_taken
|
||||
sort_dir: desc
|
||||
~rating_min: 1
|
||||
~rating_max: 5
|
||||
~rating: 3
|
||||
~film_id: 1
|
||||
~from: 2018-01-01
|
||||
~to: 2018-12-31
|
||||
~color_label: red
|
||||
~is_raw: true
|
||||
~is_hdr: false
|
||||
~search: 500mm
|
||||
~tag: realestate
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 200", function() {
|
||||
expect(res.status).to.equal(200);
|
||||
});
|
||||
|
||||
test("has items array", function() {
|
||||
expect(res.body.items).to.be.an("array");
|
||||
});
|
||||
|
||||
test("has total count", function() {
|
||||
expect(res.body.total).to.be.a("number");
|
||||
expect(res.body.total).to.be.above(0);
|
||||
});
|
||||
|
||||
test("has limit and offset", function() {
|
||||
expect(res.body.limit).to.equal(10);
|
||||
expect(res.body.offset).to.equal(0);
|
||||
});
|
||||
|
||||
test("each image has required fields", function() {
|
||||
res.body.items.forEach(function(img) {
|
||||
expect(img.id).to.be.a("number");
|
||||
expect(img.filename).to.be.a("string");
|
||||
expect(img.path).to.be.a("string");
|
||||
expect(img.rating).to.be.a("number");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
docs {
|
||||
## List Images
|
||||
|
||||
Returns a paginated list of images from the darktable catalog.
|
||||
|
||||
### Active query params (remove `~` prefix to enable)
|
||||
| Param | Type | Description |
|
||||
|---|---|---|
|
||||
| `limit` | int | Results per page (max 500, default 50) |
|
||||
| `offset` | int | Pagination offset |
|
||||
| `sort_by` | string | `id`, `filename`, `datetime_taken`, `rating`, `focal_length`, `iso` |
|
||||
| `sort_dir` | string | `asc` or `desc` |
|
||||
| `rating_min` | int | Minimum star rating (−1=rejected, 0=unrated, 1–5=stars) |
|
||||
| `rating_max` | int | Maximum star rating |
|
||||
| `rating` | int | Exact rating |
|
||||
| `film_id` | int | Filter by film roll ID |
|
||||
| `from` | date | Earliest capture date (YYYY-MM-DD) |
|
||||
| `to` | date | Latest capture date (YYYY-MM-DD) |
|
||||
| `color_label` | string | `red`, `yellow`, `green`, `blue`, `purple` |
|
||||
| `is_raw` | bool | `true` / `false` |
|
||||
| `is_hdr` | bool | `true` / `false` |
|
||||
| `search` | string | Free-text search in filename, maker, model, lens |
|
||||
| `tag` | string | Filter by exact tag name |
|
||||
}
|
||||
49
contrib/darktable-api/bruno/images/Refresh Cache.bru
Normal file
49
contrib/darktable-api/bruno/images/Refresh Cache.bru
Normal file
@@ -0,0 +1,49 @@
|
||||
meta {
|
||||
name: Refresh Cache
|
||||
type: http
|
||||
seq: 5
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{baseUrl}}/api/v1/images/{{imageId}}/refresh
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{accessToken}}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 202 Accepted", function() {
|
||||
expect(res.status).to.equal(202);
|
||||
});
|
||||
|
||||
test("status is accepted", function() {
|
||||
expect(res.body.status).to.equal("accepted");
|
||||
});
|
||||
|
||||
test("image_id matches request", function() {
|
||||
expect(res.body.image_id).to.equal(Number(bru.getVar("imageId")));
|
||||
});
|
||||
|
||||
test("has filename", function() {
|
||||
expect(res.body.filename).to.be.a("string");
|
||||
});
|
||||
|
||||
test("has queue path", function() {
|
||||
expect(res.body.queue).to.be.a("string");
|
||||
});
|
||||
}
|
||||
|
||||
docs {
|
||||
## Refresh Cache
|
||||
|
||||
Enqueues the image for thumbnail regeneration inside the running darktable instance.
|
||||
|
||||
Returns **202 Accepted** immediately. The darktable bridge plugin
|
||||
(`darktable-api-bridge.lua`) polls `/tmp/dt-api-refresh.queue` every second
|
||||
and calls `image:drop_cache()` + `image:generate_cache()` when it finds this ID.
|
||||
|
||||
Requires scope: `cache:refresh`
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
meta {
|
||||
name: Get Module (Not Found)
|
||||
type: http
|
||||
seq: 4
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/api/v1/images/{{imageId}}/modules/nonexistent_module
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{accessToken}}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 404", function() {
|
||||
expect(res.status).to.equal(404);
|
||||
});
|
||||
|
||||
test("error is module_not_found", function() {
|
||||
expect(res.body.error).to.equal("module_not_found");
|
||||
});
|
||||
}
|
||||
|
||||
docs {
|
||||
## Get Module — Not Found
|
||||
|
||||
Verifies 404 is returned for an operation that does not exist in the image history.
|
||||
}
|
||||
55
contrib/darktable-api/bruno/modules/Get Module.bru
Normal file
55
contrib/darktable-api/bruno/modules/Get Module.bru
Normal file
@@ -0,0 +1,55 @@
|
||||
meta {
|
||||
name: Get Module
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/api/v1/images/{{imageId}}/modules/filmicrgb
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{accessToken}}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 200", function() {
|
||||
expect(res.status).to.equal(200);
|
||||
});
|
||||
|
||||
test("operation is filmicrgb", function() {
|
||||
expect(res.body.operation).to.equal("filmicrgb");
|
||||
});
|
||||
|
||||
test("has enabled flag", function() {
|
||||
expect(res.body.enabled).to.be.a("boolean");
|
||||
});
|
||||
|
||||
test("has decoded params", function() {
|
||||
expect(res.body.params).to.be.an("object");
|
||||
expect(res.body.params.grey_point_source).to.be.a("number");
|
||||
expect(res.body.params.black_point_source).to.be.a("number");
|
||||
expect(res.body.params.white_point_source).to.be.a("number");
|
||||
expect(res.body.params.contrast).to.be.a("number");
|
||||
expect(res.body.params.saturation).to.be.a("number");
|
||||
});
|
||||
|
||||
// Save for the Patch Module test
|
||||
bru.setVar("filmicGreyPointSource", res.body.params.grey_point_source);
|
||||
}
|
||||
|
||||
docs {
|
||||
## Get Module
|
||||
|
||||
Returns the active history entry for the `filmicrgb` module.
|
||||
|
||||
Change the URL path segment to inspect a different module, e.g.:
|
||||
- `.../modules/exposure`
|
||||
- `.../modules/temperature`
|
||||
- `.../modules/highlights`
|
||||
- `.../modules/channelmixerrgb`
|
||||
|
||||
Returns 404 if the operation has never been added to this image's history.
|
||||
}
|
||||
79
contrib/darktable-api/bruno/modules/List Modules.bru
Normal file
79
contrib/darktable-api/bruno/modules/List Modules.bru
Normal file
@@ -0,0 +1,79 @@
|
||||
meta {
|
||||
name: List Modules
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/api/v1/images/{{imageId}}/modules
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{accessToken}}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 200", function() {
|
||||
expect(res.status).to.equal(200);
|
||||
});
|
||||
|
||||
test("has image_id", function() {
|
||||
expect(res.body.image_id).to.equal(Number(bru.getVar("imageId")));
|
||||
});
|
||||
|
||||
test("has items array", function() {
|
||||
expect(res.body.items).to.be.an("array");
|
||||
expect(res.body.items.length).to.be.above(0);
|
||||
});
|
||||
|
||||
test("each item has required fields", function() {
|
||||
res.body.items.forEach(function(m) {
|
||||
expect(m.operation).to.be.a("string");
|
||||
expect(m.enabled).to.be.a("boolean");
|
||||
expect(m.module_version).to.be.a("number");
|
||||
expect(m.history_num).to.be.a("number");
|
||||
});
|
||||
});
|
||||
|
||||
test("known modules have decoded params", function() {
|
||||
var known = res.body.items.filter(function(m) {
|
||||
return ["exposure","temperature","filmicrgb","highlights",
|
||||
"channelmixerrgb","flip","gamma","rawprepare","demosaic"].indexOf(m.operation) >= 0;
|
||||
});
|
||||
known.forEach(function(m) {
|
||||
expect(m.params).to.not.be.null;
|
||||
expect(typeof m.params).to.equal("object");
|
||||
});
|
||||
});
|
||||
|
||||
test("unknown modules have params_b64", function() {
|
||||
var unknown = res.body.items.filter(function(m) { return m.params === undefined || m.params === null; });
|
||||
unknown.forEach(function(m) {
|
||||
expect(m.params_b64).to.be.a("string");
|
||||
});
|
||||
});
|
||||
|
||||
// Save the first module op for subsequent tests
|
||||
bru.setVar("moduleOp", res.body.items[0].operation);
|
||||
}
|
||||
|
||||
docs {
|
||||
## List Modules
|
||||
|
||||
Returns all active development modules (history stack entries) for the image.
|
||||
|
||||
Each item includes:
|
||||
- `operation` – module name (e.g. `"exposure"`, `"filmicrgb"`)
|
||||
- `enabled` – whether the module is currently active in the pipeline
|
||||
- `module_version` – params struct version stored in the database
|
||||
- `multi_name` – user label for multi-instance modules (empty for single-instance)
|
||||
- `history_num` – position in the history stack (higher = more recent)
|
||||
- `params` – decoded named parameters (for supported modules)
|
||||
- `params_b64` – base64-encoded raw binary blob (for unsupported modules)
|
||||
|
||||
### Supported modules (params decoded)
|
||||
`exposure`, `temperature`, `filmicrgb`, `channelmixerrgb`, `highlights`,
|
||||
`flip`, `gamma`, `rawprepare`, `demosaic`
|
||||
}
|
||||
89
contrib/darktable-api/bruno/modules/Patch Module.bru
Normal file
89
contrib/darktable-api/bruno/modules/Patch Module.bru
Normal file
@@ -0,0 +1,89 @@
|
||||
meta {
|
||||
name: Patch Module
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
patch {
|
||||
url: {{baseUrl}}/api/v1/images/{{imageId}}/modules/filmicrgb
|
||||
body: json
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{accessToken}}
|
||||
}
|
||||
|
||||
params:query {
|
||||
~preview: medium
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"enabled": true,
|
||||
"params": {
|
||||
"grey_point_source": 18.45,
|
||||
"white_point_source": 4.0,
|
||||
"black_point_source": -8.0,
|
||||
"contrast": 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 204 (no preview) or 200 (with preview)", function() {
|
||||
expect([200, 204]).to.include(res.status);
|
||||
});
|
||||
|
||||
test("if 200, content-type is image/jpeg", function() {
|
||||
if (res.status === 200) {
|
||||
expect(res.headers["content-type"]).to.equal("image/jpeg");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
docs {
|
||||
## Patch Module
|
||||
|
||||
Update one or more parameters on a development module and optionally get a
|
||||
preview image back showing the result.
|
||||
|
||||
### Body fields
|
||||
- `enabled` (boolean, optional) – enable or disable the module
|
||||
- `params` (object, optional) – named parameter overrides for known modules
|
||||
- `params_b64` (string, optional) – raw base64 binary blob for unknown modules
|
||||
(mutually exclusive with `params`)
|
||||
|
||||
Only fields present in `params` are changed; all others keep their current values.
|
||||
|
||||
### Preview query param
|
||||
Add `?preview=small|medium|large|xlarge` to receive a JPEG thumbnail (200)
|
||||
instead of empty 204. The server waits up to 30 s for the thumbnail to
|
||||
regenerate before returning. Without this param the endpoint returns
|
||||
**204 No Content** immediately (the bridge regenerates async in ~1-3 s).
|
||||
|
||||
### filmicrgb key parameters
|
||||
| Field | Description | Default |
|
||||
|---|---|---|
|
||||
| `grey_point_source` | middle-grey exposure % | 18.45 |
|
||||
| `black_point_source` | black relative exposure EV | -8.0 |
|
||||
| `white_point_source` | white relative exposure EV | 4.0 |
|
||||
| `contrast` | S-curve contrast | 1.0 |
|
||||
| `saturation` | extreme-luminance saturation | 0.0 |
|
||||
| `balance` | shadows ↔ highlights balance | 0.0 |
|
||||
| `auto_hardness` | auto-adjust curve hardness | true |
|
||||
|
||||
### exposure key parameters
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `exposure` | exposure compensation in EV |
|
||||
| `black` | black level correction |
|
||||
| `mode` | 0=manual, 1=deflicker |
|
||||
|
||||
### temperature key parameters
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `red` | red channel multiplier |
|
||||
| `green` | green channel multiplier |
|
||||
| `blue` | blue channel multiplier |
|
||||
}
|
||||
54
contrib/darktable-api/bruno/tags/List Tags.bru
Normal file
54
contrib/darktable-api/bruno/tags/List Tags.bru
Normal file
@@ -0,0 +1,54 @@
|
||||
meta {
|
||||
name: List Tags
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{baseUrl}}/api/v1/tags
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{accessToken}}
|
||||
}
|
||||
|
||||
params:query {
|
||||
limit: 50
|
||||
offset: 0
|
||||
~search: darktable
|
||||
}
|
||||
|
||||
tests {
|
||||
test("returns 200", function() {
|
||||
expect(res.status).to.equal(200);
|
||||
});
|
||||
|
||||
test("has items array", function() {
|
||||
expect(res.body.items).to.be.an("array");
|
||||
});
|
||||
|
||||
test("each tag has id, name, image_count", function() {
|
||||
res.body.items.forEach(function(tag) {
|
||||
expect(tag.id).to.be.a("number");
|
||||
expect(tag.name).to.be.a("string");
|
||||
expect(tag.image_count).to.be.a("number");
|
||||
});
|
||||
});
|
||||
|
||||
test("tags are sorted alphabetically", function() {
|
||||
var names = res.body.items.map(function(t) { return t.name; });
|
||||
var sorted = names.slice().sort();
|
||||
expect(names).to.deep.equal(sorted);
|
||||
});
|
||||
}
|
||||
|
||||
docs {
|
||||
## List Tags
|
||||
|
||||
Returns all tags in the catalog with their image counts, sorted alphabetically.
|
||||
|
||||
Use `?search=keyword` to filter by tag name prefix/substring.
|
||||
Use `?limit` and `?offset` for pagination (max 2000 per page).
|
||||
}
|
||||
38
contrib/darktable-api/config.lua
Normal file
38
contrib/darktable-api/config.lua
Normal file
@@ -0,0 +1,38 @@
|
||||
local M = {}
|
||||
|
||||
local home = os.getenv("HOME") or "/root"
|
||||
|
||||
M.host = os.getenv("DT_API_HOST") or "127.0.0.1"
|
||||
M.port = tonumber(os.getenv("DT_API_PORT")) or 7645
|
||||
|
||||
-- Darktable paths
|
||||
M.library_db = os.getenv("DT_LIBRARY") or (home .. "/.config/darktable/library.db")
|
||||
M.data_db = os.getenv("DT_DATA_DB") or (home .. "/.config/darktable/data.db")
|
||||
M.cache_dir = os.getenv("DT_CACHE_DIR") or (home .. "/.cache/darktable")
|
||||
|
||||
-- JWT / OAuth
|
||||
M.jwt_secret = os.getenv("DT_JWT_SECRET") or "change-this-secret-in-production"
|
||||
M.token_ttl = tonumber(os.getenv("DT_TOKEN_TTL")) or 3600
|
||||
|
||||
-- OAuth 2.0 clients table: client_id -> { secret, scopes[] }
|
||||
-- Additional clients can be registered by adding entries here or setting
|
||||
-- DT_CLIENT_ID / DT_CLIENT_SECRET environment variables.
|
||||
M.clients = {}
|
||||
local default_id = os.getenv("DT_CLIENT_ID") or "darktable-client"
|
||||
local default_secret = os.getenv("DT_CLIENT_SECRET") or "changeme"
|
||||
M.clients[default_id] = {
|
||||
secret = default_secret,
|
||||
scopes = { "images:read", "previews:read", "tags:read", "films:read", "cache:refresh" },
|
||||
}
|
||||
|
||||
-- Queue file the bridge plugin watches for cache-refresh requests.
|
||||
M.refresh_queue = os.getenv("DT_REFRESH_QUEUE") or "/tmp/dt-api-refresh.queue"
|
||||
|
||||
-- Queue file the bridge plugin watches for full-resolution export requests.
|
||||
M.export_queue = os.getenv("DT_EXPORT_QUEUE") or "/tmp/dt-api-export.queue"
|
||||
|
||||
-- Directory where the bridge writes full-resolution JPEG exports.
|
||||
-- Served by GET /api/v1/images/:id/full.
|
||||
M.export_dir = os.getenv("DT_EXPORT_DIR") or "/tmp/dt-api-exports"
|
||||
|
||||
return M
|
||||
302
contrib/darktable-api/darktable-api-bridge.lua
Normal file
302
contrib/darktable-api/darktable-api-bridge.lua
Normal file
@@ -0,0 +1,302 @@
|
||||
--[[
|
||||
darktable-api-bridge.lua
|
||||
Companion plugin for the darktable REST API server.
|
||||
|
||||
Runs inside a live darktable instance and watches two queue files written by
|
||||
the standalone LuaJIT server.
|
||||
|
||||
Refresh queue (DT_REFRESH_QUEUE, default /tmp/dt-api-refresh.queue)
|
||||
One image ID per line. The bridge calls image:drop_cache() +
|
||||
image:generate_cache(true, …) so that darktable re-renders thumbnails
|
||||
through its full processing pipeline, reflecting tone curves, exposure,
|
||||
colour grading, and all other development settings.
|
||||
|
||||
Export queue (DT_EXPORT_QUEUE, default /tmp/dt-api-export.queue)
|
||||
One image ID per line. The bridge exports a full-resolution JPEG via
|
||||
darktable's rendering pipeline to DT_EXPORT_DIR/<id>.jpg.
|
||||
|
||||
Install: add the following line to ~/.config/darktable/luarc
|
||||
require "contrib/darktable-api/darktable-api-bridge"
|
||||
|
||||
Or let the startup script (start-with-api.sh) do it automatically.
|
||||
--]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
|
||||
du.check_min_api_version("7.0.0", "darktable-api-bridge")
|
||||
|
||||
local MODULE = "darktable-api-bridge"
|
||||
|
||||
-- ── Configuration ─────────────────────────────────────────────────────────────
|
||||
|
||||
-- Must match config.refresh_queue / export_queue / export_dir in config.lua.
|
||||
local QUEUE_FILE = os.getenv("DT_REFRESH_QUEUE") or "/tmp/dt-api-refresh.queue"
|
||||
local EXPORT_QUEUE = os.getenv("DT_EXPORT_QUEUE") or "/tmp/dt-api-export.queue"
|
||||
local EXPORT_DIR = os.getenv("DT_EXPORT_DIR") or "/tmp/dt-api-exports"
|
||||
local POLL_MS = 1000 -- poll interval in milliseconds
|
||||
-- Mipmap range to regenerate: 0 = smallest thumbnail, 4 ≈ 1080 px.
|
||||
local MIP_MIN = 0
|
||||
local MIP_MAX = 4
|
||||
-- JPEG quality used for full-resolution exports.
|
||||
local EXPORT_QUALITY = tonumber(os.getenv("DT_EXPORT_QUALITY")) or 95
|
||||
|
||||
-- ── Logging ───────────────────────────────────────────────────────────────────
|
||||
|
||||
local function log(msg)
|
||||
dt.print_log("[" .. MODULE .. "] " .. msg)
|
||||
end
|
||||
|
||||
local function notify(msg)
|
||||
dt.print("[" .. MODULE .. "] " .. msg)
|
||||
log(msg)
|
||||
end
|
||||
|
||||
-- ── Image lookup ──────────────────────────────────────────────────────────────
|
||||
|
||||
-- Build a fast id→image map from dt.database.
|
||||
-- dt.database is a table-like object; iterating it is O(n) but fast enough
|
||||
-- for typical library sizes (thousands of images).
|
||||
local function build_id_map()
|
||||
local map = {}
|
||||
for _, img in ipairs(dt.database) do
|
||||
map[img.id] = img
|
||||
end
|
||||
return map
|
||||
end
|
||||
|
||||
-- ── Queue file handling ───────────────────────────────────────────────────────
|
||||
|
||||
-- Atomically claim the queue file by renaming it, then read all IDs from it.
|
||||
-- Returns a set table { [id] = true } of unique image IDs, or {} if none.
|
||||
local function drain_queue()
|
||||
local processing = QUEUE_FILE .. ".processing"
|
||||
|
||||
-- Rename is atomic on POSIX filesystems: the server's append-only writes
|
||||
-- will start a fresh QUEUE_FILE after we claim this one.
|
||||
local ok = os.rename(QUEUE_FILE, processing)
|
||||
if not ok then
|
||||
return {} -- queue file doesn't exist yet
|
||||
end
|
||||
|
||||
local ids = {}
|
||||
local f = io.open(processing, "r")
|
||||
if f then
|
||||
for line in f:lines() do
|
||||
local id = tonumber(line)
|
||||
if id then ids[id] = true end
|
||||
end
|
||||
f:close()
|
||||
os.remove(processing)
|
||||
end
|
||||
return ids
|
||||
end
|
||||
|
||||
-- ── Mipmap directory ─────────────────────────────────────────────────────────
|
||||
|
||||
-- Darktable's mipmap_cache->cachedir is not directly accessible from Lua, so
|
||||
-- we replicate the naming: SHA1 hex of realpath(library.db) + ".d".
|
||||
-- We compute it once and cache it here.
|
||||
local _mipmap_cachedir
|
||||
|
||||
|
||||
local function mipmap_cachedir()
|
||||
if _mipmap_cachedir then return _mipmap_cachedir end
|
||||
local cache_dir = os.getenv("DT_CACHE_DIR")
|
||||
if not cache_dir then
|
||||
local home = os.getenv("HOME") or "~"
|
||||
cache_dir = home .. "/.cache/darktable"
|
||||
end
|
||||
-- There is exactly one mipmaps-<sha1>.d directory per library.
|
||||
-- Find it by globbing rather than recomputing the SHA1.
|
||||
local h = io.popen(string.format("ls -d %q/mipmaps-*.d 2>/dev/null | head -1", cache_dir))
|
||||
local found = h and h:read("*l"); if h then h:close() end
|
||||
if found and #found > 0 then
|
||||
-- Strip trailing ".d" to get the base prefix used in file paths.
|
||||
_mipmap_cachedir = found:match("^(.+)%.d$") or found
|
||||
log("mipmap cachedir: " .. _mipmap_cachedir .. ".d")
|
||||
return _mipmap_cachedir
|
||||
end
|
||||
log("WARNING: could not find mipmap cache directory under " .. cache_dir)
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Delete all mipmap disk files for an image so generate_cache regenerates them.
|
||||
local function delete_mipmap_files(imgid)
|
||||
local base = mipmap_cachedir()
|
||||
if not base then return end
|
||||
for level = 0, 8 do
|
||||
local path = string.format("%s.d/%d/%d.jpg", base, level, imgid)
|
||||
os.remove(path)
|
||||
end
|
||||
end
|
||||
|
||||
-- ── Cache regeneration ────────────────────────────────────────────────────────
|
||||
|
||||
local function refresh_image(img)
|
||||
local label = string.format("id=%d %s/%s", img.id, img.path, img.filename)
|
||||
log("refreshing " .. label)
|
||||
|
||||
-- Remove disk mipmap files first so generate_cache doesn't skip any level.
|
||||
-- drop_cache() only clears the in-memory entry; existing disk files would
|
||||
-- cause generate_cache to skip those levels (it guards with dt_util_test_image_file).
|
||||
delete_mipmap_files(img.id)
|
||||
img:drop_cache()
|
||||
|
||||
-- create_dirs=true ensures the per-level directories exist.
|
||||
img:generate_cache(true, MIP_MIN, MIP_MAX)
|
||||
|
||||
log("done " .. label)
|
||||
end
|
||||
|
||||
local function export_image(img, dest_path)
|
||||
local label = string.format("id=%d → %s", img.id, dest_path)
|
||||
log("exporting " .. label)
|
||||
|
||||
local fmt = dt.new_format("jpeg")
|
||||
fmt.quality = EXPORT_QUALITY
|
||||
-- max_width/max_height = 0 means no scaling: full resolution.
|
||||
fmt.max_width = 0
|
||||
fmt.max_height = 0
|
||||
|
||||
local export_err = fmt:write_image(img, dest_path)
|
||||
if export_err then
|
||||
error("export failed: " .. tostring(export_err))
|
||||
end
|
||||
|
||||
log("export done " .. label)
|
||||
end
|
||||
|
||||
-- Atomically claim the export queue and return a set of unique image IDs.
|
||||
local function drain_export_queue()
|
||||
local processing = EXPORT_QUEUE .. ".processing"
|
||||
local ok = os.rename(EXPORT_QUEUE, processing)
|
||||
if not ok then return {} end
|
||||
|
||||
local ids = {}
|
||||
local f = io.open(processing, "r")
|
||||
if f then
|
||||
for line in f:lines() do
|
||||
local id = tonumber(line)
|
||||
if id then ids[id] = true end
|
||||
end
|
||||
f:close()
|
||||
os.remove(processing)
|
||||
end
|
||||
return ids
|
||||
end
|
||||
|
||||
-- Process all pending refresh IDs in one pass.
|
||||
local function process_queue()
|
||||
local ids = drain_queue()
|
||||
local count = 0
|
||||
for id, _ in pairs(ids) do count = count + 1 end
|
||||
if count == 0 then return end
|
||||
|
||||
log(string.format("processing %d queued refresh request(s)", count))
|
||||
local id_map = build_id_map()
|
||||
|
||||
for id, _ in pairs(ids) do
|
||||
local img = id_map[id]
|
||||
if img then
|
||||
local ok, err = pcall(refresh_image, img)
|
||||
if not ok then
|
||||
log("error refreshing id=" .. id .. ": " .. tostring(err))
|
||||
end
|
||||
else
|
||||
log("image id=" .. id .. " not found in database (may have been removed)")
|
||||
end
|
||||
end
|
||||
|
||||
-- Nudge the lighttable to redraw so updated thumbnails appear immediately.
|
||||
dt.gui.views.lighttable.is_culling_mode = dt.gui.views.lighttable.is_culling_mode
|
||||
end
|
||||
|
||||
-- Process all pending export IDs in one pass.
|
||||
local function process_export_queue()
|
||||
local ids = drain_export_queue()
|
||||
local count = 0
|
||||
for id, _ in pairs(ids) do count = count + 1 end
|
||||
if count == 0 then return end
|
||||
|
||||
-- Ensure export directory exists.
|
||||
os.execute('mkdir -p "' .. EXPORT_DIR .. '"')
|
||||
|
||||
log(string.format("processing %d queued export request(s)", count))
|
||||
local id_map = build_id_map()
|
||||
|
||||
for id, _ in pairs(ids) do
|
||||
local img = id_map[id]
|
||||
if img then
|
||||
local dest = EXPORT_DIR .. "/" .. tostring(id) .. ".jpg"
|
||||
local ok, err = pcall(export_image, img, dest)
|
||||
if not ok then
|
||||
log("error exporting id=" .. id .. ": " .. tostring(err))
|
||||
end
|
||||
else
|
||||
log("export: image id=" .. id .. " not found in database")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ── Background polling loop ───────────────────────────────────────────────────
|
||||
|
||||
-- dt.control.dispatch runs the function asynchronously in darktable's worker
|
||||
-- thread pool, leaving the UI thread free. We loop inside with sleep() so
|
||||
-- one dispatch call drives the whole bridge.
|
||||
local running = true
|
||||
|
||||
local function poll_loop()
|
||||
notify(string.format(
|
||||
"started; polling queues every %d ms\n refresh: %s\n export: %s → %s",
|
||||
POLL_MS, QUEUE_FILE, EXPORT_QUEUE, EXPORT_DIR))
|
||||
|
||||
while running do
|
||||
local ok, err = pcall(process_queue)
|
||||
if not ok then log("process_queue error: " .. tostring(err)) end
|
||||
|
||||
local ok2, err2 = pcall(process_export_queue)
|
||||
if not ok2 then log("process_export_queue error: " .. tostring(err2)) end
|
||||
|
||||
dt.control.sleep(POLL_MS)
|
||||
end
|
||||
|
||||
notify("stopped")
|
||||
end
|
||||
|
||||
-- ── Plugin lifecycle ──────────────────────────────────────────────────────────
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = "darktable API bridge",
|
||||
purpose = "Watch the REST API refresh queue and regenerate image thumbnails",
|
||||
author = "darktable-api",
|
||||
help = "https://github.com/darktable-org/lua-scripts",
|
||||
}
|
||||
|
||||
local function destroy()
|
||||
running = false
|
||||
-- Give the poll loop one final chance to exit cleanly.
|
||||
dt.control.sleep(POLL_MS + 200)
|
||||
dt.destroy_event(MODULE, "shortcut")
|
||||
end
|
||||
script_data.destroy = destroy
|
||||
|
||||
-- Register a manual-trigger shortcut so the user can force an immediate poll
|
||||
-- without waiting for the next timer tick.
|
||||
dt.register_event(MODULE, "shortcut",
|
||||
function(event, shortcut)
|
||||
log("manual poll triggered via shortcut")
|
||||
local ok, err = pcall(process_queue)
|
||||
if not ok then notify("error during manual poll: " .. tostring(err)) end
|
||||
local ok2, err2 = pcall(process_export_queue)
|
||||
if not ok2 then notify("export error during manual poll: " .. tostring(err2)) end
|
||||
end,
|
||||
"poll refresh queue now"
|
||||
)
|
||||
|
||||
-- Start the background loop.
|
||||
dt.control.dispatch(poll_loop)
|
||||
|
||||
return script_data
|
||||
525
contrib/darktable-api/db.lua
Normal file
525
contrib/darktable-api/db.lua
Normal file
@@ -0,0 +1,525 @@
|
||||
-- Darktable SQLite catalog access (read-only for image queries; read-write for
|
||||
-- module-parameter edits via the history table).
|
||||
-- Schema inspected from an actual darktable library (recent version):
|
||||
--
|
||||
-- images(id, group_id, film_id, filename, width, height, aspect_ratio,
|
||||
-- maker_id, model_id, lens_id, camera_id,
|
||||
-- exposure, aperture, iso, focal_length, focus_distance,
|
||||
-- datetime_taken INTEGER (Unix epoch),
|
||||
-- flags, orientation, longitude, latitude, altitude,
|
||||
-- version, max_version, write_timestamp, change_timestamp, ...)
|
||||
-- makers(id, name) models(id, name) lens(id, name)
|
||||
-- film_rolls(id, access_timestamp, folder)
|
||||
-- tagged_images(imgid, tagid, position)
|
||||
-- color_labels(imgid, color) -- color: 0=red 1=yellow 2=green 3=blue 4=purple
|
||||
-- data.tags(id, name, ...) -- in data.db
|
||||
-- meta_data(id, key, value) -- key references data.meta_data.key
|
||||
--
|
||||
-- images.flags & 0x07 → rating (0=unrated, 1-5=stars, 6=rejected)
|
||||
-- images.flags & 64 → DT_IMAGE_RAW
|
||||
-- images.flags & 128 → DT_IMAGE_HDR
|
||||
-- images.flags & 32 → DT_IMAGE_LDR
|
||||
local sqlite3 = require "lsqlite3"
|
||||
local bit = require "bit" -- LuaJIT built-in; Lua 5.1 bitwise
|
||||
local config = require "config"
|
||||
|
||||
local band = bit.band
|
||||
local unpack = unpack or table.unpack -- Lua 5.1 vs 5.2+ compatibility
|
||||
|
||||
local M = {}
|
||||
|
||||
-- ── Connections ───────────────────────────────────────────────────────────────
|
||||
|
||||
local _db -- read-only, lazy
|
||||
local _db_rw -- read-write, opened on first write
|
||||
|
||||
local function get_db()
|
||||
if _db then return _db end
|
||||
_db = sqlite3.open(config.library_db, sqlite3.OPEN_READONLY)
|
||||
if not _db then
|
||||
error("Cannot open darktable library: " .. config.library_db)
|
||||
end
|
||||
local data_db = config.data_db:gsub("'", "''")
|
||||
_db:exec(string.format("ATTACH DATABASE '%s' AS data", data_db))
|
||||
return _db
|
||||
end
|
||||
|
||||
local function get_db_rw()
|
||||
if _db_rw then return _db_rw end
|
||||
_db_rw = sqlite3.open(config.library_db) -- read-write
|
||||
if not _db_rw then
|
||||
error("Cannot open darktable library for writing: " .. config.library_db)
|
||||
end
|
||||
-- WAL mode: allow concurrent reads from the darktable process.
|
||||
_db_rw:exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;")
|
||||
return _db_rw
|
||||
end
|
||||
|
||||
-- ── Query helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
local function query(sql, params)
|
||||
local d = get_db()
|
||||
local stmt = d:prepare(sql)
|
||||
if not stmt then
|
||||
error("SQL error: " .. (d:errmsg() or "?") .. "\nSQL: " .. sql)
|
||||
end
|
||||
if params and #params > 0 then
|
||||
stmt:bind_values(unpack(params))
|
||||
end
|
||||
local rows = {}
|
||||
for row in stmt:nrows() do
|
||||
rows[#rows+1] = row
|
||||
end
|
||||
stmt:finalize()
|
||||
return rows
|
||||
end
|
||||
|
||||
local function query_one(sql, params)
|
||||
return query(sql, params)[1]
|
||||
end
|
||||
|
||||
-- ── Schema helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
-- Rating is packed into the low 3 bits of flags: 0=unrated, 1-5=stars, 6=rejected
|
||||
local function decode_rating(flags)
|
||||
local r = band(flags or 0, 0x07)
|
||||
return r == 6 and -1 or r
|
||||
end
|
||||
|
||||
-- datetime_taken is stored as GLib microseconds since 0001-01-01 00:00:00 UTC.
|
||||
-- Unix epoch (1970-01-01) = 62135596800 seconds after 0001-01-01.
|
||||
local GLIB_EPOCH_OFFSET = 62135596800 -- seconds
|
||||
|
||||
local function glib_to_unix(glib_us)
|
||||
if not glib_us or glib_us == 0 then return nil end
|
||||
return math.floor(glib_us / 1000000) - GLIB_EPOCH_OFFSET
|
||||
end
|
||||
|
||||
local function fmt_datetime(glib_us)
|
||||
local unix_ts = glib_to_unix(glib_us)
|
||||
if not unix_ts then return nil end
|
||||
return os.date("!%Y-%m-%dT%H:%M:%SZ", unix_ts)
|
||||
end
|
||||
|
||||
local LABEL_NAMES = { [0]="red", [1]="yellow", [2]="green", [3]="blue", [4]="purple" }
|
||||
|
||||
local function row_to_image(row)
|
||||
if not row then return nil end
|
||||
return {
|
||||
id = row.id,
|
||||
film_id = row.film_id,
|
||||
group_id = row.group_id,
|
||||
filename = row.filename,
|
||||
path = row.folder,
|
||||
width = row.width,
|
||||
height = row.height,
|
||||
aspect_ratio = row.aspect_ratio,
|
||||
maker = row.maker,
|
||||
model = row.model,
|
||||
lens = row.lens,
|
||||
exposure = row.exposure,
|
||||
aperture = row.aperture,
|
||||
iso = row.iso,
|
||||
focal_length = row.focal_length,
|
||||
datetime_taken = fmt_datetime(row.datetime_taken),
|
||||
rating = decode_rating(row.flags),
|
||||
is_raw = band(row.flags or 0, 64) ~= 0,
|
||||
is_hdr = band(row.flags or 0, 128) ~= 0,
|
||||
is_ldr = band(row.flags or 0, 32) ~= 0,
|
||||
latitude = row.latitude,
|
||||
longitude = row.longitude,
|
||||
altitude = row.altitude,
|
||||
version = row.version,
|
||||
write_timestamp = row.write_timestamp,
|
||||
change_timestamp = row.change_timestamp,
|
||||
}
|
||||
end
|
||||
|
||||
-- Standard SELECT columns + JOINs used by both list_images and get_image.
|
||||
local IMAGE_SELECT = [[
|
||||
SELECT i.id, i.group_id, i.film_id, i.filename,
|
||||
i.width, i.height, i.aspect_ratio,
|
||||
mk.name AS maker, mo.name AS model, ln.name AS lens,
|
||||
i.exposure, i.aperture, i.iso, i.focal_length,
|
||||
i.datetime_taken, i.flags, i.version,
|
||||
i.longitude, i.latitude, i.altitude,
|
||||
i.write_timestamp, i.change_timestamp,
|
||||
f.folder
|
||||
FROM images i
|
||||
JOIN film_rolls f ON f.id = i.film_id
|
||||
LEFT JOIN makers mk ON mk.id = i.maker_id
|
||||
LEFT JOIN models mo ON mo.id = i.model_id
|
||||
LEFT JOIN lens ln ON ln.id = i.lens_id
|
||||
]]
|
||||
|
||||
-- ── Filter builder ────────────────────────────────────────────────────────────
|
||||
|
||||
local COLOR_MAP = { red=0, yellow=1, green=2, blue=3, purple=4 }
|
||||
local VALID_SORT = {
|
||||
id=true, filename=true, datetime_taken=true, rating=true,
|
||||
focal_length=true, iso=true,
|
||||
}
|
||||
|
||||
-- Convert a YYYY-MM-DD string to a GLib microsecond timestamp for DB comparison.
|
||||
local function date_to_glib_us(date_str, end_of_day)
|
||||
local y, m, d = date_str:match("(%d%d%d%d)[%-:](%d%d)[%-:](%d%d)")
|
||||
if not y then return nil end
|
||||
local unix_ts = os.time({
|
||||
year=tonumber(y), month=tonumber(m), day=tonumber(d),
|
||||
hour = end_of_day and 23 or 0,
|
||||
min = end_of_day and 59 or 0,
|
||||
sec = end_of_day and 59 or 0,
|
||||
})
|
||||
return (unix_ts + GLIB_EPOCH_OFFSET) * 1000000
|
||||
end
|
||||
|
||||
local function build_filter(filters)
|
||||
local conds = {}
|
||||
local params = {}
|
||||
|
||||
-- Star rating (packed in flags & 0x07; 6 = rejected)
|
||||
if filters.rating_min then
|
||||
local r = tonumber(filters.rating_min) or 0
|
||||
table.insert(conds, "(i.flags & 7) >= ?")
|
||||
table.insert(params, r == -1 and 6 or r)
|
||||
end
|
||||
if filters.rating_max then
|
||||
local r = tonumber(filters.rating_max) or 5
|
||||
table.insert(conds, "(i.flags & 7) <= ?")
|
||||
table.insert(params, r == -1 and 6 or r)
|
||||
end
|
||||
if filters.rating then
|
||||
local r = tonumber(filters.rating)
|
||||
if r then
|
||||
table.insert(conds, "(i.flags & 7) = ?")
|
||||
table.insert(params, r == -1 and 6 or r)
|
||||
end
|
||||
end
|
||||
|
||||
-- Film roll / folder
|
||||
if filters.film_id then
|
||||
table.insert(conds, "i.film_id = ?")
|
||||
table.insert(params, tonumber(filters.film_id))
|
||||
end
|
||||
|
||||
-- Date range (datetime_taken is GLib microseconds since 0001-01-01)
|
||||
if filters.from then
|
||||
local glib_us = date_to_glib_us(filters.from, false)
|
||||
if glib_us then
|
||||
table.insert(conds, "i.datetime_taken >= ?")
|
||||
table.insert(params, glib_us)
|
||||
end
|
||||
end
|
||||
if filters.to then
|
||||
local glib_us = date_to_glib_us(filters.to, true)
|
||||
if glib_us then
|
||||
table.insert(conds, "i.datetime_taken <= ?")
|
||||
table.insert(params, glib_us)
|
||||
end
|
||||
end
|
||||
|
||||
-- Color label
|
||||
if filters.color_label then
|
||||
local cid = COLOR_MAP[filters.color_label:lower()]
|
||||
if cid ~= nil then
|
||||
table.insert(conds, "EXISTS (SELECT 1 FROM color_labels cl WHERE cl.imgid=i.id AND cl.color=?)")
|
||||
table.insert(params, cid)
|
||||
end
|
||||
end
|
||||
|
||||
-- RAW / HDR flags (SQLite supports & bitwise)
|
||||
if filters.is_raw == "true" then table.insert(conds, "(i.flags & 64) != 0") end
|
||||
if filters.is_raw == "false" then table.insert(conds, "(i.flags & 64) = 0") end
|
||||
if filters.is_hdr == "true" then table.insert(conds, "(i.flags & 128) != 0") end
|
||||
|
||||
-- Free-text search across maker/model/lens/filename
|
||||
if filters.search and filters.search ~= "" then
|
||||
table.insert(conds, "(i.filename LIKE ? OR mk.name LIKE ? OR mo.name LIKE ? OR ln.name LIKE ?)")
|
||||
local pat = "%" .. filters.search .. "%"
|
||||
for _ = 1, 4 do table.insert(params, pat) end
|
||||
end
|
||||
|
||||
-- Tag filter (correlated EXISTS; works for both data.tags and main.tags layouts)
|
||||
if filters.tag and filters.tag ~= "" then
|
||||
table.insert(conds, [[EXISTS (
|
||||
SELECT 1 FROM tagged_images ti2, data.tags t2
|
||||
WHERE ti2.imgid = i.id AND t2.id = ti2.tagid AND t2.name = ?)]])
|
||||
table.insert(params, filters.tag)
|
||||
end
|
||||
|
||||
local where = #conds > 0 and ("WHERE " .. table.concat(conds, " AND ")) or ""
|
||||
return where, params
|
||||
end
|
||||
|
||||
-- ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
function M.list_images(filters)
|
||||
filters = filters or {}
|
||||
local where, params = build_filter(filters)
|
||||
|
||||
local limit = math.max(1, math.min(tonumber(filters.limit) or 50, 500))
|
||||
local offset = math.max(0, tonumber(filters.offset) or 0)
|
||||
local sort_by = VALID_SORT[filters.sort_by] and ("i." .. filters.sort_by) or "i.datetime_taken"
|
||||
local sort_dir = (filters.sort_dir == "asc") and "ASC" or "DESC"
|
||||
|
||||
-- Total count (same filter, no pagination)
|
||||
local count_params = {}
|
||||
for _, v in ipairs(params) do count_params[#count_params+1] = v end
|
||||
|
||||
local count_sql = string.format(
|
||||
"SELECT COUNT(*) AS total FROM images i " ..
|
||||
"JOIN film_rolls f ON f.id = i.film_id " ..
|
||||
"LEFT JOIN makers mk ON mk.id = i.maker_id " ..
|
||||
"LEFT JOIN models mo ON mo.id = i.model_id " ..
|
||||
"LEFT JOIN lens ln ON ln.id = i.lens_id %s",
|
||||
where)
|
||||
local count_row = query_one(count_sql, count_params)
|
||||
local total = count_row and count_row.total or 0
|
||||
|
||||
-- Data rows
|
||||
local data_params = {}
|
||||
for _, v in ipairs(params) do data_params[#data_params+1] = v end
|
||||
data_params[#data_params+1] = limit
|
||||
data_params[#data_params+1] = offset
|
||||
|
||||
local data_sql = IMAGE_SELECT ..
|
||||
where ..
|
||||
string.format(" ORDER BY %s %s LIMIT ? OFFSET ?", sort_by, sort_dir)
|
||||
|
||||
local images = {}
|
||||
for _, row in ipairs(query(data_sql, data_params)) do
|
||||
images[#images+1] = row_to_image(row)
|
||||
end
|
||||
|
||||
return { total = total, limit = limit, offset = offset, items = images }
|
||||
end
|
||||
|
||||
function M.get_image(id)
|
||||
local row = query_one(IMAGE_SELECT .. " WHERE i.id = ?", { tonumber(id) })
|
||||
if not row then return nil end
|
||||
|
||||
local img = row_to_image(row)
|
||||
|
||||
-- Tags
|
||||
local tag_rows = query([[
|
||||
SELECT t.name FROM tagged_images ti
|
||||
JOIN data.tags t ON t.id = ti.tagid
|
||||
WHERE ti.imgid = ? ORDER BY t.name
|
||||
]], { img.id })
|
||||
img.tags = {}
|
||||
for _, tr in ipairs(tag_rows) do img.tags[#img.tags+1] = tr.name end
|
||||
|
||||
-- Color labels
|
||||
local cl_rows = query("SELECT color FROM color_labels WHERE imgid=?", { img.id })
|
||||
img.color_labels = {}
|
||||
for _, cl in ipairs(cl_rows) do
|
||||
if LABEL_NAMES[cl.color] then
|
||||
img.color_labels[#img.color_labels+1] = LABEL_NAMES[cl.color]
|
||||
end
|
||||
end
|
||||
|
||||
-- Metadata (caption, description, creator, etc. via data.meta_data key map)
|
||||
local ok_meta, meta_rows = pcall(query, [[
|
||||
SELECT dm.tagname, m.value
|
||||
FROM meta_data m JOIN data.meta_data dm ON dm.key = m.key
|
||||
WHERE m.id = ?
|
||||
]], { img.id })
|
||||
img.metadata = {}
|
||||
if ok_meta then
|
||||
for _, mr in ipairs(meta_rows) do
|
||||
local subkey = mr.tagname:match("%.([^%.]+)$") or mr.tagname
|
||||
img.metadata[subkey] = mr.value
|
||||
end
|
||||
end
|
||||
|
||||
return img
|
||||
end
|
||||
|
||||
-- ── Tags ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
function M.list_tags(filters)
|
||||
filters = filters or {}
|
||||
local conds, params = {}, {}
|
||||
if filters.search and filters.search ~= "" then
|
||||
table.insert(conds, "t.name LIKE ?")
|
||||
table.insert(params, "%" .. filters.search .. "%")
|
||||
end
|
||||
local where = #conds > 0 and ("WHERE " .. table.concat(conds, " AND ")) or ""
|
||||
local limit = math.min(tonumber(filters.limit) or 200, 2000)
|
||||
local offset = tonumber(filters.offset) or 0
|
||||
params[#params+1] = limit
|
||||
params[#params+1] = offset
|
||||
|
||||
local rows = query(string.format([[
|
||||
SELECT t.id, t.name,
|
||||
(SELECT COUNT(*) FROM tagged_images ti WHERE ti.tagid=t.id) AS image_count
|
||||
FROM data.tags t %s ORDER BY t.name LIMIT ? OFFSET ?
|
||||
]], where), params)
|
||||
|
||||
local tags = {}
|
||||
for _, r in ipairs(rows) do
|
||||
tags[#tags+1] = { id=r.id, name=r.name, image_count=r.image_count }
|
||||
end
|
||||
return tags
|
||||
end
|
||||
|
||||
-- ── Film rolls ────────────────────────────────────────────────────────────────
|
||||
|
||||
function M.list_films(filters)
|
||||
filters = filters or {}
|
||||
local conds, params = {}, {}
|
||||
if filters.search and filters.search ~= "" then
|
||||
table.insert(conds, "f.folder LIKE ?")
|
||||
table.insert(params, "%" .. filters.search .. "%")
|
||||
end
|
||||
local where = #conds > 0 and ("WHERE " .. table.concat(conds, " AND ")) or ""
|
||||
local limit = math.min(tonumber(filters.limit) or 200, 2000)
|
||||
local offset = tonumber(filters.offset) or 0
|
||||
params[#params+1] = limit
|
||||
params[#params+1] = offset
|
||||
|
||||
local rows = query(string.format([[
|
||||
SELECT f.id, f.folder, f.access_timestamp,
|
||||
(SELECT COUNT(*) FROM images i WHERE i.film_id=f.id) AS image_count
|
||||
FROM film_rolls f %s ORDER BY f.folder LIMIT ? OFFSET ?
|
||||
]], where), params)
|
||||
|
||||
local films = {}
|
||||
for _, r in ipairs(rows) do
|
||||
films[#films+1] = {
|
||||
id = r.id,
|
||||
folder = r.folder,
|
||||
access_timestamp = r.access_timestamp,
|
||||
image_count = r.image_count,
|
||||
}
|
||||
end
|
||||
return films
|
||||
end
|
||||
|
||||
-- ── Module history ────────────────────────────────────────────────────────────
|
||||
-- The history table stores one or more entries per (imgid, operation).
|
||||
-- Only the entry with the highest `num` is the active one.
|
||||
-- `module` column = the params-struct version number for that row.
|
||||
|
||||
local modules_mod -- lazy-loaded to avoid circular requires
|
||||
|
||||
local function get_modules_mod()
|
||||
if not modules_mod then modules_mod = require "modules" end
|
||||
return modules_mod
|
||||
end
|
||||
|
||||
local function history_row_to_module(row)
|
||||
local mods = get_modules_mod()
|
||||
local blob = row.op_params or ""
|
||||
local ver = row.module or 0
|
||||
local params = mods.decode(row.operation, ver, blob)
|
||||
return {
|
||||
operation = row.operation,
|
||||
enabled = row.enabled ~= 0,
|
||||
module_version = ver,
|
||||
multi_name = row.multi_name or "",
|
||||
multi_priority = row.multi_priority or 0,
|
||||
history_num = row.num,
|
||||
params = params, -- nil for unknown modules
|
||||
params_b64 = params and nil or require("base64").encode(blob),
|
||||
}
|
||||
end
|
||||
|
||||
-- Return the active (highest-num) history entry for every operation on imgid.
|
||||
function M.list_modules(imgid)
|
||||
local rows = query([[
|
||||
SELECT h.operation, h.module, h.enabled, h.op_params,
|
||||
h.multi_name, h.multi_priority, h.num
|
||||
FROM history h
|
||||
INNER JOIN (
|
||||
SELECT operation, MAX(num) AS max_num
|
||||
FROM history WHERE imgid = ?
|
||||
GROUP BY operation
|
||||
) latest ON h.operation = latest.operation AND h.num = latest.max_num
|
||||
WHERE h.imgid = ?
|
||||
ORDER BY h.operation
|
||||
]], { tonumber(imgid), tonumber(imgid) })
|
||||
|
||||
local result = {}
|
||||
for _, row in ipairs(rows) do
|
||||
result[#result+1] = history_row_to_module(row)
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
-- Return the raw op_params binary blob for one operation (latest history entry).
|
||||
function M.get_module_blob(imgid, operation)
|
||||
local row = query_one([[
|
||||
SELECT op_params FROM history
|
||||
WHERE imgid = ? AND operation = ?
|
||||
ORDER BY num DESC LIMIT 1
|
||||
]], { tonumber(imgid), operation })
|
||||
return row and row.op_params or nil
|
||||
end
|
||||
|
||||
-- Return the single active history entry for one operation.
|
||||
function M.get_module(imgid, operation)
|
||||
local row = query_one([[
|
||||
SELECT h.operation, h.module, h.enabled, h.op_params,
|
||||
h.multi_name, h.multi_priority, h.num
|
||||
FROM history h
|
||||
WHERE h.imgid = ? AND h.operation = ?
|
||||
ORDER BY h.num DESC LIMIT 1
|
||||
]], { tonumber(imgid), operation })
|
||||
if not row then return nil end
|
||||
return history_row_to_module(row)
|
||||
end
|
||||
|
||||
-- Update the active history entry for an operation.
|
||||
-- `new_blob` (string|nil) – new binary op_params; nil to leave unchanged
|
||||
-- `new_enabled` (bool|nil) – new enabled flag; nil to leave unchanged
|
||||
-- Returns true on success, or false + error message.
|
||||
function M.set_module_params(imgid, operation, new_enabled, new_blob)
|
||||
-- Find the current latest entry and its num.
|
||||
local row = query_one([[
|
||||
SELECT num, module, op_params, enabled, blendop_params, blendop_version,
|
||||
multi_priority, multi_name, multi_name_hand_edited
|
||||
FROM history
|
||||
WHERE imgid = ? AND operation = ?
|
||||
ORDER BY num DESC LIMIT 1
|
||||
]], { tonumber(imgid), operation })
|
||||
|
||||
if not row then
|
||||
return false, "module '" .. operation .. "' not found in history for image " .. imgid
|
||||
end
|
||||
|
||||
local d = get_db_rw()
|
||||
|
||||
-- Find the global max num for this image so we can insert after it.
|
||||
local max_row = query_one("SELECT MAX(num) AS m FROM history WHERE imgid=?", {tonumber(imgid)})
|
||||
local new_num = (max_row and max_row.m or 0) + 1
|
||||
|
||||
local enabled_val = (new_enabled == nil) and row.enabled or (new_enabled and 1 or 0)
|
||||
local blob_val = new_blob or row.op_params
|
||||
|
||||
-- Insert a new history entry (preserves darktable's undo history).
|
||||
local sql = [[
|
||||
INSERT INTO history
|
||||
(imgid, num, module, operation, op_params, enabled,
|
||||
blendop_params, blendop_version, multi_priority, multi_name, multi_name_hand_edited)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
]]
|
||||
local stmt = d:prepare(sql)
|
||||
if not stmt then
|
||||
return false, "SQL prepare failed: " .. (d:errmsg() or "?")
|
||||
end
|
||||
stmt:bind_values(
|
||||
tonumber(imgid), new_num, row.module, operation, blob_val, enabled_val,
|
||||
row.blendop_params, row.blendop_version,
|
||||
row.multi_priority, row.multi_name, row.multi_name_hand_edited
|
||||
)
|
||||
local rc = stmt:step()
|
||||
stmt:finalize()
|
||||
|
||||
if rc ~= sqlite3.DONE then
|
||||
return false, "INSERT failed (rc=" .. tostring(rc) .. "): " .. (d:errmsg() or "?")
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return M
|
||||
155
contrib/darktable-api/json.lua
Normal file
155
contrib/darktable-api/json.lua
Normal file
@@ -0,0 +1,155 @@
|
||||
-- Minimal JSON encoder/decoder (pure Lua, no dependencies)
|
||||
local M = {}
|
||||
|
||||
-- ── Encoder ──────────────────────────────────────────────────────────────────
|
||||
|
||||
-- Iterate byte-by-byte to avoid null-byte issues in Lua pattern ranges.
|
||||
local function encode_str(s)
|
||||
local buf = {'"'}
|
||||
for i = 1, #s do
|
||||
local b = s:byte(i)
|
||||
if b == 34 then buf[#buf+1] = '\\"'
|
||||
elseif b == 92 then buf[#buf+1] = '\\\\'
|
||||
elseif b == 10 then buf[#buf+1] = '\\n'
|
||||
elseif b == 13 then buf[#buf+1] = '\\r'
|
||||
elseif b == 9 then buf[#buf+1] = '\\t'
|
||||
elseif b < 32 or b == 127 then
|
||||
buf[#buf+1] = string.format('\\u%04x', b)
|
||||
else
|
||||
buf[#buf+1] = s:sub(i, i)
|
||||
end
|
||||
end
|
||||
buf[#buf+1] = '"'
|
||||
return table.concat(buf)
|
||||
end
|
||||
|
||||
local function enc(v, depth)
|
||||
if depth > 64 then error("json.encode: table too deeply nested") end
|
||||
local t = type(v)
|
||||
if t == "nil" then return "null"
|
||||
elseif t == "boolean" then return tostring(v)
|
||||
elseif t == "number" then
|
||||
if v ~= v or math.abs(v) == math.huge then return "null" end
|
||||
return v == math.floor(v) and string.format("%d", v) or string.format("%.10g", v)
|
||||
elseif t == "string" then
|
||||
return encode_str(v)
|
||||
elseif t == "table" then
|
||||
-- Treat as array when it has a consecutive integer key sequence from 1
|
||||
if #v > 0 then
|
||||
local parts = {}
|
||||
for i = 1, #v do parts[i] = enc(v[i], depth + 1) end
|
||||
return "[" .. table.concat(parts, ",") .. "]"
|
||||
end
|
||||
local parts = {}
|
||||
for k, x in pairs(v) do
|
||||
if type(k) == "string" then
|
||||
parts[#parts+1] = encode_str(k) .. ":" .. enc(x, depth + 1)
|
||||
end
|
||||
end
|
||||
return "{" .. table.concat(parts, ",") .. "}"
|
||||
end
|
||||
return "null"
|
||||
end
|
||||
|
||||
function M.encode(v) return enc(v, 0) end
|
||||
|
||||
-- ── Decoder ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function M.decode(src)
|
||||
local pos = 1
|
||||
|
||||
local function skip()
|
||||
while pos <= #src and src:byte(pos) <= 32 do pos = pos + 1 end
|
||||
end
|
||||
|
||||
local parse -- forward declaration
|
||||
|
||||
local function parse_string()
|
||||
pos = pos + 1 -- consume opening "
|
||||
local buf = {}
|
||||
while pos <= #src do
|
||||
local c = src:sub(pos, pos)
|
||||
if c == '"' then pos = pos + 1; return table.concat(buf) end
|
||||
if c == '\\' then
|
||||
pos = pos + 1
|
||||
c = src:sub(pos, pos)
|
||||
if c == '"' then buf[#buf+1] = '"'
|
||||
elseif c == '\\' then buf[#buf+1] = '\\'
|
||||
elseif c == '/' then buf[#buf+1] = '/'
|
||||
elseif c == 'n' then buf[#buf+1] = '\n'
|
||||
elseif c == 'r' then buf[#buf+1] = '\r'
|
||||
elseif c == 't' then buf[#buf+1] = '\t'
|
||||
elseif c == 'b' then buf[#buf+1] = '\b'
|
||||
elseif c == 'f' then buf[#buf+1] = '\f'
|
||||
elseif c == 'u' then
|
||||
local hex = tonumber(src:sub(pos+1, pos+4), 16) or 0
|
||||
-- Only emit ASCII; multi-byte unicode emits a placeholder
|
||||
buf[#buf+1] = hex < 128 and string.char(hex) or '\xEF\xBF\xBD'
|
||||
pos = pos + 4
|
||||
end
|
||||
else
|
||||
buf[#buf+1] = c
|
||||
end
|
||||
pos = pos + 1
|
||||
end
|
||||
error("json.decode: unterminated string near pos " .. pos)
|
||||
end
|
||||
|
||||
local function parse_object()
|
||||
pos = pos + 1 -- consume '{'
|
||||
local obj = {}
|
||||
skip()
|
||||
if src:sub(pos, pos) == '}' then pos = pos + 1; return obj end
|
||||
while true do
|
||||
skip()
|
||||
local key = parse_string()
|
||||
skip()
|
||||
if src:sub(pos, pos) ~= ':' then error("json.decode: expected ':' near pos " .. pos) end
|
||||
pos = pos + 1
|
||||
skip()
|
||||
obj[key] = parse()
|
||||
skip()
|
||||
local ch = src:sub(pos, pos); pos = pos + 1
|
||||
if ch == '}' then return obj end
|
||||
if ch ~= ',' then error("json.decode: expected ',' or '}' near pos " .. pos) end
|
||||
end
|
||||
end
|
||||
|
||||
local function parse_array()
|
||||
pos = pos + 1 -- consume '['
|
||||
local arr = {}
|
||||
skip()
|
||||
if src:sub(pos, pos) == ']' then pos = pos + 1; return arr end
|
||||
while true do
|
||||
skip()
|
||||
arr[#arr+1] = parse()
|
||||
skip()
|
||||
local ch = src:sub(pos, pos); pos = pos + 1
|
||||
if ch == ']' then return arr end
|
||||
if ch ~= ',' then error("json.decode: expected ',' or ']' near pos " .. pos) end
|
||||
end
|
||||
end
|
||||
|
||||
parse = function()
|
||||
skip()
|
||||
local c = src:sub(pos, pos)
|
||||
if c == '"' then return parse_string()
|
||||
elseif c == '{' then return parse_object()
|
||||
elseif c == '[' then return parse_array()
|
||||
elseif c == 't' then pos = pos + 4; return true
|
||||
elseif c == 'f' then pos = pos + 5; return false
|
||||
elseif c == 'n' then pos = pos + 4; return nil
|
||||
else
|
||||
-- Parentheses create the capture; without them find returns only start/end.
|
||||
local _, e, n = src:find("^(-?%d+%.?%d*[eE]?[+-]?%d*)", pos)
|
||||
if n then pos = e + 1; return tonumber(n) end
|
||||
error("json.decode: unexpected token '" .. c .. "' at pos " .. pos)
|
||||
end
|
||||
end
|
||||
|
||||
local ok, result = pcall(parse)
|
||||
if not ok then return nil, result end
|
||||
return result
|
||||
end
|
||||
|
||||
return M
|
||||
502
contrib/darktable-api/modules.lua
Normal file
502
contrib/darktable-api/modules.lua
Normal file
@@ -0,0 +1,502 @@
|
||||
-- Module parameter decoder / encoder for the darktable REST API.
|
||||
-- Runs in the LuaJIT server process. Uses FFI to unpack and repack the
|
||||
-- binary op_params blobs stored in the library.db history table.
|
||||
--
|
||||
-- Each struct definition mirrors the darktable 4.6 iop C struct exactly so
|
||||
-- that sizeof() matches the byte count in the database. The `module` column
|
||||
-- in the history table carries the version number stored for that row.
|
||||
--
|
||||
-- Unknown modules (or unsupported versions) pass through as a base64 string.
|
||||
|
||||
local ffi = require "ffi"
|
||||
local base64 = require "base64"
|
||||
|
||||
-- ── C struct definitions ───────────────────────────────────────────────────────
|
||||
|
||||
ffi.cdef[[
|
||||
/* --- flip (ver=2, 4 bytes) --- */
|
||||
typedef struct { int32_t orientation; } dt_flip_v2_t;
|
||||
|
||||
/* --- gamma / display encoding (ver=1, 8 bytes) --- */
|
||||
typedef struct { float gamma; float linear; } dt_gamma_v1_t;
|
||||
|
||||
/* --- temperature / white balance (ver=3, 16 bytes) --- */
|
||||
typedef struct { float red; float green; float blue; float various; } dt_temperature_v3_t;
|
||||
|
||||
/* --- exposure (ver=7, 28 bytes) --- */
|
||||
typedef struct {
|
||||
int32_t mode;
|
||||
float black;
|
||||
float exposure;
|
||||
float deflicker_percentile;
|
||||
float deflicker_target_level;
|
||||
int32_t compensate_exposure_bias;
|
||||
int32_t compensate_hilite_pres;
|
||||
} dt_exposure_v7_t;
|
||||
|
||||
/* --- rawprepare (ver=1, 28 bytes) --- */
|
||||
typedef struct {
|
||||
int32_t left; int32_t top; int32_t right; int32_t bottom;
|
||||
uint16_t raw_black_level_separate[4];
|
||||
uint16_t raw_white_point;
|
||||
uint16_t _pad1;
|
||||
} dt_rawprepare_v1_t;
|
||||
|
||||
/* --- rawprepare (ver=2, 32 bytes) --- */
|
||||
typedef struct {
|
||||
int32_t left; int32_t top; int32_t right; int32_t bottom;
|
||||
uint16_t raw_black_level_separate[4];
|
||||
uint16_t raw_white_point;
|
||||
uint16_t _pad1;
|
||||
int32_t flat_field;
|
||||
} dt_rawprepare_v2_t;
|
||||
|
||||
/* --- demosaic (ver=3, 20 bytes) --- */
|
||||
typedef struct {
|
||||
int32_t green_eq;
|
||||
float median_thrs;
|
||||
int32_t color_smoothing;
|
||||
int32_t demosaicing_method;
|
||||
int32_t lmmse_refine;
|
||||
} dt_demosaic_v3_t;
|
||||
|
||||
/* --- highlights (ver=4, 48 bytes) --- */
|
||||
typedef struct {
|
||||
int32_t mode;
|
||||
float blendL;
|
||||
float blendC;
|
||||
float strength;
|
||||
float clip;
|
||||
float noise_level;
|
||||
int32_t iterations;
|
||||
int32_t scales;
|
||||
float candidating;
|
||||
float combine;
|
||||
int32_t recovery;
|
||||
float solid_color;
|
||||
} dt_highlights_v4_t;
|
||||
|
||||
/* --- channelmixerrgb (ver=3, 160 bytes) --- */
|
||||
typedef struct {
|
||||
float red[4];
|
||||
float green[4];
|
||||
float blue[4];
|
||||
float saturation[4];
|
||||
float lightness[4];
|
||||
float grey[4];
|
||||
int32_t normalize_R;
|
||||
int32_t normalize_G;
|
||||
int32_t normalize_B;
|
||||
int32_t normalize_sat;
|
||||
int32_t normalize_light;
|
||||
int32_t normalize_grey;
|
||||
int32_t illuminant;
|
||||
int32_t illum_fluo;
|
||||
int32_t illum_led;
|
||||
int32_t adaptation;
|
||||
float x;
|
||||
float y;
|
||||
float temperature;
|
||||
float gamut;
|
||||
int32_t clip;
|
||||
int32_t version;
|
||||
} dt_channelmixerrgb_v3_t;
|
||||
|
||||
/* --- filmicrgb (ver=6, 116 bytes) --- */
|
||||
typedef struct {
|
||||
float grey_point_source;
|
||||
float black_point_source;
|
||||
float white_point_source;
|
||||
float reconstruct_threshold;
|
||||
float reconstruct_feather;
|
||||
float reconstruct_bloom_vs_details;
|
||||
float reconstruct_grey_vs_color;
|
||||
float reconstruct_structure_vs_texture;
|
||||
float security_factor;
|
||||
float grey_point_target;
|
||||
float black_point_target;
|
||||
float white_point_target;
|
||||
float output_power;
|
||||
float latitude;
|
||||
float contrast;
|
||||
float saturation;
|
||||
float balance;
|
||||
float noise_level;
|
||||
int32_t preserve_color;
|
||||
int32_t version;
|
||||
int32_t auto_hardness;
|
||||
int32_t custom_grey;
|
||||
int32_t high_quality_reconstruction;
|
||||
int32_t noise_distribution;
|
||||
int32_t shadows;
|
||||
int32_t highlights;
|
||||
int32_t compensate_icc_black;
|
||||
int32_t spline_version;
|
||||
int32_t enable_highlight_reconstruction;
|
||||
} dt_filmicrgb_v6_t;
|
||||
]]
|
||||
|
||||
-- ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
local function bool(v) return v ~= 0 end
|
||||
|
||||
-- Cast a Lua binary string into a pointer to ctype.
|
||||
local function cast_blob(ctype, blob)
|
||||
local buf = ffi.new("uint8_t[?]", #blob)
|
||||
ffi.copy(buf, blob, #blob)
|
||||
return ffi.cast(ctype, buf), buf -- return buf too so GC doesn't collect it
|
||||
end
|
||||
|
||||
-- Read a 4-float array field from a cdata array into a Lua table.
|
||||
local function farr(p, n)
|
||||
n = n or 4
|
||||
local t = {}
|
||||
for i = 0, n-1 do t[i+1] = p[i] end
|
||||
return t
|
||||
end
|
||||
|
||||
-- Write a Lua table back into a cdata 4-float array.
|
||||
local function set_farr(p, t, n)
|
||||
n = n or 4
|
||||
for i = 0, n-1 do
|
||||
if t[i+1] ~= nil then p[i] = t[i+1] end
|
||||
end
|
||||
end
|
||||
|
||||
-- Apply a field-level patch: if `src[k]` exists, set `dst.k = src[k]`.
|
||||
local function patch_fields(dst, src, ...)
|
||||
for _, k in ipairs({...}) do
|
||||
if src[k] ~= nil then dst[k] = src[k] end
|
||||
end
|
||||
end
|
||||
|
||||
-- Build a fresh binary blob by copying the existing one and applying changes.
|
||||
local function make_blob(ctype, size, existing, apply_fn)
|
||||
local buf = ffi.new("uint8_t[?]", size)
|
||||
if existing then ffi.copy(buf, existing, size) end
|
||||
local p = ffi.cast(ctype, buf)
|
||||
apply_fn(p)
|
||||
return ffi.string(buf, size)
|
||||
end
|
||||
|
||||
-- ── Per-module decode / encode ────────────────────────────────────────────────
|
||||
|
||||
local function decode_flip(blob, ver)
|
||||
if ver ~= 2 or #blob ~= 4 then return nil end
|
||||
local p, _ = cast_blob("dt_flip_v2_t *", blob)
|
||||
return { orientation = tonumber(p.orientation) }
|
||||
end
|
||||
|
||||
local function encode_flip(blob, changes)
|
||||
return make_blob("dt_flip_v2_t *", 4, blob, function(p)
|
||||
if changes.orientation ~= nil then p.orientation = changes.orientation end
|
||||
end)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function decode_gamma(blob, ver)
|
||||
if ver ~= 1 or #blob ~= 8 then return nil end
|
||||
local p, _ = cast_blob("dt_gamma_v1_t *", blob)
|
||||
return { gamma = tonumber(p.gamma), linear = tonumber(p.linear) }
|
||||
end
|
||||
|
||||
local function encode_gamma(blob, changes)
|
||||
return make_blob("dt_gamma_v1_t *", 8, blob, function(p)
|
||||
patch_fields(p, changes, "gamma", "linear")
|
||||
end)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function decode_temperature(blob, ver)
|
||||
if ver ~= 3 or #blob ~= 16 then return nil end
|
||||
local p, _ = cast_blob("dt_temperature_v3_t *", blob)
|
||||
return {
|
||||
red = tonumber(p.red),
|
||||
green = tonumber(p.green),
|
||||
blue = tonumber(p.blue),
|
||||
various = tonumber(p.various),
|
||||
}
|
||||
end
|
||||
|
||||
local function encode_temperature(blob, changes)
|
||||
return make_blob("dt_temperature_v3_t *", 16, blob, function(p)
|
||||
patch_fields(p, changes, "red", "green", "blue", "various")
|
||||
end)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function decode_exposure(blob, ver)
|
||||
if ver ~= 7 or #blob ~= 28 then return nil end
|
||||
local p, _ = cast_blob("dt_exposure_v7_t *", blob)
|
||||
return {
|
||||
mode = tonumber(p.mode),
|
||||
black = tonumber(p.black),
|
||||
exposure = tonumber(p.exposure),
|
||||
deflicker_percentile = tonumber(p.deflicker_percentile),
|
||||
deflicker_target_level = tonumber(p.deflicker_target_level),
|
||||
compensate_exposure_bias = bool(p.compensate_exposure_bias),
|
||||
compensate_hilite_pres = bool(p.compensate_hilite_pres),
|
||||
}
|
||||
end
|
||||
|
||||
local function encode_exposure(blob, changes)
|
||||
return make_blob("dt_exposure_v7_t *", 28, blob, function(p)
|
||||
patch_fields(p, changes, "mode", "black", "exposure",
|
||||
"deflicker_percentile", "deflicker_target_level")
|
||||
if changes.compensate_exposure_bias ~= nil then
|
||||
p.compensate_exposure_bias = changes.compensate_exposure_bias and 1 or 0
|
||||
end
|
||||
if changes.compensate_hilite_pres ~= nil then
|
||||
p.compensate_hilite_pres = changes.compensate_hilite_pres and 1 or 0
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function decode_rawprepare(blob, ver)
|
||||
if ver == 1 and #blob == 28 then
|
||||
local p, _ = cast_blob("dt_rawprepare_v1_t *", blob)
|
||||
return {
|
||||
left = tonumber(p.left), top = tonumber(p.top),
|
||||
right = tonumber(p.right), bottom = tonumber(p.bottom),
|
||||
raw_black_level_separate = farr(p.raw_black_level_separate, 4),
|
||||
raw_white_point = tonumber(p.raw_white_point),
|
||||
}
|
||||
elseif ver == 2 and #blob == 32 then
|
||||
local p, _ = cast_blob("dt_rawprepare_v2_t *", blob)
|
||||
return {
|
||||
left = tonumber(p.left), top = tonumber(p.top),
|
||||
right = tonumber(p.right), bottom = tonumber(p.bottom),
|
||||
raw_black_level_separate = farr(p.raw_black_level_separate, 4),
|
||||
raw_white_point = tonumber(p.raw_white_point),
|
||||
flat_field = tonumber(p.flat_field),
|
||||
}
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function encode_rawprepare(blob, changes, ver)
|
||||
if ver == 1 then
|
||||
return make_blob("dt_rawprepare_v1_t *", 28, blob, function(p)
|
||||
patch_fields(p, changes, "left", "top", "right", "bottom", "raw_white_point")
|
||||
if changes.raw_black_level_separate then
|
||||
set_farr(p.raw_black_level_separate, changes.raw_black_level_separate, 4)
|
||||
end
|
||||
end)
|
||||
else
|
||||
return make_blob("dt_rawprepare_v2_t *", 32, blob, function(p)
|
||||
patch_fields(p, changes, "left", "top", "right", "bottom", "raw_white_point", "flat_field")
|
||||
if changes.raw_black_level_separate then
|
||||
set_farr(p.raw_black_level_separate, changes.raw_black_level_separate, 4)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function decode_demosaic(blob, ver)
|
||||
if ver ~= 3 or #blob ~= 20 then return nil end
|
||||
local p, _ = cast_blob("dt_demosaic_v3_t *", blob)
|
||||
return {
|
||||
green_eq = tonumber(p.green_eq),
|
||||
median_thrs = tonumber(p.median_thrs),
|
||||
color_smoothing = tonumber(p.color_smoothing),
|
||||
demosaicing_method = tonumber(p.demosaicing_method),
|
||||
lmmse_refine = tonumber(p.lmmse_refine),
|
||||
}
|
||||
end
|
||||
|
||||
local function encode_demosaic(blob, changes)
|
||||
return make_blob("dt_demosaic_v3_t *", 20, blob, function(p)
|
||||
patch_fields(p, changes, "green_eq", "median_thrs", "color_smoothing",
|
||||
"demosaicing_method", "lmmse_refine")
|
||||
end)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function decode_highlights(blob, ver)
|
||||
if ver ~= 4 or #blob ~= 48 then return nil end
|
||||
local p, _ = cast_blob("dt_highlights_v4_t *", blob)
|
||||
return {
|
||||
mode = tonumber(p.mode),
|
||||
blendL = tonumber(p.blendL),
|
||||
blendC = tonumber(p.blendC),
|
||||
strength = tonumber(p.strength),
|
||||
clip = tonumber(p.clip),
|
||||
noise_level = tonumber(p.noise_level),
|
||||
iterations = tonumber(p.iterations),
|
||||
scales = tonumber(p.scales),
|
||||
candidating = tonumber(p.candidating),
|
||||
combine = tonumber(p.combine),
|
||||
recovery = tonumber(p.recovery),
|
||||
solid_color = tonumber(p.solid_color),
|
||||
}
|
||||
end
|
||||
|
||||
local function encode_highlights(blob, changes)
|
||||
return make_blob("dt_highlights_v4_t *", 48, blob, function(p)
|
||||
patch_fields(p, changes, "mode", "blendL", "blendC", "strength", "clip",
|
||||
"noise_level", "iterations", "scales", "candidating",
|
||||
"combine", "recovery", "solid_color")
|
||||
end)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function decode_channelmixerrgb(blob, ver)
|
||||
if ver ~= 3 or #blob ~= 160 then return nil end
|
||||
local p, _ = cast_blob("dt_channelmixerrgb_v3_t *", blob)
|
||||
return {
|
||||
red = farr(p.red),
|
||||
green = farr(p.green),
|
||||
blue = farr(p.blue),
|
||||
saturation = farr(p.saturation),
|
||||
lightness = farr(p.lightness),
|
||||
grey = farr(p.grey),
|
||||
normalize_R = bool(p.normalize_R),
|
||||
normalize_G = bool(p.normalize_G),
|
||||
normalize_B = bool(p.normalize_B),
|
||||
normalize_sat = bool(p.normalize_sat),
|
||||
normalize_light = bool(p.normalize_light),
|
||||
normalize_grey = bool(p.normalize_grey),
|
||||
illuminant = tonumber(p.illuminant),
|
||||
illum_fluo = tonumber(p.illum_fluo),
|
||||
illum_led = tonumber(p.illum_led),
|
||||
adaptation = tonumber(p.adaptation),
|
||||
x = tonumber(p.x),
|
||||
y = tonumber(p.y),
|
||||
temperature = tonumber(p.temperature),
|
||||
gamut = tonumber(p.gamut),
|
||||
clip = bool(p.clip),
|
||||
version = tonumber(p.version),
|
||||
}
|
||||
end
|
||||
|
||||
local function encode_channelmixerrgb(blob, changes)
|
||||
return make_blob("dt_channelmixerrgb_v3_t *", 160, blob, function(p)
|
||||
for _, k in ipairs({"red","green","blue","saturation","lightness","grey"}) do
|
||||
if changes[k] then set_farr(p[k], changes[k], 4) end
|
||||
end
|
||||
for _, k in ipairs({"normalize_R","normalize_G","normalize_B",
|
||||
"normalize_sat","normalize_light","normalize_grey","clip"}) do
|
||||
if changes[k] ~= nil then p[k] = changes[k] and 1 or 0 end
|
||||
end
|
||||
patch_fields(p, changes, "illuminant","illum_fluo","illum_led","adaptation",
|
||||
"x","y","temperature","gamut","version")
|
||||
end)
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
local function decode_filmicrgb(blob, ver)
|
||||
if ver ~= 6 or #blob ~= 116 then return nil end
|
||||
local p, _ = cast_blob("dt_filmicrgb_v6_t *", blob)
|
||||
return {
|
||||
grey_point_source = tonumber(p.grey_point_source),
|
||||
black_point_source = tonumber(p.black_point_source),
|
||||
white_point_source = tonumber(p.white_point_source),
|
||||
reconstruct_threshold = tonumber(p.reconstruct_threshold),
|
||||
reconstruct_feather = tonumber(p.reconstruct_feather),
|
||||
reconstruct_bloom_vs_details = tonumber(p.reconstruct_bloom_vs_details),
|
||||
reconstruct_grey_vs_color = tonumber(p.reconstruct_grey_vs_color),
|
||||
reconstruct_structure_vs_texture = tonumber(p.reconstruct_structure_vs_texture),
|
||||
security_factor = tonumber(p.security_factor),
|
||||
grey_point_target = tonumber(p.grey_point_target),
|
||||
black_point_target = tonumber(p.black_point_target),
|
||||
white_point_target = tonumber(p.white_point_target),
|
||||
output_power = tonumber(p.output_power),
|
||||
latitude = tonumber(p.latitude),
|
||||
contrast = tonumber(p.contrast),
|
||||
saturation = tonumber(p.saturation),
|
||||
balance = tonumber(p.balance),
|
||||
noise_level = tonumber(p.noise_level),
|
||||
preserve_color = tonumber(p.preserve_color),
|
||||
version = tonumber(p.version),
|
||||
auto_hardness = bool(p.auto_hardness),
|
||||
custom_grey = bool(p.custom_grey),
|
||||
high_quality_reconstruction = tonumber(p.high_quality_reconstruction),
|
||||
noise_distribution = tonumber(p.noise_distribution),
|
||||
shadows = tonumber(p.shadows),
|
||||
highlights = tonumber(p.highlights),
|
||||
compensate_icc_black = bool(p.compensate_icc_black),
|
||||
spline_version = tonumber(p.spline_version),
|
||||
enable_highlight_reconstruction = bool(p.enable_highlight_reconstruction),
|
||||
}
|
||||
end
|
||||
|
||||
local function encode_filmicrgb(blob, changes)
|
||||
return make_blob("dt_filmicrgb_v6_t *", 116, blob, function(p)
|
||||
patch_fields(p, changes,
|
||||
"grey_point_source","black_point_source","white_point_source",
|
||||
"reconstruct_threshold","reconstruct_feather",
|
||||
"reconstruct_bloom_vs_details","reconstruct_grey_vs_color",
|
||||
"reconstruct_structure_vs_texture","security_factor",
|
||||
"grey_point_target","black_point_target","white_point_target",
|
||||
"output_power","latitude","contrast","saturation","balance","noise_level",
|
||||
"preserve_color","version","high_quality_reconstruction",
|
||||
"noise_distribution","shadows","highlights","spline_version")
|
||||
for _, k in ipairs({"auto_hardness","custom_grey","compensate_icc_black",
|
||||
"enable_highlight_reconstruction"}) do
|
||||
if changes[k] ~= nil then p[k] = changes[k] and 1 or 0 end
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
-- ── Dispatch tables ───────────────────────────────────────────────────────────
|
||||
|
||||
local DECODERS = {
|
||||
flip = decode_flip,
|
||||
gamma = decode_gamma,
|
||||
temperature = decode_temperature,
|
||||
exposure = decode_exposure,
|
||||
rawprepare = decode_rawprepare,
|
||||
demosaic = decode_demosaic,
|
||||
highlights = decode_highlights,
|
||||
channelmixerrgb = decode_channelmixerrgb,
|
||||
filmicrgb = decode_filmicrgb,
|
||||
}
|
||||
|
||||
local ENCODERS = {
|
||||
flip = encode_flip,
|
||||
gamma = encode_gamma,
|
||||
temperature = encode_temperature,
|
||||
exposure = encode_exposure,
|
||||
rawprepare = function(b, c, v) return encode_rawprepare(b, c, v) end,
|
||||
demosaic = encode_demosaic,
|
||||
highlights = encode_highlights,
|
||||
channelmixerrgb = encode_channelmixerrgb,
|
||||
filmicrgb = encode_filmicrgb,
|
||||
}
|
||||
|
||||
-- ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
local M = {}
|
||||
|
||||
-- Decode a binary op_params blob for `operation` (ver = history.module column).
|
||||
-- Returns a Lua table of named fields, or nil if the operation/version is unknown.
|
||||
function M.decode(operation, ver, blob)
|
||||
local fn = DECODERS[operation]
|
||||
if not fn then return nil end
|
||||
local ok, result = pcall(fn, blob, ver)
|
||||
if ok then return result end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Apply `changes` (a table of field overrides) to `blob` and return the
|
||||
-- modified binary string. Returns nil if the operation is not known.
|
||||
-- For unknown modules, the caller should pass params_b64 directly.
|
||||
function M.encode(operation, ver, blob, changes)
|
||||
local fn = ENCODERS[operation]
|
||||
if not fn then return nil end
|
||||
local ok, result = pcall(fn, blob, changes, ver)
|
||||
if ok then return result end
|
||||
return nil
|
||||
end
|
||||
|
||||
return M
|
||||
131
contrib/darktable-api/preview.lua
Normal file
131
contrib/darktable-api/preview.lua
Normal file
@@ -0,0 +1,131 @@
|
||||
-- Serve image previews from darktable's mipmap cache.
|
||||
--
|
||||
-- Darktable stores mipmaps as individual JPEG files at:
|
||||
-- <cache_dir>/mipmaps-<sha1_of_library_realpath>.d/<mip_level>/<imgid>.jpg
|
||||
--
|
||||
-- Mip levels (approximate pixel sizes on the long edge):
|
||||
-- 0 → ~67 px 1 → ~135 px 2 → ~270 px 3 → ~540 px
|
||||
-- 4 → ~1080 px (good "medium") 5 → ~2160 px
|
||||
--
|
||||
-- If no cached mipmap is found we fall back to exiftool (embedded EXIF
|
||||
-- thumbnail) and finally to serving the original file path as a redirect.
|
||||
local ffi = require "ffi"
|
||||
local config = require "config"
|
||||
|
||||
ffi.cdef[[
|
||||
unsigned char *SHA1(const unsigned char *d, size_t n, unsigned char *md);
|
||||
]]
|
||||
local crypto = ffi.load("crypto")
|
||||
|
||||
local M = {}
|
||||
|
||||
-- ── Locate the mipmap cache directory (computed once) ─────────────────────────
|
||||
|
||||
local _mipmap_base -- e.g. "/home/user/.cache/darktable/mipmaps-abc123"
|
||||
|
||||
local function mipmap_base()
|
||||
if _mipmap_base then return _mipmap_base end
|
||||
|
||||
-- Resolve the real path of library.db (darktable uses realpath before hashing)
|
||||
local h = io.popen("realpath " .. config.library_db .. " 2>/dev/null")
|
||||
local realpath = h:read("*l"); h:close()
|
||||
realpath = realpath or config.library_db
|
||||
|
||||
-- SHA1 hex of the realpath (same algorithm as mipmap_cache.c)
|
||||
local md = ffi.new("unsigned char[20]")
|
||||
crypto.SHA1(realpath, #realpath, md)
|
||||
local hex = {}
|
||||
for i = 0, 19 do hex[i+1] = string.format("%02x", md[i]) end
|
||||
local sha1hex = table.concat(hex)
|
||||
|
||||
_mipmap_base = config.cache_dir .. "/mipmaps-" .. sha1hex
|
||||
return _mipmap_base
|
||||
end
|
||||
|
||||
-- ── Size → mip level mapping ──────────────────────────────────────────────────
|
||||
|
||||
-- size param: "thumb" (~135 px), "small" (~270 px), "medium" (~540 px),
|
||||
-- "large" (~1080 px), "xlarge" (~2160 px), or an integer level 0-5.
|
||||
local SIZE_TO_MIP = {
|
||||
thumb = 1,
|
||||
small = 2,
|
||||
medium = 3,
|
||||
large = 4,
|
||||
xlarge = 5,
|
||||
}
|
||||
|
||||
local function size_to_mip(size)
|
||||
if not size or size == "" then return 3 end -- default: "medium"
|
||||
local n = tonumber(size)
|
||||
if n then return math.max(0, math.min(5, math.floor(n))) end
|
||||
return SIZE_TO_MIP[size:lower()] or 3
|
||||
end
|
||||
|
||||
-- ── Try to read a file, return nil on failure ─────────────────────────────────
|
||||
|
||||
local function read_file(path)
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return nil end
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
return data
|
||||
end
|
||||
|
||||
-- ── Public: get preview data for an image id ──────────────────────────────────
|
||||
--
|
||||
-- Returns: data (string), content_type (string)
|
||||
-- or: nil, nil, redirect_url (caller should issue a 302)
|
||||
-- or: nil, nil, nil (404)
|
||||
function M.get(image_id, image_path, filename, size_param)
|
||||
local mip = size_to_mip(size_param)
|
||||
|
||||
-- 1. Walk mip levels: first downward (smaller = smaller file, faster),
|
||||
-- then upward (larger mip levels, let the browser scale down).
|
||||
-- This ensures we serve something even when only higher-resolution
|
||||
-- mipmaps have been cached (e.g. darktable only wrote levels 2-4).
|
||||
local base = mipmap_base()
|
||||
for level = mip, 0, -1 do
|
||||
local path = string.format("%s.d/%d/%d.jpg", base, level, image_id)
|
||||
local data = read_file(path)
|
||||
if data then return data, "image/jpeg" end
|
||||
end
|
||||
for level = mip + 1, 5 do
|
||||
local path = string.format("%s.d/%d/%d.jpg", base, level, image_id)
|
||||
local data = read_file(path)
|
||||
if data then return data, "image/jpeg" end
|
||||
end
|
||||
|
||||
-- 2. Try exiftool to extract the embedded EXIF thumbnail from the source file.
|
||||
if image_path and filename then
|
||||
local src = image_path .. "/" .. filename
|
||||
-- exiftool writes the thumbnail to stdout with -b
|
||||
local cmd = string.format("exiftool -b -ThumbnailImage %q 2>/dev/null", src)
|
||||
local fh = io.popen(cmd)
|
||||
if fh then
|
||||
local data = fh:read("*a"); fh:close()
|
||||
if data and #data > 100 then
|
||||
return data, "image/jpeg"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- 3. For JPEG/PNG source files redirect to the original (client caches it).
|
||||
if image_path and filename then
|
||||
local src = image_path .. "/" .. filename
|
||||
local ext = filename:match("%.([^%.]+)$") or ""
|
||||
if ext:lower():match("^jpe?g$") or ext:lower() == "png" then
|
||||
return nil, nil, "file://" .. src -- caller issues redirect or reads directly
|
||||
end
|
||||
end
|
||||
|
||||
return nil, nil, nil -- no preview available
|
||||
end
|
||||
|
||||
-- ── Helper: read a local file and serve it inline ─────────────────────────────
|
||||
-- Used by the API handler when get() returns a file:// redirect.
|
||||
function M.read_local(path)
|
||||
local stripped = path:match("^file://(.+)$") or path
|
||||
return read_file(stripped)
|
||||
end
|
||||
|
||||
return M
|
||||
68
contrib/darktable-api/router.lua
Normal file
68
contrib/darktable-api/router.lua
Normal file
@@ -0,0 +1,68 @@
|
||||
-- Simple radix-style HTTP router.
|
||||
-- Supports static segments and named captures (:name).
|
||||
-- Usage:
|
||||
-- router.get("/api/v1/images", handler)
|
||||
-- router.get("/api/v1/images/:id", handler)
|
||||
-- router.post("/oauth/token", handler)
|
||||
--
|
||||
-- local handler, params = router.match("GET", "/api/v1/images/42")
|
||||
-- -- handler = <function>, params = { id = "42" }
|
||||
local M = {}
|
||||
|
||||
local routes = {} -- { method, pattern_parts, handler }
|
||||
|
||||
-- Convert a route pattern like "/api/v1/images/:id/preview" into a segment list.
|
||||
local function compile(pattern)
|
||||
local parts = {}
|
||||
-- Strip leading slash so the segment list aligns with the path splitter below.
|
||||
for seg in (pattern:gsub("^/", "") .. "/"):gmatch("([^/]*)/") do
|
||||
if seg:sub(1,1) == ":" then
|
||||
parts[#parts+1] = { capture = seg:sub(2) }
|
||||
else
|
||||
parts[#parts+1] = { literal = seg }
|
||||
end
|
||||
end
|
||||
return parts
|
||||
end
|
||||
|
||||
local function register(method, pattern, handler)
|
||||
routes[#routes+1] = { method = method:upper(), parts = compile(pattern), handler = handler }
|
||||
end
|
||||
|
||||
function M.get(pattern, handler) register("GET", pattern, handler) end
|
||||
function M.post(pattern, handler) register("POST", pattern, handler) end
|
||||
function M.put(pattern, handler) register("PUT", pattern, handler) end
|
||||
function M.patch(pattern, handler) register("PATCH", pattern, handler) end
|
||||
function M.delete(pattern, handler) register("DELETE", pattern, handler) end
|
||||
|
||||
-- Match a request against registered routes.
|
||||
-- Returns: handler, path_params or nil, nil
|
||||
function M.match(method, path)
|
||||
-- Split path into segments
|
||||
local segs = {}
|
||||
for seg in (path:gsub("^/","") .. "/"):gmatch("([^/]*)/") do
|
||||
segs[#segs+1] = seg
|
||||
end
|
||||
|
||||
method = method:upper()
|
||||
|
||||
for _, route in ipairs(routes) do
|
||||
if route.method == method and #route.parts == #segs then
|
||||
local params = {}
|
||||
local matched = true
|
||||
for i, part in ipairs(route.parts) do
|
||||
if part.capture then
|
||||
params[part.capture] = segs[i]
|
||||
elseif part.literal ~= segs[i] then
|
||||
matched = false
|
||||
break
|
||||
end
|
||||
end
|
||||
if matched then return route.handler, params end
|
||||
end
|
||||
end
|
||||
|
||||
return nil, nil
|
||||
end
|
||||
|
||||
return M
|
||||
280
contrib/darktable-api/server.lua
Normal file
280
contrib/darktable-api/server.lua
Normal file
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env luajit
|
||||
-- Darktable REST API — standalone LuaJIT HTTP server
|
||||
-- Usage: luajit server.lua [port] [host]
|
||||
--
|
||||
-- Dependencies (install via luarocks):
|
||||
-- luasocket — luarocks install luasocket
|
||||
-- lsqlite3 — luarocks install lsqlite3
|
||||
-- libcrypto — provided by the system OpenSSL package
|
||||
--
|
||||
-- Quick-start:
|
||||
-- export DT_CLIENT_ID=myclient DT_CLIENT_SECRET=mysecret DT_JWT_SECRET=longrandomstring
|
||||
-- luajit server.lua
|
||||
--
|
||||
-- Then get a token:
|
||||
-- curl -s -X POST http://127.0.0.1:7645/oauth/token \
|
||||
-- -d 'grant_type=client_credentials&client_id=myclient&client_secret=mysecret'
|
||||
--
|
||||
-- And use it:
|
||||
-- curl -s -H "Authorization: Bearer <token>" http://127.0.0.1:7645/api/v1/images
|
||||
|
||||
-- ── Bootstrap: make local modules findable ────────────────────────────────────
|
||||
|
||||
local script_dir = debug.getinfo(1, "S").source:match("^@(.+)/[^/]+$") or "."
|
||||
package.path = script_dir .. "/?.lua;" .. package.path
|
||||
|
||||
-- ── Load modules ──────────────────────────────────────────────────────────────
|
||||
|
||||
local socket = require "socket"
|
||||
local config = require "config"
|
||||
local router = require "router"
|
||||
local api = require "api"
|
||||
|
||||
-- ── Static file server (PWA) ──────────────────────────────────────────────────
|
||||
|
||||
local WEB_BUILD = script_dir .. "/web/build"
|
||||
|
||||
local MIME = {
|
||||
html = "text/html; charset=utf-8",
|
||||
js = "application/javascript; charset=utf-8",
|
||||
mjs = "application/javascript; charset=utf-8",
|
||||
css = "text/css; charset=utf-8",
|
||||
json = "application/json",
|
||||
webmanifest = "application/manifest+json",
|
||||
png = "image/png",
|
||||
jpg = "image/jpeg",
|
||||
jpeg = "image/jpeg",
|
||||
svg = "image/svg+xml",
|
||||
ico = "image/x-icon",
|
||||
woff = "font/woff",
|
||||
woff2 = "font/woff2",
|
||||
txt = "text/plain; charset=utf-8",
|
||||
map = "application/json",
|
||||
}
|
||||
|
||||
-- Read a file from the web build directory. Returns data, content-type or nil.
|
||||
-- Prevents directory traversal by rejecting any path containing "..".
|
||||
local function read_static(req_path)
|
||||
if req_path:find("%.%.", 1, true) then return nil end
|
||||
|
||||
-- Strip leading slash; normalise double-slashes.
|
||||
local rel = req_path:gsub("^/+", ""):gsub("//+", "/")
|
||||
local full = WEB_BUILD .. "/" .. rel
|
||||
|
||||
local f = io.open(full, "rb")
|
||||
if not f then return nil end
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
|
||||
local ext = rel:match("%.([^%.]+)$") or ""
|
||||
local ct = MIME[ext:lower()] or "application/octet-stream"
|
||||
return data, ct
|
||||
end
|
||||
|
||||
-- Cache headers: hashed assets get long TTL; everything else no-store.
|
||||
local function cache_header(path)
|
||||
-- Vite fingerprints assets with a hash in the filename (e.g. index-Bx3Rk.js)
|
||||
if path:match("/assets/") then
|
||||
return "public, max-age=31536000, immutable"
|
||||
end
|
||||
return "no-store"
|
||||
end
|
||||
|
||||
-- ── Register routes ───────────────────────────────────────────────────────────
|
||||
|
||||
router.post("/oauth/token", api.oauth_token)
|
||||
|
||||
router.get("/health", api.health)
|
||||
|
||||
router.get("/api/v1/images", api.list_images)
|
||||
router.get("/api/v1/images/:id", api.get_image)
|
||||
router.get("/api/v1/images/:id/preview", api.get_preview)
|
||||
router.get("/api/v1/images/:id/full", api.get_full)
|
||||
router.post("/api/v1/images/:id/refresh", api.refresh_cache)
|
||||
router.post("/api/v1/images/:id/export", api.trigger_export)
|
||||
router.get("/api/v1/images/:id/modules", api.list_modules)
|
||||
router.get("/api/v1/images/:id/modules/:op", api.get_module)
|
||||
router.patch("/api/v1/images/:id/modules/:op", api.patch_module)
|
||||
|
||||
router.get("/api/v1/tags", api.list_tags)
|
||||
router.get("/api/v1/films", api.list_films)
|
||||
|
||||
-- ── HTTP helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
local STATUS_TEXT = {
|
||||
[200] = "OK", [201] = "Created", [202] = "Accepted", [204] = "No Content",
|
||||
[301] = "Moved Permanently",
|
||||
[400] = "Bad Request", [401] = "Unauthorized", [403] = "Forbidden",
|
||||
[404] = "Not Found", [405] = "Method Not Allowed",
|
||||
[500] = "Internal Server Error", [504] = "Gateway Timeout",
|
||||
}
|
||||
|
||||
local function url_decode(s)
|
||||
return s:gsub("+", " "):gsub("%%(%x%x)", function(h) return string.char(tonumber(h, 16)) end)
|
||||
end
|
||||
|
||||
-- Parse the raw HTTP request from a connected socket.
|
||||
-- Returns a request table, or nil on parse/timeout error.
|
||||
local function read_request(client)
|
||||
client:settimeout(10)
|
||||
|
||||
local line, lerr = client:receive("*l")
|
||||
if not line then return nil end
|
||||
|
||||
local method, raw_path = line:match("^(%S+)%s+(%S+)%s+HTTP/")
|
||||
if not method then return nil end
|
||||
|
||||
-- Headers
|
||||
local headers = {}
|
||||
while true do
|
||||
local hline, herr = client:receive("*l")
|
||||
if not hline or hline == "" then break end
|
||||
local name, value = hline:match("^([^:]+):%s*(.*)$")
|
||||
if name then
|
||||
-- normalise to lowercase, strip trailing whitespace
|
||||
headers[name:lower():gsub("%s+$", "")] = value:gsub("%s+$", "")
|
||||
end
|
||||
end
|
||||
|
||||
-- Body
|
||||
local body = ""
|
||||
local clen = tonumber(headers["content-length"])
|
||||
if clen and clen > 0 then
|
||||
body = client:receive(math.min(clen, 4 * 1024 * 1024)) or "" -- 4 MiB cap
|
||||
end
|
||||
|
||||
-- Split path from query string
|
||||
local path, qs = raw_path:match("^([^?]*)%??(.*)")
|
||||
|
||||
-- Parse query parameters
|
||||
local params = {}
|
||||
for k, v in (qs or ""):gmatch("([^&=]+)=([^&]*)") do
|
||||
params[url_decode(k)] = url_decode(v)
|
||||
end
|
||||
|
||||
return {
|
||||
method = method:upper(),
|
||||
path = path,
|
||||
raw_path = raw_path,
|
||||
headers = headers,
|
||||
body = body,
|
||||
params = params,
|
||||
path_params = {},
|
||||
}
|
||||
end
|
||||
|
||||
local CORS_HEADERS = {
|
||||
["Access-Control-Allow-Origin"] = "*",
|
||||
["Access-Control-Allow-Methods"] = "GET, POST, PATCH, PUT, DELETE, OPTIONS",
|
||||
["Access-Control-Allow-Headers"] = "Authorization, Content-Type",
|
||||
["Access-Control-Max-Age"] = "86400",
|
||||
}
|
||||
|
||||
-- Write an HTTP response to the socket.
|
||||
local function write_response(client, status, body, content_type, extra_headers)
|
||||
body = body or ""
|
||||
content_type = content_type or "application/json; charset=utf-8"
|
||||
local phrase = STATUS_TEXT[status] or "Unknown"
|
||||
|
||||
local lines = {
|
||||
string.format("HTTP/1.1 %d %s\r\n", status, phrase),
|
||||
"Server: darktable-api/1.0\r\n",
|
||||
"Content-Type: " .. content_type .. "\r\n",
|
||||
"Content-Length: " .. #body .. "\r\n",
|
||||
"Connection: close\r\n",
|
||||
}
|
||||
|
||||
for k, v in pairs(CORS_HEADERS) do
|
||||
lines[#lines+1] = k .. ": " .. v .. "\r\n"
|
||||
end
|
||||
if extra_headers then
|
||||
for k, v in pairs(extra_headers) do
|
||||
lines[#lines+1] = k .. ": " .. v .. "\r\n"
|
||||
end
|
||||
end
|
||||
|
||||
lines[#lines+1] = "\r\n"
|
||||
lines[#lines+1] = body
|
||||
|
||||
client:send(table.concat(lines))
|
||||
end
|
||||
|
||||
local json = require "json"
|
||||
|
||||
local function handle_client(client)
|
||||
local req = read_request(client)
|
||||
if not req then
|
||||
write_response(client, 400, '{"error":"bad_request"}')
|
||||
return
|
||||
end
|
||||
|
||||
-- CORS preflight
|
||||
if req.method == "OPTIONS" then
|
||||
write_response(client, 204, "")
|
||||
return
|
||||
end
|
||||
|
||||
-- API / OAuth routes take priority over static files.
|
||||
local handler, path_params = router.match(req.method, req.path)
|
||||
if handler then
|
||||
req.path_params = path_params or {}
|
||||
local status, body, ct, extra = handler(req)
|
||||
write_response(client, status, body, ct, extra)
|
||||
return
|
||||
end
|
||||
|
||||
-- Static file serving for the PWA (GET/HEAD only).
|
||||
if req.method == "GET" or req.method == "HEAD" then
|
||||
local data, ct = read_static(req.path)
|
||||
|
||||
if not data then
|
||||
-- SPA fallback: any unknown path gets index.html so client-side routing works.
|
||||
data, ct = read_static("/index.html")
|
||||
end
|
||||
|
||||
if data then
|
||||
local extra = { ["Cache-Control"] = cache_header(req.path) }
|
||||
write_response(client, 200, req.method == "HEAD" and "" or data, ct, extra)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
write_response(client, 404, json.encode({ error = "not_found", path = req.path }))
|
||||
end
|
||||
|
||||
-- ── Main server loop ──────────────────────────────────────────────────────────
|
||||
|
||||
local port = tonumber(arg and arg[1]) or config.port
|
||||
local host = (arg and arg[2]) or config.host
|
||||
|
||||
local server, serr = socket.bind(host, port)
|
||||
if not server then
|
||||
io.stderr:write("darktable-api: cannot bind " .. host .. ":" .. port .. ": " .. tostring(serr) .. "\n")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
server:settimeout(0) -- non-blocking accept
|
||||
|
||||
io.write(string.format(
|
||||
"darktable-api: listening on http://%s:%d\n" ..
|
||||
" library : %s\n" ..
|
||||
" cache : %s\n" ..
|
||||
" web app : http://%s:%d/\n",
|
||||
host, port, config.library_db, config.cache_dir, host, port))
|
||||
io.flush()
|
||||
|
||||
while true do
|
||||
local client, cerr = server:accept()
|
||||
if client then
|
||||
local ok, result = pcall(handle_client, client)
|
||||
if not ok then
|
||||
pcall(write_response, client, 500,
|
||||
json.encode({ error = "internal_server_error", detail = tostring(result) }))
|
||||
io.stderr:write("darktable-api: error: " .. tostring(result) .. "\n")
|
||||
end
|
||||
pcall(function() client:close() end)
|
||||
else
|
||||
-- No connection yet — yield CPU briefly.
|
||||
socket.select(nil, nil, 0.05)
|
||||
end
|
||||
end
|
||||
109
contrib/darktable-api/start-with-api.sh
Executable file
109
contrib/darktable-api/start-with-api.sh
Executable file
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bash
|
||||
# start-with-api.sh — Launch the darktable REST API server and darktable together.
|
||||
#
|
||||
# What it does:
|
||||
# 1. Optionally configures darktable's luarc to load the bridge plugin.
|
||||
# 2. Starts the standalone LuaJIT REST API server in the background.
|
||||
# 3. Starts darktable in the foreground.
|
||||
# 4. Stops the API server when darktable exits.
|
||||
#
|
||||
# Usage:
|
||||
# ./start-with-api.sh [darktable-options...]
|
||||
#
|
||||
# Environment variables (all optional):
|
||||
# DT_CLIENT_ID OAuth client ID (default: darktable-client)
|
||||
# DT_CLIENT_SECRET OAuth client secret (default: changeme)
|
||||
# DT_JWT_SECRET JWT signing secret (default: change-this-secret-in-production)
|
||||
# DT_API_HOST Server bind address (default: 127.0.0.1)
|
||||
# DT_API_PORT Server port (default: 7645)
|
||||
# DT_LIBRARY darktable library DB (default: ~/.config/darktable/library.db)
|
||||
# DT_REFRESH_QUEUE IPC queue file (default: /tmp/dt-api-refresh.queue)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
: "${DT_CLIENT_ID:=darktable-client}"
|
||||
: "${DT_CLIENT_SECRET:=changeme}"
|
||||
: "${DT_JWT_SECRET:=change-this-secret-in-production}"
|
||||
: "${DT_API_HOST:=127.0.0.1}"
|
||||
: "${DT_API_PORT:=7645}"
|
||||
: "${DT_REFRESH_QUEUE:=/tmp/dt-api-refresh.queue}"
|
||||
|
||||
export DT_CLIENT_ID DT_CLIENT_SECRET DT_JWT_SECRET
|
||||
export DT_API_HOST DT_API_PORT DT_REFRESH_QUEUE
|
||||
|
||||
# ── 1. Ensure the bridge plugin is in darktable's luarc ───────────────────────
|
||||
|
||||
LUARC="$HOME/.config/darktable/luarc"
|
||||
BRIDGE_REQUIRE='require "contrib/darktable-api/darktable-api-bridge"'
|
||||
|
||||
if ! grep -qF "$BRIDGE_REQUIRE" "$LUARC" 2>/dev/null; then
|
||||
echo "[start-with-api] Adding bridge plugin to $LUARC"
|
||||
echo "$BRIDGE_REQUIRE" >> "$LUARC"
|
||||
fi
|
||||
|
||||
# ── 2. Install the bridge plugin into darktable's lua directory ───────────────
|
||||
# darktable searches $HOME/.config/darktable/lua/ for scripts.
|
||||
# We copy (not symlink) the bridge plugin file there so darktable can
|
||||
# require "contrib/darktable-api/darktable-api-bridge" without any extra path config.
|
||||
|
||||
DT_LUA_DIR="$HOME/.config/darktable/lua"
|
||||
BRIDGE_DEST="$DT_LUA_DIR/contrib/darktable-api"
|
||||
BRIDGE_SRC="$SCRIPT_DIR/darktable-api-bridge.lua"
|
||||
|
||||
mkdir -p "$BRIDGE_DEST"
|
||||
if ! cmp -s "$BRIDGE_SRC" "$BRIDGE_DEST/darktable-api-bridge.lua" 2>/dev/null; then
|
||||
cp "$BRIDGE_SRC" "$BRIDGE_DEST/darktable-api-bridge.lua"
|
||||
echo "[start-with-api] Installed bridge plugin → $BRIDGE_DEST/"
|
||||
fi
|
||||
|
||||
# ── 3. Clean up any stale queue file ─────────────────────────────────────────
|
||||
|
||||
rm -f "$DT_REFRESH_QUEUE" "${DT_REFRESH_QUEUE}.processing"
|
||||
|
||||
# ── 4. Start the LuaJIT REST API server ───────────────────────────────────────
|
||||
|
||||
API_LOG="${TMPDIR:-/tmp}/darktable-api.log"
|
||||
echo "[start-with-api] Starting API server on http://${DT_API_HOST}:${DT_API_PORT}"
|
||||
echo "[start-with-api] Log: $API_LOG"
|
||||
|
||||
luajit "$SCRIPT_DIR/server.lua" "$DT_API_PORT" "$DT_API_HOST" >"$API_LOG" 2>&1 &
|
||||
API_PID=$!
|
||||
|
||||
# Give the server a moment to bind and confirm it's up.
|
||||
sleep 0.5
|
||||
if ! kill -0 "$API_PID" 2>/dev/null; then
|
||||
echo "[start-with-api] ERROR: API server failed to start. Check $API_LOG" >&2
|
||||
cat "$API_LOG" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! curl -sf "http://${DT_API_HOST}:${DT_API_PORT}/health" >/dev/null 2>&1; then
|
||||
echo "[start-with-api] WARNING: health check failed; server may still be starting"
|
||||
fi
|
||||
|
||||
echo "[start-with-api] API server running (PID $API_PID)"
|
||||
echo ""
|
||||
echo " Get a token:"
|
||||
echo " curl -X POST http://${DT_API_HOST}:${DT_API_PORT}/oauth/token \\"
|
||||
echo " -d 'grant_type=client_credentials&client_id=${DT_CLIENT_ID}&client_secret=${DT_CLIENT_SECRET}'"
|
||||
echo ""
|
||||
echo " Trigger a cache refresh (replace TOKEN and IMAGE_ID):"
|
||||
echo " curl -X POST http://${DT_API_HOST}:${DT_API_PORT}/api/v1/images/IMAGE_ID/refresh \\"
|
||||
echo " -H 'Authorization: Bearer TOKEN'"
|
||||
echo ""
|
||||
|
||||
# ── 5. Start darktable ────────────────────────────────────────────────────────
|
||||
|
||||
echo "[start-with-api] Starting darktable..."
|
||||
darktable "$@" || true
|
||||
|
||||
# ── 6. Stop the API server on exit ───────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "[start-with-api] darktable exited; stopping API server (PID $API_PID)"
|
||||
kill "$API_PID" 2>/dev/null || true
|
||||
wait "$API_PID" 2>/dev/null || true
|
||||
rm -f "$DT_REFRESH_QUEUE" "${DT_REFRESH_QUEUE}.processing"
|
||||
echo "[start-with-api] done"
|
||||
4
contrib/darktable-api/web/.gitignore
vendored
Normal file
4
contrib/darktable-api/web/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
.vite/
|
||||
19
contrib/darktable-api/web/index.html
Normal file
19
contrib/darktable-api/web/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0d0d0d" />
|
||||
<meta name="description" content="darktable REST API web interface" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="darktable" />
|
||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||
<title>darktable Gallery</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6058
contrib/darktable-api/web/package-lock.json
generated
Normal file
6058
contrib/darktable-api/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
contrib/darktable-api/web/package.json
Normal file
20
contrib/darktable-api/web/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "darktable-api-pwa",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^5.4.11",
|
||||
"vite-plugin-pwa": "^0.21.1"
|
||||
}
|
||||
}
|
||||
28
contrib/darktable-api/web/public/icon.svg
Normal file
28
contrib/darktable-api/web/public/icon.svg
Normal file
@@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<!-- Dark background -->
|
||||
<rect width="512" height="512" rx="80" fill="#0d0d0d"/>
|
||||
<!-- Aperture blades in orange accent -->
|
||||
<!-- Center circle -->
|
||||
<circle cx="256" cy="256" r="60" fill="none" stroke="#f4a22d" stroke-width="8"/>
|
||||
<!-- Aperture blade 1 -->
|
||||
<path d="M256 120 L296 196 L256 196 Z" fill="#f4a22d" opacity="0.9"/>
|
||||
<!-- Aperture blade 2 -->
|
||||
<path d="M370 181 L316 240 L296 196 Z" fill="#f4a22d" opacity="0.9"/>
|
||||
<!-- Aperture blade 3 -->
|
||||
<path d="M392 296 L316 240 L336 296 Z" fill="#f4a22d" opacity="0.9"/>
|
||||
<!-- Aperture blade 4 -->
|
||||
<path d="M336 392 L256 316 L336 296 Z" fill="#f4a22d" opacity="0.9"/>
|
||||
<!-- Aperture blade 5 -->
|
||||
<path d="M176 392 L196 316 L256 316 Z" fill="#f4a22d" opacity="0.9"/>
|
||||
<!-- Aperture blade 6 -->
|
||||
<path d="M120 296 L196 316 L176 240 Z" fill="#f4a22d" opacity="0.9"/>
|
||||
<!-- Aperture blade 7 -->
|
||||
<path d="M120 181 L176 240 L196 196 Z" fill="#f4a22d" opacity="0.9"/>
|
||||
<!-- Aperture blade 8 -->
|
||||
<path d="M216 120 L216 196 L256 196 Z" fill="#f4a22d" opacity="0.9"/>
|
||||
<!-- Outer ring -->
|
||||
<circle cx="256" cy="256" r="180" fill="none" stroke="#f4a22d" stroke-width="12" opacity="0.6"/>
|
||||
<!-- Inner highlight -->
|
||||
<circle cx="256" cy="256" r="40" fill="#f4a22d" opacity="0.3"/>
|
||||
<circle cx="256" cy="256" r="20" fill="#f4a22d"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
69
contrib/darktable-api/web/src/App.jsx
Normal file
69
contrib/darktable-api/web/src/App.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useReducer,
|
||||
} from 'react';
|
||||
import { ToastProvider } from './components/Toast.jsx';
|
||||
import { getSettings } from './api.js';
|
||||
import Settings from './screens/Settings.jsx';
|
||||
import Gallery from './screens/Gallery.jsx';
|
||||
|
||||
// ── App Context ───────────────────────────────────────────────────────────────
|
||||
|
||||
const AppContext = createContext(null);
|
||||
|
||||
export function useApp() {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) throw new Error('useApp must be used within AppContext');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
screen: 'gallery', // 'settings' | 'gallery'
|
||||
configured: false,
|
||||
};
|
||||
|
||||
function reducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'INIT':
|
||||
return { ...state, configured: action.configured, screen: action.configured ? 'gallery' : 'settings' };
|
||||
case 'GO_SETTINGS':
|
||||
return { ...state, screen: 'settings' };
|
||||
case 'GO_GALLERY':
|
||||
return { ...state, screen: 'gallery', configured: true };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// ── App Root ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function App() {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
|
||||
useEffect(() => {
|
||||
const settings = getSettings();
|
||||
dispatch({ type: 'INIT', configured: !!settings });
|
||||
}, []);
|
||||
|
||||
const goSettings = useCallback(() => dispatch({ type: 'GO_SETTINGS' }), []);
|
||||
const goGallery = useCallback(() => dispatch({ type: 'GO_GALLERY' }), []);
|
||||
|
||||
const contextValue = { goSettings, goGallery, configured: state.configured };
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={contextValue}>
|
||||
<ToastProvider>
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{state.screen === 'settings' ? (
|
||||
<Settings />
|
||||
) : (
|
||||
<Gallery />
|
||||
)}
|
||||
</div>
|
||||
</ToastProvider>
|
||||
</AppContext.Provider>
|
||||
);
|
||||
}
|
||||
353
contrib/darktable-api/web/src/api.js
Normal file
353
contrib/darktable-api/web/src/api.js
Normal file
@@ -0,0 +1,353 @@
|
||||
// Darktable REST API client
|
||||
// Handles OAuth2 client_credentials, token caching, all endpoints.
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
SETTINGS: 'dt_settings',
|
||||
TOKEN: 'dt_token',
|
||||
};
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getSettings() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEYS.SETTINGS);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSettings(settings) {
|
||||
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
export function clearSettings() {
|
||||
localStorage.removeItem(STORAGE_KEYS.SETTINGS);
|
||||
localStorage.removeItem(STORAGE_KEYS.TOKEN);
|
||||
}
|
||||
|
||||
// ── Token management ──────────────────────────────────────────────────────────
|
||||
|
||||
function getCachedToken() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEYS.TOKEN);
|
||||
if (!raw) return null;
|
||||
const token = JSON.parse(raw);
|
||||
// Treat as expired 30 seconds early to avoid race conditions
|
||||
if (Date.now() >= token.expiresAt - 30_000) return null;
|
||||
return token.accessToken;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cacheToken(accessToken, expiresIn) {
|
||||
const token = {
|
||||
accessToken,
|
||||
expiresAt: Date.now() + expiresIn * 1000,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEYS.TOKEN, JSON.stringify(token));
|
||||
}
|
||||
|
||||
let tokenFetchPromise = null;
|
||||
|
||||
async function fetchToken(settings) {
|
||||
const url = `${settings.baseUrl}/oauth/token`;
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: settings.clientId,
|
||||
client_secret: settings.clientSecret,
|
||||
});
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new ApiError(res.status, `Token request failed: ${text || res.statusText}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const expiresIn = data.expires_in ?? 3600;
|
||||
cacheToken(data.access_token, expiresIn);
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
async function getToken() {
|
||||
const settings = getSettings();
|
||||
if (!settings) throw new ApiError(0, 'Not configured. Please open Settings.');
|
||||
|
||||
const cached = getCachedToken();
|
||||
if (cached) return cached;
|
||||
|
||||
// Deduplicate concurrent token fetches
|
||||
if (!tokenFetchPromise) {
|
||||
tokenFetchPromise = fetchToken(settings).finally(() => {
|
||||
tokenFetchPromise = null;
|
||||
});
|
||||
}
|
||||
return tokenFetchPromise;
|
||||
}
|
||||
|
||||
// ── Error class ───────────────────────────────────────────────────────────────
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core fetch helper ─────────────────────────────────────────────────────────
|
||||
|
||||
async function apiFetch(path, options = {}) {
|
||||
const settings = getSettings();
|
||||
if (!settings) throw new ApiError(0, 'Not configured. Please open Settings.');
|
||||
|
||||
const token = await getToken();
|
||||
const url = `${settings.baseUrl}${path}`;
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
let message;
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
message = json.error || json.message || text;
|
||||
} catch {
|
||||
message = text || res.statusText;
|
||||
}
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async function apiJson(path, options = {}) {
|
||||
const res = await apiFetch(path, {
|
||||
...options,
|
||||
headers: { Accept: 'application/json', ...options.headers },
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Blob URL cache ────────────────────────────────────────────────────────────
|
||||
// Keeps a reference-counted map of blob URLs so we don't recreate them for the
|
||||
// same image+size combination within a session.
|
||||
|
||||
const blobCache = new Map(); // key → { url, refs }
|
||||
|
||||
export function releaseBlobUrl(key) {
|
||||
const entry = blobCache.get(key);
|
||||
if (!entry) return;
|
||||
entry.refs -= 1;
|
||||
if (entry.refs <= 0) {
|
||||
URL.revokeObjectURL(entry.url);
|
||||
blobCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPreviewBlob(id, size = 'thumb') {
|
||||
const key = `preview:${id}:${size}`;
|
||||
const existing = blobCache.get(key);
|
||||
if (existing) {
|
||||
existing.refs += 1;
|
||||
return { url: existing.url, key };
|
||||
}
|
||||
|
||||
// Use raw fetch so we can inspect the status before assuming it's an image.
|
||||
const settings = getSettings();
|
||||
if (!settings) throw new ApiError(0, 'Not configured.');
|
||||
const token = await getToken();
|
||||
|
||||
const res = await fetch(
|
||||
`${settings.baseUrl}/api/v1/images/${id}/preview?size=${size}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
|
||||
// 202 means the server has enqueued thumbnail generation; client should retry.
|
||||
if (res.status === 202) {
|
||||
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '4', 10);
|
||||
return { generating: true, retryAfter };
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new ApiError(res.status, text || res.statusText);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
blobCache.set(key, { url, refs: 1 });
|
||||
return { url, key };
|
||||
}
|
||||
|
||||
export async function getFullBlob(id) {
|
||||
const key = `full:${id}`;
|
||||
const existing = blobCache.get(key);
|
||||
if (existing) {
|
||||
existing.refs += 1;
|
||||
return { url: existing.url, key };
|
||||
}
|
||||
|
||||
const res = await apiFetch(`/api/v1/images/${id}/full`);
|
||||
// May return 202 with Retry-After; caller handles polling
|
||||
if (res.status === 202) {
|
||||
const retryAfter = parseInt(res.headers.get('Retry-After') ?? '2', 10);
|
||||
return { pending: true, retryAfter };
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
blobCache.set(key, { url, refs: 1 });
|
||||
return { url, key };
|
||||
}
|
||||
|
||||
// ── Images ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listImages(params = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
const allowed = [
|
||||
'limit', 'offset', 'sort_by', 'sort_dir',
|
||||
'rating_min', 'rating_max', 'film_id',
|
||||
'from', 'to', 'search', 'tag',
|
||||
];
|
||||
for (const key of allowed) {
|
||||
if (params[key] !== undefined && params[key] !== '' && params[key] !== null) {
|
||||
qs.set(key, String(params[key]));
|
||||
}
|
||||
}
|
||||
const path = `/api/v1/images${qs.toString() ? '?' + qs.toString() : ''}`;
|
||||
return apiJson(path);
|
||||
}
|
||||
|
||||
export async function getImage(id) {
|
||||
return apiJson(`/api/v1/images/${id}`);
|
||||
}
|
||||
|
||||
export async function refreshImage(id) {
|
||||
return apiJson(`/api/v1/images/${id}/refresh`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function exportImage(id, { signal } = {}) {
|
||||
// 1. Enqueue the export (POST returns 202 immediately).
|
||||
await apiFetch(`/api/v1/images/${id}/export`, { method: 'POST', signal });
|
||||
|
||||
// 2. Poll GET /full until the JPEG is ready (up to 120 s).
|
||||
const settings = getSettings();
|
||||
const token = await getToken();
|
||||
const deadline = Date.now() + 120_000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (signal?.aborted) throw new ApiError(0, 'Cancelled');
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
if (signal?.aborted) throw new ApiError(0, 'Cancelled');
|
||||
|
||||
const res = await fetch(`${settings.baseUrl}/api/v1/images/${id}/full`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal,
|
||||
});
|
||||
|
||||
if (res.ok) return res.blob();
|
||||
if (res.status !== 202) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new ApiError(res.status, text || res.statusText);
|
||||
}
|
||||
// 202 → still generating, loop again
|
||||
}
|
||||
|
||||
throw new ApiError(504, 'Export timed out after 120 s');
|
||||
}
|
||||
|
||||
// ── Modules ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listModules(imageId) {
|
||||
return apiJson(`/api/v1/images/${imageId}/modules`);
|
||||
}
|
||||
|
||||
export async function getModule(imageId, op) {
|
||||
return apiJson(`/api/v1/images/${imageId}/modules/${op}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch a module and optionally request a preview render.
|
||||
* Returns { preview: blobUrl } when previewSize is set and the server responds
|
||||
* with image/jpeg, or { data: json } for plain JSON responses.
|
||||
*
|
||||
* The PATCH with preview can take up to 30 seconds.
|
||||
*/
|
||||
export async function patchModule(imageId, op, body, previewSize = null) {
|
||||
const settings = getSettings();
|
||||
if (!settings) throw new ApiError(0, 'Not configured.');
|
||||
|
||||
const token = await getToken();
|
||||
|
||||
let path = `/api/v1/images/${imageId}/modules/${op}`;
|
||||
if (previewSize) path += `?preview=${previewSize}`;
|
||||
|
||||
const url = `${settings.baseUrl}${path}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json, image/jpeg',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
// 35s timeout implemented via AbortController below
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new ApiError(res.status, text || res.statusText);
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('Content-Type') ?? '';
|
||||
if (contentType.includes('image/jpeg') || contentType.includes('image/')) {
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
return { previewUrl: url };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return { data };
|
||||
}
|
||||
|
||||
// ── Tags & Films ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listTags() {
|
||||
return apiJson('/api/v1/tags');
|
||||
}
|
||||
|
||||
export async function listFilms() {
|
||||
return apiJson('/api/v1/films');
|
||||
}
|
||||
|
||||
// ── Health ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function checkHealth(baseUrl) {
|
||||
const url = `${baseUrl}/health`;
|
||||
const res = await fetch(url, { method: 'GET' });
|
||||
if (!res.ok) throw new ApiError(res.status, `Health check failed: ${res.statusText}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the full connection: health check + token fetch.
|
||||
* Returns { ok: true } or throws ApiError.
|
||||
*/
|
||||
export async function testConnection(settings) {
|
||||
await checkHealth(settings.baseUrl);
|
||||
await fetchToken(settings);
|
||||
return { ok: true };
|
||||
}
|
||||
235
contrib/darktable-api/web/src/components/FilterBar.jsx
Normal file
235
contrib/darktable-api/web/src/components/FilterBar.jsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { listFilms, ApiError } from '../api.js';
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'datetime_taken:desc', label: 'Newest first' },
|
||||
{ value: 'datetime_taken:asc', label: 'Oldest first' },
|
||||
{ value: 'filename:asc', label: 'Filename A→Z' },
|
||||
{ value: 'filename:desc', label: 'Filename Z→A' },
|
||||
{ value: 'rating:desc', label: 'Top rated' },
|
||||
];
|
||||
|
||||
const STAR_OPTIONS = [
|
||||
{ value: '', label: 'Any rating' },
|
||||
{ value: '1', label: '★ and up' },
|
||||
{ value: '2', label: '★★ and up' },
|
||||
{ value: '3', label: '★★★ and up' },
|
||||
{ value: '4', label: '★★★★ and up' },
|
||||
{ value: '5', label: '★★★★★ only' },
|
||||
];
|
||||
|
||||
export default function FilterBar({ filters, onChange }) {
|
||||
const [films, setFilms] = useState([]);
|
||||
const [filmsError, setFilmsError] = useState(null);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const searchRef = useRef(null);
|
||||
const searchTimer = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
listFilms()
|
||||
.then(data => setFilms(Array.isArray(data) ? data : (data.items ?? [])))
|
||||
.catch(err => {
|
||||
if (err instanceof ApiError && err.status === 0) return; // not configured yet
|
||||
setFilmsError('Could not load film rolls');
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSearchChange = useCallback((e) => {
|
||||
const value = e.target.value;
|
||||
// Debounce search by 400ms
|
||||
clearTimeout(searchTimer.current);
|
||||
searchTimer.current = setTimeout(() => {
|
||||
onChange({ ...filters, search: value, offset: 0 });
|
||||
}, 400);
|
||||
}, [filters, onChange]);
|
||||
|
||||
const handleSortChange = useCallback((e) => {
|
||||
const [sort_by, sort_dir] = e.target.value.split(':');
|
||||
onChange({ ...filters, sort_by, sort_dir, offset: 0 });
|
||||
}, [filters, onChange]);
|
||||
|
||||
const handleRatingChange = useCallback((e) => {
|
||||
onChange({ ...filters, rating_min: e.target.value, offset: 0 });
|
||||
}, [filters, onChange]);
|
||||
|
||||
const handleFilmChange = useCallback((e) => {
|
||||
onChange({ ...filters, film_id: e.target.value, offset: 0 });
|
||||
}, [filters, onChange]);
|
||||
|
||||
const currentSort = `${filters.sort_by ?? 'datetime_taken'}:${filters.sort_dir ?? 'desc'}`;
|
||||
const activeFilterCount = [
|
||||
filters.search,
|
||||
filters.rating_min,
|
||||
filters.film_id,
|
||||
].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<div style={styles.root}>
|
||||
{/* Search row */}
|
||||
<div style={styles.searchRow}>
|
||||
<div style={styles.searchWrap}>
|
||||
<span style={styles.searchIcon}>⌕</span>
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
className="form-input"
|
||||
placeholder="Search images…"
|
||||
defaultValue={filters.search ?? ''}
|
||||
onChange={handleSearchChange}
|
||||
style={{ paddingLeft: 36, height: 40 }}
|
||||
aria-label="Search images"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{
|
||||
height: 40,
|
||||
padding: '0 12px',
|
||||
fontSize: '0.8125rem',
|
||||
gap: 6,
|
||||
color: expanded || activeFilterCount > 0 ? 'var(--color-accent)' : undefined,
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={() => setExpanded(x => !x)}
|
||||
aria-expanded={expanded}
|
||||
aria-label="Toggle filters"
|
||||
>
|
||||
<span>⚙</span>
|
||||
<span>Filters</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span style={styles.badge}>{activeFilterCount}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded filters */}
|
||||
{expanded && (
|
||||
<div style={styles.filterRow}>
|
||||
<div style={{ flex: 1, minWidth: 140 }}>
|
||||
<label className="form-label" style={{ marginBottom: 4, display: 'block', fontSize: '0.75rem' }}>
|
||||
Sort by
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
style={{ height: 38, fontSize: '0.875rem' }}
|
||||
value={currentSort}
|
||||
onChange={handleSortChange}
|
||||
aria-label="Sort order"
|
||||
>
|
||||
{SORT_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 130 }}>
|
||||
<label className="form-label" style={{ marginBottom: 4, display: 'block', fontSize: '0.75rem' }}>
|
||||
Min rating
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
style={{ height: 38, fontSize: '0.875rem' }}
|
||||
value={filters.rating_min ?? ''}
|
||||
onChange={handleRatingChange}
|
||||
aria-label="Minimum star rating"
|
||||
>
|
||||
{STAR_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{films.length > 0 && (
|
||||
<div style={{ flex: 1, minWidth: 150 }}>
|
||||
<label className="form-label" style={{ marginBottom: 4, display: 'block', fontSize: '0.75rem' }}>
|
||||
Film roll
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
style={{ height: 38, fontSize: '0.875rem' }}
|
||||
value={filters.film_id ?? ''}
|
||||
onChange={handleFilmChange}
|
||||
aria-label="Film roll"
|
||||
>
|
||||
<option value="">All rolls</option>
|
||||
{films.map(f => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.folder ?? f.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeFilterCount > 0 && (
|
||||
<div style={{ alignSelf: 'flex-end', paddingBottom: 0 }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ height: 38, padding: '0 12px', fontSize: '0.8125rem', color: 'var(--color-danger)' }}
|
||||
onClick={() => onChange({ ...filters, search: '', rating_min: '', film_id: '', offset: 0 })}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
root: {
|
||||
background: 'var(--color-surface)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
padding: '10px 12px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
position: 'sticky',
|
||||
top: 'var(--header-height)',
|
||||
zIndex: 'var(--z-filter)',
|
||||
},
|
||||
searchRow: {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
searchWrap: {
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
},
|
||||
searchIcon: {
|
||||
position: 'absolute',
|
||||
left: 10,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '1.1rem',
|
||||
pointerEvents: 'none',
|
||||
lineHeight: 1,
|
||||
},
|
||||
filterRow: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
alignItems: 'flex-end',
|
||||
paddingTop: 4,
|
||||
},
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
right: 4,
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
borderRadius: '9999px',
|
||||
background: 'var(--color-accent)',
|
||||
color: '#0d0d0d',
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: 700,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 3px',
|
||||
},
|
||||
};
|
||||
246
contrib/darktable-api/web/src/components/ImageCard.jsx
Normal file
246
contrib/darktable-api/web/src/components/ImageCard.jsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { getPreviewBlob, releaseBlobUrl } from '../api.js';
|
||||
|
||||
const COLOR_LABEL_COLORS = {
|
||||
red: '#e05252',
|
||||
yellow: '#f4c842',
|
||||
green: '#52c06e',
|
||||
blue: '#5299e0',
|
||||
purple: '#a052c0',
|
||||
};
|
||||
|
||||
function StarRating({ rating }) {
|
||||
if (!rating || rating < 1) return null;
|
||||
return (
|
||||
<div style={{
|
||||
color: 'var(--color-accent)',
|
||||
fontSize: '0.6875rem',
|
||||
lineHeight: 1,
|
||||
textShadow: '0 1px 3px rgba(0,0,0,0.8)',
|
||||
}}>
|
||||
{'★'.repeat(Math.min(5, rating))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorDots({ labels }) {
|
||||
if (!labels || labels.length === 0) return null;
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 3 }}>
|
||||
{labels.map(label => (
|
||||
<span
|
||||
key={label}
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: COLOR_LABEL_COLORS[label] ?? '#aaa',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
title={label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImageCard({ image, onClick }) {
|
||||
const [blobUrl, setBlobUrl] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const blobKeyRef = useRef(null);
|
||||
const isMounted = useRef(true);
|
||||
const retriesRef = useRef(0);
|
||||
const retryTimerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
retriesRef.current = 0;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setGenerating(false);
|
||||
|
||||
function loadPreview() {
|
||||
getPreviewBlob(image.id, 'thumb')
|
||||
.then((result) => {
|
||||
if (!isMounted.current) {
|
||||
if (result.key) releaseBlobUrl(result.key);
|
||||
return;
|
||||
}
|
||||
if (result.generating) {
|
||||
setLoading(false);
|
||||
setGenerating(true);
|
||||
if (retriesRef.current < 15) {
|
||||
retriesRef.current += 1;
|
||||
retryTimerRef.current = setTimeout(loadPreview, (result.retryAfter ?? 4) * 1000);
|
||||
} else {
|
||||
setGenerating(false);
|
||||
setError(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (blobKeyRef.current) releaseBlobUrl(blobKeyRef.current);
|
||||
blobKeyRef.current = result.key;
|
||||
setBlobUrl(result.url);
|
||||
setLoading(false);
|
||||
setGenerating(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMounted.current) {
|
||||
setLoading(false);
|
||||
setGenerating(false);
|
||||
setError(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadPreview();
|
||||
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
if (blobKeyRef.current) {
|
||||
releaseBlobUrl(blobKeyRef.current);
|
||||
blobKeyRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [image.id]);
|
||||
|
||||
// Compute aspect ratio for the thumbnail placeholder
|
||||
const aspect = image.width && image.height
|
||||
? Math.min(1.5, Math.max(0.6, image.height / image.width))
|
||||
: 1;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'block',
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'transform var(--transition-fast), box-shadow var(--transition-fast)',
|
||||
width: '100%',
|
||||
padding: 0,
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}
|
||||
aria-label={`Open ${image.filename}`}
|
||||
type="button"
|
||||
>
|
||||
{/* Thumbnail container — padding-bottom trick for aspect ratio */}
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
paddingBottom: `${aspect * 100}%`,
|
||||
background: 'var(--color-surface-2)',
|
||||
}}>
|
||||
{(loading || generating) && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
display: 'flex', flexDirection: 'column',
|
||||
alignItems: 'center', justifyContent: 'center',
|
||||
gap: 4,
|
||||
}}>
|
||||
<span className="spinner" />
|
||||
{generating && (
|
||||
<span style={{ fontSize: '0.625rem', color: 'var(--color-text-muted)', opacity: 0.7 }}>
|
||||
Generating…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{error && !loading && !generating && (
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<span style={{ fontSize: '1.5rem', opacity: 0.4 }}>⊘</span>
|
||||
</div>
|
||||
)}
|
||||
{blobUrl && !error && (
|
||||
<img
|
||||
src={blobUrl}
|
||||
alt={image.filename}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bottom overlay: stars + color dots */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
left: 4,
|
||||
right: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'space-between',
|
||||
gap: 4,
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<StarRating rating={image.rating} />
|
||||
<ColorDots labels={image.color_labels} />
|
||||
</div>
|
||||
|
||||
{/* RAW badge */}
|
||||
{image.is_raw && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
background: 'rgba(0,0,0,0.65)',
|
||||
color: 'var(--color-accent)',
|
||||
fontSize: '0.625rem',
|
||||
fontWeight: 700,
|
||||
padding: '1px 5px',
|
||||
borderRadius: 3,
|
||||
letterSpacing: '0.04em',
|
||||
}}>
|
||||
RAW
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Caption */}
|
||||
<div style={{ padding: '6px 8px', display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'block',
|
||||
}}>
|
||||
{image.filename}
|
||||
</span>
|
||||
{image.datetime_taken && (
|
||||
<span style={{ fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
|
||||
{formatDate(image.datetime_taken)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(dt) {
|
||||
if (!dt) return '';
|
||||
try {
|
||||
return new Date(dt).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return dt;
|
||||
}
|
||||
}
|
||||
71
contrib/darktable-api/web/src/components/ModuleList.jsx
Normal file
71
contrib/darktable-api/web/src/components/ModuleList.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { listModules, ApiError } from '../api.js';
|
||||
import ModulePanel from './ModulePanel.jsx';
|
||||
|
||||
export default function ModuleList({ imageId, onPreviewUpdate }) {
|
||||
const [modules, setModules] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setModules(null);
|
||||
|
||||
listModules(imageId)
|
||||
.then(data => {
|
||||
// API may return an array or { items: [] }
|
||||
const list = Array.isArray(data) ? data : (data.items ?? data.modules ?? []);
|
||||
setModules(list);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
const msg = err instanceof ApiError ? err.message : String(err);
|
||||
setError(msg);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [imageId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 32 }}>
|
||||
<span className="spinner spinner-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="error-banner" style={{ margin: '0 0 8px' }}>
|
||||
<span>⚠</span>
|
||||
<span>Modules: {error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!modules || modules.length === 0) {
|
||||
return (
|
||||
<div className="empty-state" style={{ padding: '24px' }}>
|
||||
<span className="empty-state-icon">◻</span>
|
||||
<p className="text-sm text-muted">No development modules found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<h3 style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)', marginBottom: 4 }}>
|
||||
Development modules ({modules.length})
|
||||
</h3>
|
||||
{modules.map((mod, idx) => (
|
||||
<ModulePanel
|
||||
key={`${mod.operation}-${mod.history_num ?? idx}`}
|
||||
imageId={imageId}
|
||||
module={mod}
|
||||
onPreviewUpdate={onPreviewUpdate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
401
contrib/darktable-api/web/src/components/ModulePanel.jsx
Normal file
401
contrib/darktable-api/web/src/components/ModulePanel.jsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react';
|
||||
import { patchModule, ApiError } from '../api.js';
|
||||
import { useToast } from './Toast.jsx';
|
||||
|
||||
// ── Known param schemas ───────────────────────────────────────────────────────
|
||||
// Provides type and range hints for well-known module parameters.
|
||||
// Fallback: treat everything as a generic number / string.
|
||||
|
||||
const MODULE_PARAMS = {
|
||||
exposure: {
|
||||
exposure: { type: 'float', min: -18, max: 18, step: 0.01, label: 'Exposure (EV)' },
|
||||
black: { type: 'float', min: -1, max: 1, step: 0.001, label: 'Black point' },
|
||||
mode: { type: 'int', label: 'Mode' },
|
||||
},
|
||||
temperature: {
|
||||
red: { type: 'float', min: 0.1, max: 8, step: 0.01, label: 'Red' },
|
||||
green: { type: 'float', min: 0.1, max: 8, step: 0.01, label: 'Green' },
|
||||
blue: { type: 'float', min: 0.1, max: 8, step: 0.01, label: 'Blue' },
|
||||
g2: { type: 'float', min: 0.1, max: 8, step: 0.01, label: 'Green2' },
|
||||
},
|
||||
filmicrgb: {
|
||||
grey_point_source: { type: 'float', min: 0.1, max: 36, step: 0.01, label: 'Grey point src (%)' },
|
||||
black_point_source: { type: 'float', min: -16, max: -0.1, step: 0.01, label: 'Black point src (EV)' },
|
||||
white_point_source: { type: 'float', min: 0.1, max: 16, step: 0.01, label: 'White point src (EV)' },
|
||||
contrast: { type: 'float', min: 0, max: 5, step: 0.01, label: 'Contrast' },
|
||||
saturation: { type: 'float', min: 0, max: 4, step: 0.01, label: 'Saturation' },
|
||||
balance: { type: 'float', min: -50, max: 50, step: 0.1, label: 'Shadows↔Highlights' },
|
||||
output_power: { type: 'float', min: 0, max: 10, step: 0.01, label: 'Output power' },
|
||||
latitude: { type: 'float', min: 0.01, max: 99, step: 0.1, label: 'Latitude (%)' },
|
||||
noise_level: { type: 'float', min: 0, max: 0.5, step: 0.001, label: 'Noise level' },
|
||||
},
|
||||
highlights: {
|
||||
mode: { type: 'int', label: 'Mode' },
|
||||
strength: { type: 'float', min: 0, max: 1, step: 0.01, label: 'Strength' },
|
||||
clip: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Clip' },
|
||||
noise_level: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Noise level' },
|
||||
iterations: { type: 'int', label: 'Iterations' },
|
||||
},
|
||||
channelmixerrgb: {
|
||||
temperature: { type: 'float', min: 1667, max: 25000, step: 10, label: 'Temperature (K)' },
|
||||
illuminant: { type: 'int', label: 'Illuminant' },
|
||||
adaptation: { type: 'int', label: 'Adaptation' },
|
||||
},
|
||||
flip: {
|
||||
orientation: { type: 'int', label: 'Orientation' },
|
||||
},
|
||||
gamma: {
|
||||
gamma: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Gamma' },
|
||||
linear: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Linear' },
|
||||
},
|
||||
rawprepare: {
|
||||
left: { type: 'int', label: 'Left crop (px)' },
|
||||
top: { type: 'int', label: 'Top crop (px)' },
|
||||
right: { type: 'int', label: 'Right crop (px)' },
|
||||
bottom: { type: 'int', label: 'Bottom crop (px)' },
|
||||
raw_white_point: { type: 'int', label: 'Raw white point' },
|
||||
},
|
||||
demosaic: {
|
||||
demosaicing_method: { type: 'int', label: 'Method' },
|
||||
green_eq: { type: 'int', label: 'Green equalization' },
|
||||
median_thrs: { type: 'float', min: 0, max: 1, step: 0.001, label: 'Median threshold' },
|
||||
},
|
||||
};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function inferType(key, value, opName) {
|
||||
const schema = MODULE_PARAMS[opName]?.[key];
|
||||
if (schema) return schema;
|
||||
|
||||
const numVal = Number(value);
|
||||
if (typeof value === 'boolean') return { type: 'bool' };
|
||||
if (!isNaN(numVal)) {
|
||||
const isFloat = String(value).includes('.') || (Number.isFinite(numVal) && !Number.isInteger(numVal));
|
||||
return isFloat
|
||||
? { type: 'float', min: -100, max: 100, step: 0.01, label: key }
|
||||
: { type: 'int', label: key };
|
||||
}
|
||||
return { type: 'string', label: key };
|
||||
}
|
||||
|
||||
// ── ParamField ────────────────────────────────────────────────────────────────
|
||||
|
||||
function ParamField({ paramKey, value, schema, onChange }) {
|
||||
const label = schema?.label ?? paramKey;
|
||||
|
||||
if (schema?.type === 'bool' || typeof value === 'boolean') {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, minHeight: 36 }}>
|
||||
<span style={{ fontSize: '0.875rem', color: 'var(--color-text-muted)' }}>{label}</span>
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!value}
|
||||
onChange={e => onChange(paramKey, e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (schema?.type === 'float' && schema.min !== undefined && schema.max !== undefined) {
|
||||
const numVal = Number(value);
|
||||
const displayVal = Number.isNaN(numVal) ? 0 : numVal;
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>{label}</span>
|
||||
<span style={{ fontSize: '0.8125rem', fontFamily: 'var(--font-mono)', color: 'var(--color-text)' }}>
|
||||
{displayVal.toFixed(schema.step < 0.01 ? 4 : schema.step < 0.1 ? 3 : 2)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
className="param-slider"
|
||||
min={schema.min}
|
||||
max={schema.max}
|
||||
step={schema.step ?? 0.01}
|
||||
value={displayVal}
|
||||
onChange={e => onChange(paramKey, parseFloat(e.target.value))}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ fontSize: '0.625rem', color: 'var(--color-text-faint)' }}>{schema.min}</span>
|
||||
<span style={{ fontSize: '0.625rem', color: 'var(--color-text-faint)' }}>{schema.max}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// int / string — text input
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minHeight: 36 }}>
|
||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', flex: 1 }}>{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
className="form-input"
|
||||
style={{ width: 90, height: 32, padding: '0 8px', fontSize: '0.8125rem' }}
|
||||
value={value ?? ''}
|
||||
step={schema?.type === 'float' ? (schema.step ?? 0.01) : 1}
|
||||
onChange={e => {
|
||||
const raw = e.target.value;
|
||||
const num = Number(raw);
|
||||
onChange(paramKey, Number.isNaN(num) ? raw : num);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ModulePanel ───────────────────────────────────────────────────────────────
|
||||
|
||||
function paramsReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'RESET':
|
||||
return { ...action.params };
|
||||
case 'SET':
|
||||
return { ...state, [action.key]: action.value };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export default function ModulePanel({ imageId, module: mod, onPreviewUpdate }) {
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [enabled, setEnabled] = useState(mod.enabled ?? true);
|
||||
const [params, dispatchParams] = useReducer(paramsReducer, mod.params ?? {});
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const [renderState, setRenderState] = useState(null); // null | 'rendering' | 'done' | 'error'
|
||||
const [previewUrl, setPreviewUrl] = useState(null);
|
||||
const previewUrlRef = useRef(null);
|
||||
|
||||
// Sync when module prop changes (e.g., after gallery refresh)
|
||||
useEffect(() => {
|
||||
setEnabled(mod.enabled ?? true);
|
||||
dispatchParams({ type: 'RESET', params: mod.params ?? {} });
|
||||
setDirty(false);
|
||||
}, [mod]);
|
||||
|
||||
// Cleanup preview blob URL on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function handleParamChange(key, value) {
|
||||
dispatchParams({ type: 'SET', key, value });
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
function handleEnabledChange(newEnabled) {
|
||||
setEnabled(newEnabled);
|
||||
setDirty(true);
|
||||
}
|
||||
|
||||
async function handleApply() {
|
||||
setApplying(true);
|
||||
setRenderState('rendering');
|
||||
|
||||
const body = {
|
||||
enabled,
|
||||
params,
|
||||
};
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 35_000);
|
||||
|
||||
try {
|
||||
const result = await patchModule(imageId, mod.operation, body, 'medium');
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (result.previewUrl) {
|
||||
// Revoke previous preview blob
|
||||
if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current);
|
||||
previewUrlRef.current = result.previewUrl;
|
||||
setPreviewUrl(result.previewUrl);
|
||||
onPreviewUpdate?.(result.previewUrl);
|
||||
setRenderState('done');
|
||||
toast(`${mod.operation}: applied`, 'success');
|
||||
} else {
|
||||
setRenderState('done');
|
||||
toast(`${mod.operation}: applied (no preview)`, 'success');
|
||||
}
|
||||
setDirty(false);
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err.name === 'AbortError') {
|
||||
setRenderState('error');
|
||||
toast(`${mod.operation}: render timed out`, 'error');
|
||||
} else {
|
||||
const msg = err instanceof ApiError ? err.message : String(err);
|
||||
setRenderState('error');
|
||||
toast(`${mod.operation}: ${msg}`, 'error');
|
||||
}
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
}
|
||||
|
||||
const paramEntries = Object.entries(params);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--color-surface-2)',
|
||||
}}>
|
||||
{/* Header row — always visible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '10px 14px',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
minHeight: 44,
|
||||
}}
|
||||
aria-expanded={open}
|
||||
>
|
||||
{/* Enabled toggle — stop propagation so clicking it doesn't toggle open */}
|
||||
<span
|
||||
role="none"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
handleEnabledChange(!enabled);
|
||||
}}
|
||||
style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}
|
||||
title={enabled ? 'Disable module' : 'Enable module'}
|
||||
>
|
||||
<label className="toggle" style={{ pointerEvents: 'none' }}>
|
||||
<input type="checkbox" readOnly checked={enabled} tabIndex={-1} />
|
||||
<span className="toggle-track" />
|
||||
</label>
|
||||
</span>
|
||||
|
||||
<span style={{ flex: 1, fontSize: '0.9rem', fontWeight: 500, color: enabled ? 'var(--color-text)' : 'var(--color-text-muted)' }}>
|
||||
{mod.multi_name ? `${mod.operation} · ${mod.multi_name}` : mod.operation}
|
||||
</span>
|
||||
|
||||
{dirty && (
|
||||
<span style={{
|
||||
fontSize: '0.6875rem',
|
||||
color: 'var(--color-accent)',
|
||||
background: 'var(--color-accent-dim)',
|
||||
padding: '1px 7px',
|
||||
borderRadius: '9999px',
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
modified
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text-faint)',
|
||||
transform: open ? 'rotate(180deg)' : 'rotate(0)',
|
||||
transition: 'transform var(--transition-fast)',
|
||||
display: 'inline-block',
|
||||
}}>
|
||||
▾
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Expanded body */}
|
||||
{open && (
|
||||
<div style={{ padding: '4px 14px 14px', display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ height: 1, background: 'var(--color-border)' }} />
|
||||
|
||||
{/* Module info row */}
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
{mod.history_num !== undefined && (
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-faint)' }}>
|
||||
history #{mod.history_num}
|
||||
</span>
|
||||
)}
|
||||
{mod.module_version !== undefined && (
|
||||
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-faint)' }}>
|
||||
v{mod.module_version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Param fields */}
|
||||
{paramEntries.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{paramEntries.map(([key, value]) => {
|
||||
const schema = inferType(key, value, mod.operation);
|
||||
return (
|
||||
<ParamField
|
||||
key={key}
|
||||
paramKey={key}
|
||||
value={value}
|
||||
schema={schema}
|
||||
onChange={handleParamChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-faint)' }}>
|
||||
No editable parameters
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Apply button + render state */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ width: '100%', height: 40, fontSize: '0.875rem' }}
|
||||
onClick={handleApply}
|
||||
disabled={applying || (!dirty && renderState !== null)}
|
||||
>
|
||||
{applying ? (
|
||||
<>
|
||||
<span className="spinner" style={{ width: 16, height: 16 }} />
|
||||
Rendering…
|
||||
</>
|
||||
) : 'Apply'}
|
||||
</button>
|
||||
|
||||
{renderState === 'rendering' && (
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', textAlign: 'center' }}>
|
||||
Waiting for preview render (up to 30 s)…
|
||||
</p>
|
||||
)}
|
||||
{renderState === 'error' && (
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--color-danger)', textAlign: 'center' }}>
|
||||
Render failed or timed out.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline preview after apply */}
|
||||
{previewUrl && renderState === 'done' && (
|
||||
<div style={{ borderRadius: 'var(--radius-md)', overflow: 'hidden', border: '1px solid var(--color-border)' }}>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Module preview"
|
||||
style={{ width: '100%', height: 'auto', display: 'block' }}
|
||||
/>
|
||||
<p style={{ fontSize: '0.6875rem', color: 'var(--color-text-faint)', textAlign: 'center', padding: '4px 0' }}>
|
||||
Preview after {mod.operation}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
contrib/darktable-api/web/src/components/Toast.jsx
Normal file
133
contrib/darktable-api/web/src/components/Toast.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { createContext, useCallback, useContext, useReducer, useRef } from 'react';
|
||||
|
||||
// ── Context ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const ToastContext = createContext(null);
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
function reducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'ADD':
|
||||
return [...state, action.toast];
|
||||
case 'REMOVE':
|
||||
return state.filter(t => t.id !== action.id);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }) {
|
||||
const [toasts, dispatch] = useReducer(reducer, []);
|
||||
const timers = useRef({});
|
||||
|
||||
const dismiss = useCallback((id) => {
|
||||
clearTimeout(timers.current[id]);
|
||||
delete timers.current[id];
|
||||
dispatch({ type: 'REMOVE', id });
|
||||
}, []);
|
||||
|
||||
const toast = useCallback((message, type = 'info', duration = 4000) => {
|
||||
const id = ++nextId;
|
||||
dispatch({ type: 'ADD', toast: { id, message, type } });
|
||||
if (duration > 0) {
|
||||
timers.current[id] = setTimeout(() => dismiss(id), duration);
|
||||
}
|
||||
return id;
|
||||
}, [dismiss]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toast, dismiss }}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} dismiss={dismiss} />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast must be used within ToastProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const ICONS = {
|
||||
info: 'ℹ',
|
||||
success: '✓',
|
||||
warning: '⚠',
|
||||
error: '✕',
|
||||
};
|
||||
|
||||
function ToastContainer({ toasts, dismiss }) {
|
||||
if (toasts.length === 0) return null;
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: 'max(80px, calc(env(safe-area-inset-bottom, 0px) + 80px))',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 'var(--z-toast)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
width: 'min(calc(100vw - 32px), 400px)',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
{toasts.map(t => (
|
||||
<ToastItem key={t.id} toast={t} dismiss={dismiss} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToastItem({ toast, dismiss }) {
|
||||
const colorMap = {
|
||||
info: 'var(--color-text)',
|
||||
success: 'var(--color-success)',
|
||||
warning: 'var(--color-warning)',
|
||||
error: 'var(--color-danger)',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '10px',
|
||||
padding: '12px 16px',
|
||||
background: 'var(--color-surface-2)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
fontSize: '0.9rem',
|
||||
pointerEvents: 'all',
|
||||
animation: 'toastIn 200ms ease',
|
||||
maxWidth: '100%',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onClick={() => dismiss(toast.id)}
|
||||
>
|
||||
<span style={{ color: colorMap[toast.type] ?? colorMap.info, fontSize: '1rem', flexShrink: 0, lineHeight: 1.5 }}>
|
||||
{ICONS[toast.type] ?? ICONS.info}
|
||||
</span>
|
||||
<span style={{ flex: 1, color: 'var(--color-text)', lineHeight: 1.5, wordBreak: 'break-word' }}>
|
||||
{toast.message}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Keyframe injected once
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateY(12px) scale(0.96); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
456
contrib/darktable-api/web/src/index.css
Normal file
456
contrib/darktable-api/web/src/index.css
Normal file
@@ -0,0 +1,456 @@
|
||||
/* ── CSS Custom Properties ───────────────────────────────────────────────── */
|
||||
:root {
|
||||
--color-bg: #0d0d0d;
|
||||
--color-surface: #1a1a1a;
|
||||
--color-surface-2: #242424;
|
||||
--color-surface-3: #2e2e2e;
|
||||
--color-border: #333333;
|
||||
--color-accent: #f4a22d;
|
||||
--color-accent-dim: rgba(244, 162, 45, 0.15);
|
||||
--color-accent-hover: #f8b84a;
|
||||
--color-text: #e0e0e0;
|
||||
--color-text-muted: #888888;
|
||||
--color-text-faint: #555555;
|
||||
--color-danger: #e05252;
|
||||
--color-success: #52c06e;
|
||||
--color-warning: #f4a22d;
|
||||
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.6);
|
||||
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.7);
|
||||
|
||||
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
|
||||
--tap-target: 44px;
|
||||
--header-height: 56px;
|
||||
--filter-height: 56px;
|
||||
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-med: 250ms ease;
|
||||
--transition-slow: 350ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
--z-header: 100;
|
||||
--z-filter: 90;
|
||||
--z-backdrop: 200;
|
||||
--z-sheet: 210;
|
||||
--z-toast: 300;
|
||||
}
|
||||
|
||||
/* ── Reset ───────────────────────────────────────────────────────────────── */
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
font-size: 16px;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* ── Typography ─────────────────────────────────────────────────────────── */
|
||||
h1 { font-size: 1.5rem; font-weight: 700; line-height: 1.2; }
|
||||
h2 { font-size: 1.25rem; font-weight: 600; line-height: 1.3; }
|
||||
h3 { font-size: 1rem; font-weight: 600; }
|
||||
h4 { font-size: 0.875rem; font-weight: 600; }
|
||||
|
||||
.text-sm { font-size: 0.875rem; }
|
||||
.text-xs { font-size: 0.75rem; }
|
||||
.text-muted { color: var(--color-text-muted); }
|
||||
.text-accent { color: var(--color-accent); }
|
||||
.text-mono { font-family: var(--font-mono); }
|
||||
|
||||
/* ── Utility classes ────────────────────────────────────────────────────── */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.flex { display: flex; }
|
||||
.flex-col { flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.justify-center { justify-content: center; }
|
||||
.gap-1 { gap: 4px; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.gap-4 { gap: 16px; }
|
||||
.flex-1 { flex: 1; }
|
||||
|
||||
.w-full { width: 100%; }
|
||||
.h-full { height: 100%; }
|
||||
|
||||
.overflow-hidden { overflow: hidden; }
|
||||
.overflow-auto { overflow: auto; }
|
||||
|
||||
/* ── Shared component styles ─────────────────────────────────────────────── */
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: var(--tap-target);
|
||||
padding: 0 18px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
transition: background-color var(--transition-fast), opacity var(--transition-fast);
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-accent);
|
||||
color: #0d0d0d;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--color-surface-3);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: var(--color-surface-2);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background-color: transparent;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background-color: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--color-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0;
|
||||
width: var(--tap-target);
|
||||
height: var(--tap-target);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* Form controls */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
height: var(--tap-target);
|
||||
padding: 0 12px;
|
||||
background-color: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9375rem;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-dim);
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: var(--color-text-faint);
|
||||
}
|
||||
|
||||
.form-select {
|
||||
height: var(--tap-target);
|
||||
padding: 0 32px 0 12px;
|
||||
background-color: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9375rem;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 12px center;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-dim);
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.card {
|
||||
background-color: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--color-border);
|
||||
border-top-color: var(--color-accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spinner-lg {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-color: var(--color-border);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Tag pill */
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.75rem;
|
||||
background-color: var(--color-surface-3);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Star display */
|
||||
.stars {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
color: var(--color-accent);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 48px 24px;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
font-size: 3rem;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(224, 82, 82, 0.12);
|
||||
border: 1px solid rgba(224, 82, 82, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
color: #f08080;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Toggle switch */
|
||||
.toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--color-surface-3);
|
||||
border-radius: var(--radius-full);
|
||||
border: 1px solid var(--color-border);
|
||||
transition: background-color var(--transition-fast), border-color var(--transition-fast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-track {
|
||||
background-color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.toggle-track::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-track::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Slider */
|
||||
.param-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-surface-3);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.param-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
cursor: pointer;
|
||||
border: 2px solid #0d0d0d;
|
||||
box-shadow: 0 0 0 2px rgba(244, 162, 45, 0.3);
|
||||
}
|
||||
|
||||
.param-slider::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
cursor: pointer;
|
||||
border: 2px solid #0d0d0d;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-surface-3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* Safe area insets for notched phones */
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
11
contrib/darktable-api/web/src/main.jsx
Normal file
11
contrib/darktable-api/web/src/main.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App.jsx';
|
||||
|
||||
const root = createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
368
contrib/darktable-api/web/src/screens/Gallery.jsx
Normal file
368
contrib/darktable-api/web/src/screens/Gallery.jsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { listImages, ApiError } from '../api.js';
|
||||
import { useApp } from '../App.jsx';
|
||||
import { useToast } from '../components/Toast.jsx';
|
||||
import FilterBar from '../components/FilterBar.jsx';
|
||||
import ImageCard from '../components/ImageCard.jsx';
|
||||
import ImageDetail from './ImageDetail.jsx';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
// ── State machine for the image list ─────────────────────────────────────────
|
||||
|
||||
const initialListState = {
|
||||
items: [],
|
||||
total: 0,
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
error: null,
|
||||
offset: 0,
|
||||
hasMore: true,
|
||||
};
|
||||
|
||||
function listReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'LOAD_START':
|
||||
return { ...state, loading: true, error: null };
|
||||
case 'LOAD_MORE_START':
|
||||
return { ...state, loadingMore: true, error: null };
|
||||
case 'LOAD_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
items: action.replace ? action.items : [...state.items, ...action.items],
|
||||
total: action.total,
|
||||
offset: action.offset + action.items.length,
|
||||
hasMore: action.offset + action.items.length < action.total,
|
||||
error: null,
|
||||
};
|
||||
case 'LOAD_ERROR':
|
||||
return { ...state, loading: false, loadingMore: false, error: action.error };
|
||||
case 'RESET':
|
||||
return { ...initialListState };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const defaultFilters = {
|
||||
sort_by: 'datetime_taken',
|
||||
sort_dir: 'desc',
|
||||
search: '',
|
||||
rating_min: '',
|
||||
film_id: '',
|
||||
};
|
||||
|
||||
// ── Pull-to-refresh ───────────────────────────────────────────────────────────
|
||||
|
||||
const PTR_THRESHOLD = 70; // px to trigger refresh
|
||||
|
||||
// ── Gallery ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Gallery() {
|
||||
const { goSettings } = useApp();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [listState, dispatch] = useReducer(listReducer, initialListState);
|
||||
const [filters, setFilters] = useState(defaultFilters);
|
||||
const [selectedImage, setSelectedImage] = useState(null);
|
||||
|
||||
// Pull-to-refresh state
|
||||
const [ptrY, setPtrY] = useState(0);
|
||||
const [ptrActive, setPtrActive] = useState(false);
|
||||
const touchStartY = useRef(null);
|
||||
const scrollRef = useRef(null);
|
||||
|
||||
// Sentinel element for IntersectionObserver (infinite scroll)
|
||||
const sentinelRef = useRef(null);
|
||||
const observerRef = useRef(null);
|
||||
|
||||
// Current active fetch abort controller
|
||||
const fetchControllerRef = useRef(null);
|
||||
|
||||
// ── Data loading ────────────────────────────────────────────────────────────
|
||||
|
||||
const loadPage = useCallback(async (currentOffset, replace) => {
|
||||
// Cancel any previous fetch
|
||||
if (fetchControllerRef.current) fetchControllerRef.current.abort();
|
||||
const controller = new AbortController();
|
||||
fetchControllerRef.current = controller;
|
||||
|
||||
dispatch({ type: replace ? 'LOAD_START' : 'LOAD_MORE_START' });
|
||||
|
||||
try {
|
||||
const params = {
|
||||
limit: PAGE_SIZE,
|
||||
offset: currentOffset,
|
||||
sort_by: filters.sort_by,
|
||||
sort_dir: filters.sort_dir,
|
||||
};
|
||||
if (filters.search) params.search = filters.search;
|
||||
if (filters.rating_min) params.rating_min = filters.rating_min;
|
||||
if (filters.film_id) params.film_id = filters.film_id;
|
||||
|
||||
const data = await listImages(params);
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
const items = Array.isArray(data) ? data : (data.items ?? []);
|
||||
const total = data.total ?? items.length;
|
||||
|
||||
dispatch({
|
||||
type: 'LOAD_SUCCESS',
|
||||
items,
|
||||
total,
|
||||
offset: currentOffset,
|
||||
replace,
|
||||
});
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
const msg = err instanceof ApiError ? err.message : String(err);
|
||||
dispatch({ type: 'LOAD_ERROR', error: msg });
|
||||
toast(msg, 'error');
|
||||
}
|
||||
}, [filters, toast]);
|
||||
|
||||
// Initial load and when filters change
|
||||
useEffect(() => {
|
||||
dispatch({ type: 'RESET' });
|
||||
loadPage(0, true);
|
||||
// Scroll to top
|
||||
scrollRef.current?.scrollTo({ top: 0 });
|
||||
}, [filters]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
// Note: we intentionally omit loadPage from deps to avoid double-fires;
|
||||
// filters change triggers reset + load, which is correct.
|
||||
|
||||
// ── IntersectionObserver for infinite scroll ────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
observerRef.current?.disconnect();
|
||||
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (
|
||||
entries[0].isIntersecting &&
|
||||
!listState.loading &&
|
||||
!listState.loadingMore &&
|
||||
listState.hasMore
|
||||
) {
|
||||
loadPage(listState.offset, false);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '200px' }
|
||||
);
|
||||
|
||||
if (sentinelRef.current) {
|
||||
observerRef.current.observe(sentinelRef.current);
|
||||
}
|
||||
|
||||
return () => observerRef.current?.disconnect();
|
||||
}, [listState.loading, listState.loadingMore, listState.hasMore, listState.offset, loadPage]);
|
||||
|
||||
// ── Pull-to-refresh touch handlers ─────────────────────────────────────────
|
||||
|
||||
function handleTouchStart(e) {
|
||||
if (scrollRef.current?.scrollTop === 0) {
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchMove(e) {
|
||||
if (touchStartY.current === null) return;
|
||||
const dy = e.touches[0].clientY - touchStartY.current;
|
||||
if (dy > 0) {
|
||||
setPtrY(Math.min(dy * 0.5, PTR_THRESHOLD + 20));
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchEnd() {
|
||||
if (ptrY >= PTR_THRESHOLD) {
|
||||
setPtrActive(true);
|
||||
dispatch({ type: 'RESET' });
|
||||
loadPage(0, true).finally(() => {
|
||||
setPtrActive(false);
|
||||
setPtrY(0);
|
||||
});
|
||||
} else {
|
||||
setPtrY(0);
|
||||
}
|
||||
touchStartY.current = null;
|
||||
}
|
||||
|
||||
// ── Filter handler ──────────────────────────────────────────────────────────
|
||||
|
||||
const handleFiltersChange = useCallback((newFilters) => {
|
||||
setFilters(newFilters);
|
||||
}, []);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const showEmpty = !listState.loading && !listState.error && listState.items.length === 0;
|
||||
const showError = !!listState.error && listState.items.length === 0;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* Fixed header */}
|
||||
<header style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 12px',
|
||||
height: 'var(--header-height)',
|
||||
background: 'var(--color-surface)',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
flexShrink: 0,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 'var(--z-header)',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<img src="/icon.svg" alt="" style={{ width: 28, height: 28, borderRadius: 6 }} />
|
||||
<span style={{ fontWeight: 700, fontSize: '1rem', color: 'var(--color-accent)' }}>
|
||||
darktable
|
||||
</span>
|
||||
{listState.total > 0 && (
|
||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>
|
||||
{listState.total.toLocaleString()} images
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-icon"
|
||||
onClick={goSettings}
|
||||
aria-label="Settings"
|
||||
title="Settings"
|
||||
style={{ fontSize: '1.25rem' }}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* Filter bar */}
|
||||
<FilterBar filters={filters} onChange={handleFiltersChange} />
|
||||
|
||||
{/* Scrollable gallery area */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={{ flex: 1, overflowY: 'auto', overscrollBehavior: 'contain', position: 'relative' }}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Pull-to-refresh indicator */}
|
||||
{ptrY > 10 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: Math.min(ptrY, PTR_THRESHOLD + 20),
|
||||
color: ptrY >= PTR_THRESHOLD ? 'var(--color-accent)' : 'var(--color-text-muted)',
|
||||
fontSize: '0.875rem',
|
||||
gap: 8,
|
||||
transition: ptrActive ? 'height var(--transition-med)' : 'none',
|
||||
}}>
|
||||
{ptrActive
|
||||
? <><span className="spinner" style={{ width: 16, height: 16 }} /> Refreshing…</>
|
||||
: (ptrY >= PTR_THRESHOLD ? '↑ Release to refresh' : '↓ Pull to refresh')
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Initial loading */}
|
||||
{listState.loading && listState.items.length === 0 && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 64, gap: 12 }}>
|
||||
<span className="spinner spinner-lg" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error (no items) */}
|
||||
{showError && (
|
||||
<div style={{ padding: '24px 16px' }}>
|
||||
<div className="error-banner">
|
||||
<span>⚠</span>
|
||||
<div>
|
||||
<p>{listState.error}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: 10, height: 36, fontSize: '0.875rem' }}
|
||||
onClick={() => loadPage(0, true)}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{showEmpty && (
|
||||
<div className="empty-state">
|
||||
<span className="empty-state-icon">◻</span>
|
||||
<p>No images found</p>
|
||||
<p className="text-sm text-muted">Try adjusting your filters</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image grid */}
|
||||
{listState.items.length > 0 && (
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: 6,
|
||||
padding: 6,
|
||||
}}>
|
||||
{listState.items.map(img => (
|
||||
<ImageCard
|
||||
key={img.id}
|
||||
image={img}
|
||||
onClick={() => setSelectedImage(img)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Infinite scroll sentinel */}
|
||||
<div
|
||||
ref={sentinelRef}
|
||||
style={{ height: 1 }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Load-more spinner */}
|
||||
{listState.loadingMore && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '20px 0' }}>
|
||||
<span className="spinner" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End-of-list indicator */}
|
||||
{!listState.hasMore && listState.items.length > 0 && (
|
||||
<p style={{
|
||||
textAlign: 'center',
|
||||
padding: '16px 0',
|
||||
fontSize: '0.8125rem',
|
||||
color: 'var(--color-text-faint)',
|
||||
}}>
|
||||
All {listState.total.toLocaleString()} images loaded
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image detail bottom sheet */}
|
||||
{selectedImage && (
|
||||
<ImageDetail
|
||||
image={selectedImage}
|
||||
onClose={() => setSelectedImage(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
443
contrib/darktable-api/web/src/screens/ImageDetail.jsx
Normal file
443
contrib/darktable-api/web/src/screens/ImageDetail.jsx
Normal file
@@ -0,0 +1,443 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { getPreviewBlob, releaseBlobUrl, refreshImage, exportImage, ApiError } from '../api.js';
|
||||
import { useToast } from '../components/Toast.jsx';
|
||||
import ModuleList from '../components/ModuleList.jsx';
|
||||
|
||||
const PREVIEW_SIZES = ['thumb', 'small', 'medium', 'large', 'xlarge'];
|
||||
|
||||
// ── EXIF metadata rows ────────────────────────────────────────────────────────
|
||||
|
||||
function MetaRow({ label, value }) {
|
||||
if (!value && value !== 0) return null;
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 8, padding: '5px 0' }}>
|
||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', minWidth: 120, flexShrink: 0 }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text)', wordBreak: 'break-word' }}>
|
||||
{String(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Preview area ──────────────────────────────────────────────────────────────
|
||||
|
||||
function PreviewImage({ imageId, size, onModulePreview, modulePreviewUrl }) {
|
||||
const [blobUrl, setBlobUrl] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const blobKeyRef = useRef(null);
|
||||
const isMounted = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
|
||||
getPreviewBlob(imageId, size)
|
||||
.then(({ url, key }) => {
|
||||
if (!isMounted.current) { releaseBlobUrl(key); return; }
|
||||
if (blobKeyRef.current) releaseBlobUrl(blobKeyRef.current);
|
||||
blobKeyRef.current = key;
|
||||
setBlobUrl(url);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isMounted.current) { setLoading(false); setError(true); }
|
||||
});
|
||||
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
if (blobKeyRef.current) { releaseBlobUrl(blobKeyRef.current); blobKeyRef.current = null; }
|
||||
};
|
||||
}, [imageId, size]);
|
||||
|
||||
const displayUrl = modulePreviewUrl ?? blobUrl;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
minHeight: 200,
|
||||
background: '#111',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{loading && !displayUrl && (
|
||||
<span className="spinner spinner-lg" />
|
||||
)}
|
||||
{error && !displayUrl && (
|
||||
<div style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>
|
||||
<div style={{ fontSize: '2rem', marginBottom: 8 }}>⊘</div>
|
||||
<p className="text-sm">Preview unavailable</p>
|
||||
</div>
|
||||
)}
|
||||
{displayUrl && (
|
||||
<img
|
||||
src={displayUrl}
|
||||
alt="Image preview"
|
||||
style={{ maxWidth: '100%', maxHeight: '60vh', objectFit: 'contain', display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
{modulePreviewUrl && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 8, right: 8,
|
||||
background: 'rgba(244, 162, 45, 0.9)',
|
||||
color: '#000',
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: 700,
|
||||
padding: '2px 8px',
|
||||
borderRadius: '9999px',
|
||||
}}>
|
||||
MODULE PREVIEW
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ImageDetail({ image, onClose }) {
|
||||
const { toast } = useToast();
|
||||
const [previewSizeIdx, setPreviewSizeIdx] = useState(2); // 'medium'
|
||||
const [tab, setTab] = useState('info'); // 'info' | 'modules'
|
||||
const [actionBusy, setActionBusy] = useState(null); // 'refresh' | 'export' | null
|
||||
const [modulePreviewUrl, setModulePreviewUrl] = useState(null);
|
||||
const modulePreviewUrlRef = useRef(null);
|
||||
|
||||
// Animate in
|
||||
const [visible, setVisible] = useState(false);
|
||||
useEffect(() => {
|
||||
const t = requestAnimationFrame(() => setVisible(true));
|
||||
return () => cancelAnimationFrame(t);
|
||||
}, []);
|
||||
|
||||
// Cleanup module preview blob
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (modulePreviewUrlRef.current) URL.revokeObjectURL(modulePreviewUrlRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
function cyclePreviewSize() {
|
||||
setPreviewSizeIdx(i => (i + 1) % PREVIEW_SIZES.length);
|
||||
setModulePreviewUrl(null); // clear module preview when switching size
|
||||
}
|
||||
|
||||
function handleModulePreview(url) {
|
||||
if (modulePreviewUrlRef.current) URL.revokeObjectURL(modulePreviewUrlRef.current);
|
||||
modulePreviewUrlRef.current = url;
|
||||
setModulePreviewUrl(url);
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
setActionBusy('refresh');
|
||||
try {
|
||||
await refreshImage(image.id);
|
||||
toast('Thumbnails regenerated', 'success');
|
||||
} catch (err) {
|
||||
toast(err instanceof ApiError ? err.message : String(err), 'error');
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
const exportAbortRef = useRef(null);
|
||||
|
||||
async function handleExport() {
|
||||
// Cancel any in-progress export if the button is clicked again.
|
||||
exportAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
exportAbortRef.current = controller;
|
||||
|
||||
setActionBusy('export');
|
||||
try {
|
||||
const blob = await exportImage(image.id, { signal: controller.signal });
|
||||
const stem = image.filename?.replace(/\.[^.]+$/, '') ?? 'export';
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${stem}.jpg`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast('Export downloaded', 'success');
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.message === 'Cancelled') return;
|
||||
toast(e instanceof ApiError ? e.message : String(e), 'error');
|
||||
} finally {
|
||||
setActionBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackdropClick(e) {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}
|
||||
|
||||
const currentSize = PREVIEW_SIZES[previewSizeIdx];
|
||||
|
||||
const meta = image.metadata ?? {};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
onClick={handleBackdropClick}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.7)',
|
||||
zIndex: 'var(--z-backdrop)',
|
||||
transition: 'opacity var(--transition-slow)',
|
||||
opacity: visible ? 1 : 0,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Bottom sheet */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={image.filename}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 'var(--z-sheet)',
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: 'var(--radius-xl) var(--radius-xl) 0 0',
|
||||
boxShadow: 'var(--shadow-lg)',
|
||||
maxHeight: '92dvh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
transform: visible ? 'translateY(0)' : 'translateY(100%)',
|
||||
transition: 'transform var(--transition-slow)',
|
||||
overflowY: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '12px 0 4px' }}>
|
||||
<div style={{
|
||||
width: 40, height: 4,
|
||||
borderRadius: 2,
|
||||
background: 'var(--color-border)',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Sheet header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '4px 16px 12px',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
gap: 12,
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<h2 style={{
|
||||
fontSize: '1rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{image.filename}
|
||||
</h2>
|
||||
{image.path && (
|
||||
<p style={{
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--color-text-muted)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
marginTop: 2,
|
||||
}}>
|
||||
{image.path}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-icon"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', overscrollBehavior: 'contain' }}>
|
||||
{/* Preview image — tap to cycle sizes */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={cyclePreviewSize}
|
||||
title={`Preview size: ${currentSize} (tap to cycle)`}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
background: '#0a0a0a',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<PreviewImage
|
||||
imageId={image.id}
|
||||
size={currentSize}
|
||||
modulePreviewUrl={modulePreviewUrl}
|
||||
/>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
background: 'rgba(0,0,0,0.65)',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: '0.6875rem',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '9999px',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
{currentSize} · tap to cycle
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: 'flex', gap: 10, padding: '12px 16px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1, height: 40, fontSize: '0.875rem' }}
|
||||
onClick={handleRefresh}
|
||||
disabled={actionBusy !== null}
|
||||
>
|
||||
{actionBusy === 'refresh'
|
||||
? <><span className="spinner" style={{ width: 14, height: 14 }} /> Refreshing…</>
|
||||
: '↺ Refresh'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1, height: 40, fontSize: '0.875rem' }}
|
||||
onClick={handleExport}
|
||||
disabled={actionBusy !== null}
|
||||
>
|
||||
{actionBusy === 'export'
|
||||
? <><span className="spinner" style={{ width: 14, height: 14 }} /> Exporting…</>
|
||||
: '⬇ Export'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
borderBottom: '1px solid var(--color-border)',
|
||||
padding: '0 16px',
|
||||
gap: 4,
|
||||
}}>
|
||||
{['info', 'modules'].map(t => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTab(t)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderBottom: `2px solid ${tab === t ? 'var(--color-accent)' : 'transparent'}`,
|
||||
padding: '10px 16px',
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: tab === t ? 600 : 400,
|
||||
color: tab === t ? 'var(--color-accent)' : 'var(--color-text-muted)',
|
||||
cursor: 'pointer',
|
||||
transition: 'color var(--transition-fast), border-color var(--transition-fast)',
|
||||
minHeight: 44,
|
||||
}}
|
||||
>
|
||||
{t === 'info' ? 'Info' : 'Modules'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div style={{ padding: '16px' }}>
|
||||
{tab === 'info' && (
|
||||
<InfoTab image={image} meta={meta} />
|
||||
)}
|
||||
{tab === 'modules' && (
|
||||
<ModuleList
|
||||
imageId={image.id}
|
||||
onPreviewUpdate={handleModulePreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Safe area bottom padding */}
|
||||
<div style={{ height: 'max(16px, env(safe-area-inset-bottom, 16px))' }} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Info tab ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function InfoTab({ image, meta }) {
|
||||
const stars = image.rating > 0 ? '★'.repeat(image.rating) : '—';
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Tags */}
|
||||
{image.tags && image.tags.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, paddingBottom: 12 }}>
|
||||
{image.tags.map(tag => (
|
||||
<span key={tag} className="tag-pill">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ height: 1, background: 'var(--color-border)', margin: '4px 0 8px' }} />
|
||||
|
||||
<MetaRow label="Rating" value={stars} />
|
||||
<MetaRow label="Date taken" value={image.datetime_taken} />
|
||||
<MetaRow label="Dimensions" value={image.width && image.height ? `${image.width} × ${image.height}` : null} />
|
||||
<MetaRow label="Camera" value={[image.maker, image.model].filter(Boolean).join(' ')} />
|
||||
<MetaRow label="Type" value={image.is_raw ? 'RAW' : 'JPEG/other'} />
|
||||
<MetaRow label="Film roll" value={image.film_name ?? image.film_id} />
|
||||
|
||||
{/* Color labels */}
|
||||
{image.color_labels && image.color_labels.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 8, padding: '5px 0', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', minWidth: 120 }}>
|
||||
Color labels
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{image.color_labels.map(l => (
|
||||
<span key={l} className="tag-pill" style={{ textTransform: 'capitalize' }}>{l}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extra metadata fields from image.metadata object */}
|
||||
{Object.keys(meta).length > 0 && (
|
||||
<>
|
||||
<div style={{ height: 1, background: 'var(--color-border)', margin: '8px 0 4px' }} />
|
||||
<p style={{ fontSize: '0.75rem', color: 'var(--color-text-faint)', marginBottom: 4 }}>
|
||||
Additional metadata
|
||||
</p>
|
||||
{Object.entries(meta).map(([key, value]) => (
|
||||
<MetaRow key={key} label={key} value={value} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
contrib/darktable-api/web/src/screens/Settings.jsx
Normal file
267
contrib/darktable-api/web/src/screens/Settings.jsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import React, { useEffect, useReducer } from 'react';
|
||||
import { useApp } from '../App.jsx';
|
||||
import { getSettings, saveSettings, testConnection, ApiError } from '../api.js';
|
||||
import { useToast } from '../components/Toast.jsx';
|
||||
|
||||
const initialForm = {
|
||||
baseUrl: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
};
|
||||
|
||||
function formReducer(state, action) {
|
||||
switch (action.type) {
|
||||
case 'SET_FIELD':
|
||||
return { ...state, [action.field]: action.value };
|
||||
case 'LOAD':
|
||||
return { ...action.data };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const statusIdle = { kind: 'idle' };
|
||||
const statusTesting = { kind: 'testing' };
|
||||
const statusOk = { kind: 'ok' };
|
||||
const statusErr = (msg) => ({ kind: 'error', msg });
|
||||
|
||||
export default function Settings() {
|
||||
const { goGallery } = useApp();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [form, dispatchForm] = useReducer(formReducer, initialForm);
|
||||
const [status, setStatus] = React.useState(statusIdle);
|
||||
|
||||
useEffect(() => {
|
||||
const s = getSettings();
|
||||
if (s) {
|
||||
dispatchForm({
|
||||
type: 'LOAD',
|
||||
data: {
|
||||
baseUrl: s.baseUrl ?? '',
|
||||
clientId: s.clientId ?? '',
|
||||
clientSecret: s.clientSecret ?? '',
|
||||
},
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
function setField(field) {
|
||||
return (e) => dispatchForm({ type: 'SET_FIELD', field, value: e.target.value });
|
||||
}
|
||||
|
||||
async function handleTest(e) {
|
||||
e.preventDefault();
|
||||
setStatus(statusTesting);
|
||||
|
||||
const settings = {
|
||||
baseUrl: form.baseUrl.replace(/\/$/, ''),
|
||||
clientId: form.clientId.trim(),
|
||||
clientSecret: form.clientSecret.trim(),
|
||||
};
|
||||
|
||||
try {
|
||||
await testConnection(settings);
|
||||
setStatus(statusOk);
|
||||
toast('Connection successful', 'success');
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : String(err);
|
||||
setStatus(statusErr(msg));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(e) {
|
||||
e.preventDefault();
|
||||
setStatus(statusTesting);
|
||||
|
||||
const settings = {
|
||||
baseUrl: form.baseUrl.replace(/\/$/, ''),
|
||||
clientId: form.clientId.trim(),
|
||||
clientSecret: form.clientSecret.trim(),
|
||||
};
|
||||
|
||||
try {
|
||||
await testConnection(settings);
|
||||
saveSettings(settings);
|
||||
setStatus(statusOk);
|
||||
toast('Settings saved', 'success');
|
||||
goGallery();
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : String(err);
|
||||
setStatus(statusErr(msg));
|
||||
}
|
||||
}
|
||||
|
||||
const isBusy = status.kind === 'testing';
|
||||
const isValid = form.baseUrl.trim() && form.clientId.trim() && form.clientSecret.trim();
|
||||
|
||||
return (
|
||||
<div style={styles.root}>
|
||||
{/* Header */}
|
||||
<header style={styles.header}>
|
||||
<div style={styles.logoRow}>
|
||||
<img src="/icon.svg" alt="" style={styles.logo} />
|
||||
<div>
|
||||
<h1 style={{ fontSize: '1.25rem' }}>darktable Gallery</h1>
|
||||
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginTop: 2 }}>
|
||||
API connection settings
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Form */}
|
||||
<main style={styles.main}>
|
||||
<form onSubmit={handleSave} style={styles.form} noValidate>
|
||||
<div className="card" style={{ padding: '24px', display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="baseUrl">
|
||||
API Base URL
|
||||
</label>
|
||||
<input
|
||||
id="baseUrl"
|
||||
className="form-input"
|
||||
type="url"
|
||||
placeholder="http://localhost:8080"
|
||||
value={form.baseUrl}
|
||||
onChange={setField('baseUrl')}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="text-xs text-muted">
|
||||
Leave empty to use same-origin (when served by the Lua server)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="clientId">
|
||||
Client ID
|
||||
</label>
|
||||
<input
|
||||
id="clientId"
|
||||
className="form-input"
|
||||
type="text"
|
||||
placeholder="my-client"
|
||||
value={form.clientId}
|
||||
onChange={setField('clientId')}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label" htmlFor="clientSecret">
|
||||
Client Secret
|
||||
</label>
|
||||
<input
|
||||
id="clientSecret"
|
||||
className="form-input"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={form.clientSecret}
|
||||
onChange={setField('clientSecret')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status.kind === 'error' && (
|
||||
<div className="error-banner">
|
||||
<span>⚠</span>
|
||||
<span>{status.msg}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.kind === 'ok' && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '12px 16px',
|
||||
background: 'rgba(82, 192, 110, 0.1)',
|
||||
border: '1px solid rgba(82, 192, 110, 0.3)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
color: 'var(--color-success)',
|
||||
fontSize: '0.875rem',
|
||||
}}>
|
||||
<span>✓</span>
|
||||
<span>Connection verified</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={handleTest}
|
||||
disabled={isBusy || !isValid}
|
||||
>
|
||||
{isBusy ? (
|
||||
<>
|
||||
<span className="spinner" style={{ width: 16, height: 16 }} />
|
||||
Testing…
|
||||
</>
|
||||
) : 'Test Connection'}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
disabled={isBusy || !isValid}
|
||||
>
|
||||
{isBusy ? (
|
||||
<>
|
||||
<span className="spinner" style={{ width: 16, height: 16 }} />
|
||||
Saving…
|
||||
</>
|
||||
) : 'Save & Connect'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted" style={{ textAlign: 'center', lineHeight: 1.6 }}>
|
||||
Credentials are stored only in your browser's localStorage and
|
||||
are never sent anywhere except the configured API URL.
|
||||
</p>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
root: {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
},
|
||||
header: {
|
||||
padding: '24px 20px 0',
|
||||
},
|
||||
logoRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
marginBottom: 32,
|
||||
},
|
||||
logo: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
},
|
||||
main: {
|
||||
flex: 1,
|
||||
padding: '0 20px 32px',
|
||||
maxWidth: 480,
|
||||
width: '100%',
|
||||
margin: '0 auto',
|
||||
},
|
||||
form: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 20,
|
||||
},
|
||||
};
|
||||
81
contrib/darktable-api/web/vite.config.js
Normal file
81
contrib/darktable-api/web/vite.config.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
root: '.',
|
||||
build: {
|
||||
outDir: 'build',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /^.*\/api\/v1\/images\?/,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-images-list',
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 5,
|
||||
},
|
||||
networkTimeoutSeconds: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /^.*\/api\/v1\/images\/\d+\/preview/,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'image-previews',
|
||||
expiration: {
|
||||
maxEntries: 500,
|
||||
maxAgeSeconds: 60 * 60 * 24,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /^.*\/api\/v1\/(tags|films)/,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-metadata',
|
||||
expiration: {
|
||||
maxEntries: 20,
|
||||
maxAgeSeconds: 60 * 10,
|
||||
},
|
||||
networkTimeoutSeconds: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: {
|
||||
name: 'darktable Gallery',
|
||||
short_name: 'darktable',
|
||||
description: 'darktable REST API web interface',
|
||||
theme_color: '#0d0d0d',
|
||||
background_color: '#0d0d0d',
|
||||
display: 'standalone',
|
||||
orientation: 'any',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{
|
||||
src: 'icon.svg',
|
||||
sizes: 'any',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
334
contrib/dbmaint.lua
Normal file
334
contrib/dbmaint.lua
Normal file
@@ -0,0 +1,334 @@
|
||||
--[[
|
||||
|
||||
dbmaint.lua - perform database maintenance
|
||||
|
||||
Copyright (C) 2024 Bill Ferguson <wpferguson@gamil.com>.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
dbmaint - perform database maintenance
|
||||
|
||||
Perform database maintenance to clean up missing images and filmstrips.
|
||||
|
||||
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
None
|
||||
|
||||
USAGE
|
||||
* start dbmaint from script_manager
|
||||
* scan for missing film rolls or missing images
|
||||
* look at the results and choose to delete or not
|
||||
|
||||
BUGS, COMMENTS, SUGGESTIONS
|
||||
Bill Ferguson <wpferguson@gamil.com>
|
||||
|
||||
CHANGES
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
local log = require "lib/dtutils.log"
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- C O N S T A N T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local MODULE <const> = "dbmaint"
|
||||
local DEFAULT_LOG_LEVEL <const> = log.error
|
||||
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- A P I C H E C K
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
du.check_min_api_version("7.0.0", MODULE) -- choose the minimum version that contains the features you need
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- I 1 8 N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- S C R I P T M A N A G E R I N T E G R A T I O N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("db maintenance"),
|
||||
purpose = _("perform database maintenance"),
|
||||
author = "Bill Ferguson <wpferguson@gamil.com>",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/dbmaint/"
|
||||
}
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- L O G L E V E L
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
log.log_level(DEFAULT_LOG_LEVEL)
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- N A M E S P A C E
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local dbmaint = {}
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- G L O B A L V A R I A B L E S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
dbmaint.main_widget = nil
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- A L I A S E S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local namespace = dbmaint
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- F U N C T I O N S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
-------------------
|
||||
-- helper functions
|
||||
-------------------
|
||||
|
||||
local function set_log_level(level)
|
||||
local old_log_level = log.log_level()
|
||||
log.log_level(level)
|
||||
return old_log_level
|
||||
end
|
||||
|
||||
local function restore_log_level(level)
|
||||
log.log_level(level)
|
||||
end
|
||||
|
||||
local function scan_film_rolls()
|
||||
local missing_films = {}
|
||||
|
||||
for _, filmroll in ipairs(dt.films) do
|
||||
if not df.check_if_file_exists(filmroll.path) then
|
||||
table.insert(missing_films, filmroll)
|
||||
end
|
||||
end
|
||||
|
||||
return missing_films
|
||||
end
|
||||
|
||||
local function scan_images(film)
|
||||
local old_log_level = set_log_level(DEFAULT_LOG_LEVEL)
|
||||
local missing_images = {}
|
||||
|
||||
if film then
|
||||
for i = 1, #film do
|
||||
local image = film[i]
|
||||
log.msg(log.debug, "checking " .. image.filename)
|
||||
if not df.check_if_file_exists(image.path .. PS .. image.filename) then
|
||||
log.msg(log.info, image.filename .. " not found")
|
||||
table.insert(missing_images, image)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
restore_log_level(old_log_level)
|
||||
return missing_images
|
||||
end
|
||||
|
||||
local function remove_missing_film_rolls(list)
|
||||
for _, filmroll in ipairs(list) do
|
||||
filmroll:delete(true)
|
||||
end
|
||||
end
|
||||
|
||||
-- force the lighttable to reload
|
||||
|
||||
local function refresh_lighttable(film)
|
||||
local rules = dt.gui.libs.collect.filter()
|
||||
dt.gui.libs.collect.filter(rules)
|
||||
end
|
||||
|
||||
local function remove_missing_images(list)
|
||||
local film = list[1].film
|
||||
for _, image in ipairs(list) do
|
||||
image:delete()
|
||||
end
|
||||
refresh_lighttable(film)
|
||||
end
|
||||
|
||||
local function install_module()
|
||||
if not namespace.module_installed then
|
||||
dt.register_lib(
|
||||
MODULE, -- Module name
|
||||
_("DB maintenance"), -- Visible name
|
||||
true, -- expandable
|
||||
true, -- resetable
|
||||
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_LEFT_CENTER", 0}}, -- containers
|
||||
namespace.main_widget,
|
||||
nil,-- view_enter
|
||||
nil -- view_leave
|
||||
)
|
||||
namespace.module_installed = true
|
||||
end
|
||||
end
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- U S E R I N T E R F A C E
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
dbmaint.list_widget = dt.new_widget("text_view"){
|
||||
editable = false,
|
||||
reset_callback = function(this)
|
||||
this.text = ""
|
||||
end
|
||||
}
|
||||
|
||||
dbmaint.chooser = dt.new_widget("combobox"){
|
||||
label = _("scan for"),
|
||||
selected = 1,
|
||||
_("film rolls"), _("images"),
|
||||
reset_callback = function(this)
|
||||
this.selected = 1
|
||||
end
|
||||
}
|
||||
|
||||
dbmaint.scan_button = dt.new_widget("button"){
|
||||
label = _("scan"),
|
||||
tooltip = _("click to scan for missing film rolls/files"),
|
||||
clicked_callback = function(this)
|
||||
local found = nil
|
||||
local found_text = ""
|
||||
local old_log_level = set_log_level(DEFAULT_LOG_LEVEL)
|
||||
log.msg(log.debug, "Button clicked")
|
||||
if dbmaint.chooser.selected == 1 then -- film rolls
|
||||
found = scan_film_rolls()
|
||||
if #found > 0 then
|
||||
for _, film in ipairs(found) do
|
||||
local dir_name = du.split(film.path, PS)
|
||||
found_text = found_text .. dir_name[#dir_name] .. "\n"
|
||||
end
|
||||
end
|
||||
else
|
||||
log.msg(log.debug, "checking path " .. dt.collection[1].path .. " for missing files")
|
||||
found = scan_images(dt.collection[1].film)
|
||||
if #found > 0 then
|
||||
for _, image in ipairs(found) do
|
||||
found_text = found_text .. image.filename .. "\n"
|
||||
end
|
||||
end
|
||||
end
|
||||
if #found > 0 then
|
||||
log.msg(log.debug, "found " .. #found .. " missing items")
|
||||
dbmaint.list_widget.text = found_text
|
||||
dbmaint.found = found
|
||||
dbmaint.remove_button.sensitive = true
|
||||
else
|
||||
log.msg(log.debug, "no missing items found")
|
||||
end
|
||||
restore_log_level(old_log_level)
|
||||
end,
|
||||
reset_callback = function(this)
|
||||
dbmaint.found = nil
|
||||
end
|
||||
}
|
||||
|
||||
dbmaint.remove_button = dt.new_widget("button"){
|
||||
label = _("remove"),
|
||||
tooltip = _("remove missing film rolls/images"),
|
||||
sensitive = false,
|
||||
clicked_callback = function(this)
|
||||
if dbmaint.chooser.selected == 1 then -- film rolls
|
||||
remove_missing_film_rolls(dbmaint.found)
|
||||
else
|
||||
remove_missing_images(dbmaint.found)
|
||||
end
|
||||
dbmaint.found = nil
|
||||
dbmaint.list_widget.text = ""
|
||||
this.sensitive = false
|
||||
end,
|
||||
reset_callback = function(this)
|
||||
this.sensitive = false
|
||||
end
|
||||
}
|
||||
|
||||
dbmaint.main_widget = dt.new_widget("box"){
|
||||
orientation = "vertical",
|
||||
dt.new_widget("section_label"){label = _("missing items")},
|
||||
dbmaint.list_widget,
|
||||
dt.new_widget("label"){label = ""},
|
||||
dbmaint.chooser,
|
||||
dt.new_widget("label"){label = ""},
|
||||
dbmaint.scan_button,
|
||||
dbmaint.remove_button
|
||||
}
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- D A R K T A B L E I N T E G R A T I O N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function destroy()
|
||||
dt.gui.libs[MODULE].visible = false
|
||||
|
||||
if namespace.event_registered then
|
||||
dt.destroy_event(MODULE, "view-changed")
|
||||
end
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
local function restart()
|
||||
dt.gui.libs[MODULE].visible = true
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
script_data.restart = restart
|
||||
script_data.destroy_method = "hide"
|
||||
script_data.show = restart
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- E V E N T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
if dt.gui.current_view().id == "lighttable" then
|
||||
install_module()
|
||||
else
|
||||
if not namespace.event_registered then
|
||||
dt.register_event(MODULE, "view-changed",
|
||||
function(event, old_view, new_view)
|
||||
if new_view.name == "lighttable" and old_view.name == "darkroom" then
|
||||
install_module()
|
||||
end
|
||||
end
|
||||
)
|
||||
namespace.event_registered = true
|
||||
end
|
||||
end
|
||||
|
||||
return script_data
|
||||
1175
contrib/enfuseAdvanced.lua
Normal file
1175
contrib/enfuseAdvanced.lua
Normal file
File diff suppressed because it is too large
Load Diff
204
contrib/exportLUT.lua
Normal file
204
contrib/exportLUT.lua
Normal file
@@ -0,0 +1,204 @@
|
||||
--[[
|
||||
This file is part of darktable,
|
||||
copyright (c) 2020 Noah Clarke
|
||||
|
||||
darktable is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
darktable is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with darktable. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
Add the following line to .config/darktable/luarc to enable this lightable module:
|
||||
require "contrib/exportLut"
|
||||
|
||||
Given a haldCLUT identity file this script generates haldCLUTS from all the user's
|
||||
styles and exports them to a location of their choosing.
|
||||
|
||||
Warning: during export if a naming collision occurs the older file is automatically
|
||||
overwritten silently.
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require("lib/dtutils.file")
|
||||
local ds = require("lib/dtutils.system")
|
||||
|
||||
du.check_min_api_version("7.0.0", "exportLUT")
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("export LUT"),
|
||||
purpose = _("export a style as a LUT"),
|
||||
author = "Noah Clarke",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/exportLUT"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
du.check_min_api_version("5.0.0", "exportLUT")
|
||||
|
||||
local eL = {}
|
||||
eL.module_installed = false
|
||||
eL.event_registered = false
|
||||
eL.widgets = {}
|
||||
|
||||
-- Thanks Kevin Ertel for this bit
|
||||
local os_path_seperator = '/'
|
||||
if dt.configuration.running_os == 'windows' then os_path_seperator = '\\' end
|
||||
local mkdir_command = 'mkdir -p '
|
||||
if dt.configuration.running_os == 'windows' then mkdir_command = 'mkdir ' end
|
||||
|
||||
local file_chooser_button = dt.new_widget("file_chooser_button"){
|
||||
title = _("choose the identity file"),
|
||||
value = "",
|
||||
is_directory = false
|
||||
}
|
||||
|
||||
local export_chooser_button = dt.new_widget("file_chooser_button"){
|
||||
title = _("choose the export location"),
|
||||
value = "",
|
||||
is_directory = true
|
||||
}
|
||||
|
||||
local identity_label = dt.new_widget("label"){
|
||||
label = _("choose the identity haldclut file")
|
||||
}
|
||||
|
||||
local output_label = dt.new_widget("label"){
|
||||
label = _("choose the output location")
|
||||
}
|
||||
|
||||
local warning_label = dt.new_widget("label"){
|
||||
label = _("WARNING: files may be silently overwritten")
|
||||
}
|
||||
|
||||
local function end_job(job)
|
||||
job.valid = false
|
||||
end
|
||||
|
||||
local function output_path(style_name, job)
|
||||
local output_location = export_chooser_button.value .. os_path_seperator .. style_name .. ".png"
|
||||
output_location = string.gsub(output_location, "|", os_path_seperator)
|
||||
local output_dir = string.reverse(output_location)
|
||||
output_dir = string.gsub(output_dir, ".-" .. os_path_seperator, os_path_seperator, 1)
|
||||
output_dir = string.reverse(output_dir)
|
||||
if(output_dir ~= "") then
|
||||
df.mkdir(df.sanitize_filename(output_dir))
|
||||
end
|
||||
return output_location
|
||||
end
|
||||
|
||||
local function export_luts()
|
||||
local identity = dt.database.import(file_chooser_button.value)
|
||||
if(type(identity) ~= "userdata") then
|
||||
dt.print(_("invalid identity lut file"))
|
||||
else
|
||||
local job = dt.gui.create_job(_('exporting styles as haldCLUTs'), true, end_job)
|
||||
|
||||
local size = 1
|
||||
|
||||
for style_num, style in ipairs(dt.styles) do
|
||||
size = size + 1
|
||||
end
|
||||
|
||||
local count = 0
|
||||
for style_num, style in ipairs(dt.styles) do
|
||||
|
||||
identity:reset()
|
||||
dt.styles.apply(style, identity)
|
||||
local io_lut = dt.new_format("png")
|
||||
io_lut.bpp = 8
|
||||
|
||||
io_lut:write_image(identity, output_path(style.name, job))
|
||||
count = count + 1
|
||||
job.percent = count / size
|
||||
dt.print(string.format(_("exported: %s"), output_path(style.name, job)))
|
||||
end
|
||||
dt.print(_("done exporting haldCLUTs"))
|
||||
job.valid = false
|
||||
identity:reset()
|
||||
end
|
||||
end
|
||||
|
||||
local function install_module()
|
||||
if not eL.module_installed then
|
||||
dt.register_lib(
|
||||
"export haldclut",
|
||||
_("export haldclut"),
|
||||
true,
|
||||
false,
|
||||
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 100}},
|
||||
dt.new_widget("box")
|
||||
{
|
||||
orientation = "vertical",
|
||||
table.unpack(eL.widgets),
|
||||
},
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
eL.module_installed = true
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.gui.libs["export haldclut"].visible = false
|
||||
end
|
||||
|
||||
local function restart()
|
||||
dt.gui.libs["export haldclut"].visible = true
|
||||
end
|
||||
|
||||
local export_button = dt.new_widget("button"){
|
||||
label = _("export"),
|
||||
clicked_callback = export_luts
|
||||
}
|
||||
|
||||
table.insert(eL.widgets, identity_label)
|
||||
table.insert(eL.widgets, file_chooser_button)
|
||||
table.insert(eL.widgets, output_label)
|
||||
table.insert(eL.widgets, export_chooser_button)
|
||||
table.insert(eL.widgets, warning_label)
|
||||
table.insert(eL.widgets, export_button)
|
||||
|
||||
if dt.gui.current_view().id == "lighttable" then
|
||||
install_module()
|
||||
else
|
||||
if not eL.event_registered then
|
||||
dt.register_event(
|
||||
"exportLUT", "view-changed",
|
||||
function(event, old_view, new_view)
|
||||
if new_view.name == "lighttable" and old_view.name == "darkroom" then
|
||||
install_module()
|
||||
end
|
||||
end
|
||||
)
|
||||
eL.event_registered = true
|
||||
end
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
script_data.restart = restart
|
||||
script_data.destroy_method = "hide"
|
||||
script_data.show = restart
|
||||
|
||||
return script_data
|
||||
515
contrib/ext_editor.lua
Normal file
515
contrib/ext_editor.lua
Normal file
@@ -0,0 +1,515 @@
|
||||
--[[
|
||||
ext_editor.lua - edit images with external editors
|
||||
|
||||
darktable is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
darktable is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with darktable. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
|
||||
--[[
|
||||
ext_editor.lua - edit images with external editors
|
||||
|
||||
This script provides helpers to edit image files with programs external to darktable. It adds:
|
||||
- a new target storage "collection". Image exported will be reimported to collection for further edit with external programs
|
||||
- a new module "external editors", visible in lightable and darkroom, to select a program from a list
|
||||
- of up to 9 external editors and run it on a selected image (adjust this limit by changing MAX_EDITORS)
|
||||
- a set of lua preferences in order to configure name and path of up to 9 external editors
|
||||
- a set of lua shortcuts in order to quick launch the external editors
|
||||
|
||||
USAGE
|
||||
* require this script from main lua file
|
||||
|
||||
-- setup --
|
||||
* in "preferences/lua options" configure name and path/command of external programs
|
||||
* note that if a program name is left empty, that and all following entries will be ignored
|
||||
* in "preferences/shortcuts/lua" configure shortcuts for external programs (optional)
|
||||
* whenever programs preferences are changed, in external editors GUI, press "update list"
|
||||
|
||||
-- use --
|
||||
* in the export dialog choose "collection" and select the format and bit depth for the
|
||||
exported image
|
||||
* press "export"
|
||||
* the exported image will be imported into collection and grouped with the original image
|
||||
|
||||
* in lighttable, select an image for editing with en external program
|
||||
* (or in darkroom for the image being edited):
|
||||
* in external editors GUI, select program and press "edit"
|
||||
* edit the image with the external editor, overwite the file, quit the external program
|
||||
* the selected image will be updated
|
||||
or
|
||||
* in external editors GUI, select program and press "edit a copy"
|
||||
* edit the image with the external editor, overwite the file, quit the external program
|
||||
* a copy of the selected image will be created and updated
|
||||
or
|
||||
* use the shortcut to edit the current image with the corresponding external editor
|
||||
* overwite the file, quit the external program
|
||||
* the image will be updated
|
||||
|
||||
* warning: mouseover on lighttable/filmstrip will prevail on current image
|
||||
* this is the default DT behavior, not a bug of this script
|
||||
|
||||
CAVEATS
|
||||
* MAC compatibility not tested
|
||||
|
||||
BUGS, COMMENTS, SUGGESTIONS
|
||||
* send to Marco Carrarini, marco.carrarini@gmail.com
|
||||
]]
|
||||
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
local dtsys = require "lib/dtutils.system"
|
||||
|
||||
|
||||
-- module name
|
||||
local MODULE_NAME = "ext_editor"
|
||||
du.check_min_api_version("7.0.0", MODULE_NAME)
|
||||
|
||||
-- translation
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("external editors"),
|
||||
purpose = _("edit images with external editors"),
|
||||
author = "Marco Carrarini, marco.carrarini@gmail.com",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/ext_editor"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
-- OS compatibility
|
||||
local PS = dt.configuration.running_os == "windows" and "\\" or "/"
|
||||
|
||||
-- namespace
|
||||
local ee = {}
|
||||
ee.module_installed = false
|
||||
ee.event_registered = false
|
||||
ee.widgets = {}
|
||||
|
||||
|
||||
-- maximum number of external programs, can be increased to necessity
|
||||
local MAX_EDITORS = 9
|
||||
|
||||
-- number of valid entries in the list of external programs
|
||||
local n_entries
|
||||
|
||||
|
||||
-- allowed file extensions for external editors
|
||||
local allowed_file_types = {"JPG", "jpg", "JPEG", "jpeg", "TIF", "tif", "TIFF", "tiff", "EXR", "exr", "PNG", "png"}
|
||||
|
||||
|
||||
-- last used editor initialization
|
||||
if not dt.preferences.read(MODULE_NAME, "initialized", "bool") then
|
||||
dt.preferences.write(MODULE_NAME, "lastchoice", "integer", 0)
|
||||
dt.preferences.write(MODULE_NAME, "initialized", "bool", true)
|
||||
end
|
||||
local lastchoice = 0
|
||||
|
||||
|
||||
-- update lists of program names and paths, as well as combobox ---------------
|
||||
local function UpdateProgramList(combobox, button_edit, button_edit_copy, update_button_pressed)
|
||||
|
||||
-- initialize lists
|
||||
program_names = {}
|
||||
program_paths = {}
|
||||
|
||||
-- build lists from preferences
|
||||
local name
|
||||
local last = false
|
||||
n_entries = 0
|
||||
for i = 1, MAX_EDITORS do
|
||||
name = dt.preferences.read(MODULE_NAME, "program_name_"..i, "string")
|
||||
if (name == "" or name == nil) then last = true end
|
||||
if last then
|
||||
if combobox[n_entries + 1] then combobox[n_entries + 1] = nil end -- remove extra combobox entries
|
||||
else
|
||||
combobox[i] = i..": "..name
|
||||
program_names[i] = name
|
||||
program_paths[i] = df.sanitize_filename(dt.preferences.read(MODULE_NAME, "program_path_"..i, "string"))
|
||||
n_entries = i
|
||||
end
|
||||
end
|
||||
|
||||
lastchoice = dt.preferences.read(MODULE_NAME, "lastchoice", "integer")
|
||||
if lastchoice == 0 and n_entries > 0 then lastchoice = 1 end
|
||||
if lastchoice > n_entries then lastchoice = n_entries end
|
||||
dt.preferences.write(MODULE_NAME, "lastchoice", "integer", lastchoice)
|
||||
|
||||
-- widgets enabled if there is at least one program configured
|
||||
combobox.selected = lastchoice
|
||||
local active = n_entries > 0
|
||||
combobox.sensitive = active
|
||||
button_edit.sensitive = active
|
||||
button_edit_copy.sensitive = active
|
||||
|
||||
if update_button_pressed then dt.print(string.format(_("%d editors configured"), n_entries)) end
|
||||
end
|
||||
|
||||
|
||||
-- callback for buttons "edit" and "edit a copy" ------------------------------
|
||||
local function OpenWith(images, choice, copy)
|
||||
|
||||
-- check choice is valid, return if not
|
||||
if choice > n_entries then
|
||||
dt.print(_("not a valid choice"))
|
||||
return
|
||||
end
|
||||
|
||||
-- check if one image is selected, return if not
|
||||
if #images ~= 1 then
|
||||
dt.print(_("please select one image"))
|
||||
return
|
||||
end
|
||||
|
||||
local bin = program_paths[choice]
|
||||
local friendly_name = program_names[choice]
|
||||
|
||||
if dt.configuration.running_os == "macos" then bin = "open -W -a "..bin end
|
||||
|
||||
-- image to be edited
|
||||
local image
|
||||
i, image = next(images)
|
||||
local name = image.path..PS..image.filename
|
||||
|
||||
-- check if image format is allowed
|
||||
local file_ext = df.get_filetype(image.filename)
|
||||
local allowed = false
|
||||
for i,v in pairs(allowed_file_types) do
|
||||
if v == file_ext then
|
||||
allowed = true
|
||||
break
|
||||
end
|
||||
end
|
||||
if not allowed then
|
||||
dt.print(_("file type not allowed"))
|
||||
return
|
||||
end
|
||||
|
||||
-- save image tags, rating and color
|
||||
local tags = {}
|
||||
for i, tag in ipairs(dt.tags.get_tags(image)) do
|
||||
if not (string.sub(tag.name, 1, 9) == "darktable") then table.insert(tags, tag) end
|
||||
end
|
||||
local rating = image.rating
|
||||
local red = image.red
|
||||
local blue = image.blue
|
||||
local green = image.green
|
||||
local yellow = image.yellow
|
||||
local purple = image.purple
|
||||
|
||||
-- new image
|
||||
local new_name = name
|
||||
local new_image = image
|
||||
|
||||
if copy then
|
||||
|
||||
-- create unique filename
|
||||
new_name = df.create_unique_filename(new_name)
|
||||
|
||||
-- physical copy, check result, return if error
|
||||
local copy_success = df.file_copy(name, new_name)
|
||||
if not copy_success then
|
||||
dt.print(string.format(_("error copying file %s"), name))
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- launch the external editor, check result, return if error
|
||||
local run_cmd = bin.." "..df.sanitize_filename(new_name)
|
||||
dt.print(string.format(_("launching %s..."), friendly_name))
|
||||
local result = dtsys.external_command(run_cmd)
|
||||
if result ~= 0 then
|
||||
dt.print(string.format(_("error launching %s"), friendly_name))
|
||||
return
|
||||
end
|
||||
|
||||
if copy then
|
||||
-- import in database and group
|
||||
new_image = dt.database.import(new_name)
|
||||
new_image:group_with(image)
|
||||
else
|
||||
-- refresh the image view
|
||||
-- note that only image:drop_cache() is not enough to refresh view in darkroom mode
|
||||
-- therefore image must be deleted and reimported to force refresh
|
||||
|
||||
-- find the grouping status
|
||||
local image_leader = image.group_leader
|
||||
local group_members = image:get_group_members()
|
||||
local new_leader
|
||||
local index = nil
|
||||
local found = false
|
||||
|
||||
-- membership status, three different cases
|
||||
if image_leader == image then
|
||||
if #group_members > 1 then
|
||||
-- case 1: image is leader in a group with more members
|
||||
while not found do
|
||||
index, new_leader = next(group_members, index)
|
||||
if new_leader ~= image_leader then found = true end
|
||||
end
|
||||
new_leader:make_group_leader()
|
||||
image:delete()
|
||||
if image.local_copy then image:drop_cache() end -- to fix fail to allocate cache error
|
||||
new_image = dt.database.import(name)
|
||||
new_image:group_with(new_leader)
|
||||
new_image:make_group_leader()
|
||||
else
|
||||
-- case 2: image is the only member in group
|
||||
image:delete()
|
||||
if image.local_copy then image:drop_cache() end -- to fix fail to allocate cache error
|
||||
new_image = dt.database.import(name)
|
||||
new_image:group_with()
|
||||
end
|
||||
else
|
||||
-- case 3: image is in a group but is not leader
|
||||
image:delete()
|
||||
if image.local_copy then image:drop_cache() end -- to fix fail to allocate cache error
|
||||
new_image = dt.database.import(name)
|
||||
new_image:group_with(image_leader)
|
||||
end
|
||||
end
|
||||
|
||||
-- restore image tags, rating and color
|
||||
for i, tag in ipairs(tags) do dt.tags.attach(tag, new_image) end
|
||||
new_image.rating = rating
|
||||
new_image.red = red
|
||||
new_image.blue = blue
|
||||
new_image.green = green
|
||||
new_image.yellow = yellow
|
||||
new_image.purple = purple
|
||||
|
||||
-- select the new image
|
||||
local selection = {}
|
||||
table.insert(selection, new_image)
|
||||
dt.gui.selection(selection)
|
||||
|
||||
-- refresh darkroom view
|
||||
if dt.gui.current_view().id == "darkroom" then
|
||||
dt.gui.views.darkroom.display_image(new_image)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- callback function for shortcuts --------------------------------------------
|
||||
local function program_shortcut(event, shortcut)
|
||||
OpenWith(dt.gui.action_images, tonumber(string.sub(shortcut, -2)), false)
|
||||
end
|
||||
|
||||
|
||||
-- export images and reimport in collection -----------------------------------
|
||||
local function export2collection(storage, image_table, extra_data)
|
||||
|
||||
local temp_name, new_name, new_image, move_success
|
||||
|
||||
for image, temp_name in pairs(image_table) do
|
||||
|
||||
-- images are first exported in temp folder then moved to collection folder
|
||||
|
||||
-- create unique filename
|
||||
new_name = image.path..PS..df.get_filename(temp_name)
|
||||
new_name = df.create_unique_filename(new_name)
|
||||
|
||||
-- move image to collection folder, check result, return if error
|
||||
move_success = df.file_move(temp_name, new_name)
|
||||
if not move_success then
|
||||
dt.print(string.format(_("error moving file %s"), temp_name))
|
||||
return
|
||||
end
|
||||
|
||||
-- import in database and group
|
||||
new_image = dt.database.import(new_name)
|
||||
new_image:group_with(image.group_leader)
|
||||
end
|
||||
|
||||
dt.print(_("finished exporting"))
|
||||
end
|
||||
|
||||
|
||||
-- install the module in the UI -----------------------------------------------
|
||||
local function install_module(dr)
|
||||
|
||||
local views = {[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 90}}
|
||||
if dr then
|
||||
views = {[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 90},
|
||||
[dt.gui.views.darkroom] = {"DT_UI_CONTAINER_PANEL_LEFT_CENTER", 100}}
|
||||
end
|
||||
|
||||
if not ee.module_installed then
|
||||
-- register new module "external editors" in lighttable and darkroom ----
|
||||
dt.register_lib(
|
||||
MODULE_NAME,
|
||||
_("external editors"),
|
||||
true, -- expandable
|
||||
false, -- resetable
|
||||
views,
|
||||
dt.new_widget("box") {
|
||||
orientation = "vertical",
|
||||
table.unpack(ee.widgets),
|
||||
},
|
||||
nil, -- view_enter
|
||||
nil -- view_leave
|
||||
)
|
||||
ee.module_installed = true
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
for i = 1, MAX_EDITORS do
|
||||
dt.destroy_event(MODULE_NAME .. i, "shortcut")
|
||||
end
|
||||
dt.destroy_storage("exp2coll")
|
||||
dt.gui.libs[MODULE_NAME].visible = false
|
||||
end
|
||||
|
||||
local function restart()
|
||||
for i = 1, MAX_EDITORS do
|
||||
dt.register_event(MODULE_NAME .. i, "shortcut",
|
||||
program_shortcut, string.format(_("edit with program %02d"), i))
|
||||
end
|
||||
dt.register_storage("exp2coll", _("collection"), nil, export2collection)
|
||||
dt.gui.libs[MODULE_NAME].visible = true
|
||||
end
|
||||
|
||||
local function show()
|
||||
dt.gui.libs[MODULE_NAME].visible = true
|
||||
end
|
||||
|
||||
|
||||
-- combobox, with variable number of entries ----------------------------------
|
||||
local combobox = dt.new_widget("combobox") {
|
||||
label = _("choose program"),
|
||||
tooltip = _("select the external editor from the list"),
|
||||
changed_callback = function(self)
|
||||
dt.preferences.write(MODULE_NAME, "lastchoice", "integer", self.selected)
|
||||
end,
|
||||
""
|
||||
}
|
||||
|
||||
|
||||
-- button edit ----------------------------------------------------------------
|
||||
local button_edit = dt.new_widget("button") {
|
||||
label = _("edit"),
|
||||
tooltip = _("open the selected image in external editor"),
|
||||
--sensitive = false,
|
||||
clicked_callback = function()
|
||||
OpenWith(dt.gui.action_images, combobox.selected, false)
|
||||
end
|
||||
}
|
||||
|
||||
|
||||
-- button edit a copy ---------------------------------------------------------
|
||||
local button_edit_copy = dt.new_widget("button") {
|
||||
label = _("edit a copy"),
|
||||
tooltip = _("create a copy of the selected image and open it in external editor"),
|
||||
clicked_callback = function()
|
||||
OpenWith(dt.gui.action_images, combobox.selected, true)
|
||||
end
|
||||
}
|
||||
|
||||
|
||||
-- button update list ---------------------------------------------------------
|
||||
local button_update_list = dt.new_widget("button") {
|
||||
label = _("update list"),
|
||||
tooltip = _("update list of programs if lua preferences are changed"),
|
||||
clicked_callback = function()
|
||||
UpdateProgramList(combobox, button_edit, button_edit_copy, true)
|
||||
end
|
||||
}
|
||||
|
||||
|
||||
-- box for the buttons --------------------------------------------------------
|
||||
-- it doesn't seem there is a way to make the buttons equal in size
|
||||
local box1 = dt.new_widget("box") {
|
||||
orientation = "horizontal",
|
||||
button_edit,
|
||||
button_edit_copy,
|
||||
button_update_list
|
||||
}
|
||||
|
||||
|
||||
-- table with all the widgets --------------------------------------------------
|
||||
table.insert(ee.widgets, combobox)
|
||||
table.insert(ee.widgets, box1)
|
||||
|
||||
|
||||
-- register new module, but only when in lighttable ----------------------------
|
||||
local show_dr = dt.preferences.read(MODULE_NAME, "show_in_darkrooom", "bool")
|
||||
if dt.gui.current_view().id == "lighttable" then
|
||||
install_module(show_dr)
|
||||
else
|
||||
if not ee.event_registered then
|
||||
dt.register_event(
|
||||
MODULE_NAME, "view-changed",
|
||||
function(event, old_view, new_view)
|
||||
if new_view.name == "lighttable" and old_view.name == "darkroom" then
|
||||
install_module(show_dr)
|
||||
end
|
||||
end
|
||||
)
|
||||
ee.event_registered = true
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- initialize list of programs and widgets ------------------------------------
|
||||
UpdateProgramList(combobox, button_edit, button_edit_copy, false)
|
||||
|
||||
|
||||
-- register new storage -------------------------------------------------------
|
||||
dt.register_storage("exp2coll", _("collection"), nil, export2collection)
|
||||
|
||||
|
||||
-- register the new preferences -----------------------------------------------
|
||||
for i = MAX_EDITORS, 1, -1 do
|
||||
dt.preferences.register(MODULE_NAME, "program_path_"..i, "file",
|
||||
string.format(_("executable for external editor %d"), i),
|
||||
_("select executable for external editor") , _("(none)"))
|
||||
|
||||
dt.preferences.register(MODULE_NAME, "program_name_"..i, "string",
|
||||
string.format(_("name of external editor %d"), i),
|
||||
_("friendly name of external editor"), "")
|
||||
end
|
||||
dt.preferences.register(MODULE_NAME, "show_in_darkrooom", "bool",
|
||||
_("show external editors in darkroom"),
|
||||
_("check to show external editors module also in darkroom (requires restart)"), false)
|
||||
|
||||
|
||||
-- register the new shortcuts -------------------------------------------------
|
||||
for i = 1, MAX_EDITORS do
|
||||
dt.register_event(MODULE_NAME .. i, "shortcut",
|
||||
program_shortcut, string.format(_("edit with program %02d"), i))
|
||||
end
|
||||
|
||||
|
||||
script_data.destroy = destroy
|
||||
script_data.restart = restart
|
||||
script_data.destroy_method = "hide"
|
||||
script_data.show = show
|
||||
|
||||
return script_data
|
||||
|
||||
-- end of script --------------------------------------------------------------
|
||||
|
||||
-- vim: shiftwidth=2 expandtab tabstop=2 cindent syntax=lua
|
||||
-- kate: hl Lua;
|
||||
540
contrib/face_recognition.lua
Normal file
540
contrib/face_recognition.lua
Normal file
@@ -0,0 +1,540 @@
|
||||
--[[
|
||||
Face recognition for darktable
|
||||
|
||||
Copyright (c) 2017 Sebastian Witt
|
||||
|
||||
darktable is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
darktable is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with darktable. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
|
||||
--[[
|
||||
face_recognition
|
||||
Add a new storage option to send images to face_recognition.
|
||||
Images are exported to darktable tmp dir first.
|
||||
A directory with known faces must exist, the image name are the
|
||||
tag names which will be used.
|
||||
Multiple images for one face can exist, add a number to it, the
|
||||
number will be removed from the tag, for example:
|
||||
People|IknowYou1.jpg
|
||||
People|IknowYou2.jpg
|
||||
People|Another.jpg
|
||||
People|Youtoo.jpg
|
||||
|
||||
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
* https://github.com/ageitgey/face_recognition
|
||||
* https://github.com/darktable-org/lua-scripts/tree/master/lib
|
||||
|
||||
USAGE
|
||||
* require this file from your main luarc config file.
|
||||
|
||||
This plugin will add a new storage option and calls face_recognition after export.
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
local dtsys = require "lib/dtutils.system"
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
-- constants
|
||||
|
||||
local MODULE = "face_recognition"
|
||||
local PS = dt.configuration.running_os == "windows" and '\\' or '/'
|
||||
local OUTPUT = dt.configuration.tmp_dir .. PS .. "facerecognition.txt"
|
||||
|
||||
du.check_min_api_version("7.0.0", MODULE)
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("face recognition"),
|
||||
purpose = _("use facial recognition to tag images"),
|
||||
author = "Sebastian Witt",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/face_recognition"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
-- namespace
|
||||
|
||||
local fc = {}
|
||||
fc.module_installed = false
|
||||
fc.event_registered = false
|
||||
|
||||
local function build_image_table(images)
|
||||
local image_table = {}
|
||||
local file_extension = ""
|
||||
local tmp_dir = dt.configuration.tmp_dir .. PS
|
||||
local ff = fc.export_format.value
|
||||
local cnt = 0
|
||||
|
||||
-- check for plugin-data and direct_edit and build image table accordingly
|
||||
|
||||
if string.match(ff, "JPEG") then
|
||||
file_extension = ".jpg"
|
||||
elseif string.match(ff, "PNG") then
|
||||
file_extension = ".png"
|
||||
elseif string.match(ff, "TIFF") then
|
||||
file_extension = ".tif"
|
||||
end
|
||||
|
||||
for _,img in ipairs(images) do
|
||||
if img ~= nil then
|
||||
image_table[tmp_dir .. df.get_basename(img.filename) .. file_extension] = img
|
||||
cnt = cnt + 1
|
||||
end
|
||||
end
|
||||
|
||||
return image_table, cnt
|
||||
end
|
||||
|
||||
local function stop_job(job)
|
||||
job.valid = false
|
||||
end
|
||||
|
||||
local function do_export(img_tbl, images)
|
||||
local exporter = nil
|
||||
local upsize = false
|
||||
local ff = fc.export_format.value
|
||||
local height = dt.preferences.read(MODULE, "max_height", "integer")
|
||||
local width = dt.preferences.read(MODULE, "max_width", "integer")
|
||||
|
||||
-- get the export format parameters
|
||||
if string.match(ff, "JPEG") then
|
||||
exporter = dt.new_format("jpeg")
|
||||
exporter.quality = 80
|
||||
elseif string.match(ff, "PNG") then
|
||||
exporter = dt.new_format("png")
|
||||
exporter.bpp = 8
|
||||
elseif string.match(ff, "TIFF") then
|
||||
exporter = dt.new_format("tiff")
|
||||
exporter.bpp = 8
|
||||
end
|
||||
exporter.max_height = height
|
||||
exporter.max_width = width
|
||||
|
||||
-- export the images
|
||||
local job = dt.gui.create_job(_("export images"), true, stop_job)
|
||||
local exp_cnt = 0
|
||||
local percent_step = 1.0 / images
|
||||
job.percent = 0.0
|
||||
for export,img in pairs(img_tbl) do
|
||||
exp_cnt = exp_cnt + 1
|
||||
dt.print(string.format(_("exporting image %i of %i images"), exp_cnt, images))
|
||||
exporter:write_image(img, export, upsize)
|
||||
job.percent = job.percent + percent_step
|
||||
end
|
||||
job.valid = false
|
||||
|
||||
-- return success, or not
|
||||
return true
|
||||
end
|
||||
|
||||
local function save_preferences()
|
||||
dt.preferences.write(MODULE, "unknown_tag", "string", fc.unknown_tag.text)
|
||||
dt.preferences.write(MODULE, "no_persons_found_tag", "string", fc.no_persons_found_tag.text)
|
||||
dt.preferences.write(MODULE, "ignore_tags", "string", fc.ignore_tags.text)
|
||||
dt.preferences.write(MODULE, "category_tags", "string", fc.category_tags.text)
|
||||
dt.preferences.write(MODULE, "known_image_path", "directory", fc.known_image_path.value)
|
||||
local val = fc.tolerance.value
|
||||
val = string.gsub(tostring(val), ",", ".")
|
||||
dt.preferences.write(MODULE, "tolerance", "float", tonumber(val))
|
||||
dt.preferences.write(MODULE, "num_cores", "integer", fc.num_cores.value)
|
||||
dt.preferences.write(MODULE, "export_format", "integer", fc.export_format.selected)
|
||||
dt.preferences.write(MODULE, "max_width", "integer", tonumber(fc.width.text))
|
||||
dt.preferences.write(MODULE, "max_height", "integer", tonumber(fc.height.text))
|
||||
end
|
||||
|
||||
local function reset_preferences()
|
||||
fc.unknown_tag.text = "unknown_person"
|
||||
fc.no_persons_found_tag.text = "no_persons_found"
|
||||
fc.ignore_tags.text = ""
|
||||
fc.category_tags.text = ""
|
||||
fc.known_image_path.value = dt.configuration.config_dir .. "/face_recognition"
|
||||
fc.tolerance.value = 0.6
|
||||
fc.num_cores.value = -1
|
||||
fc.export_format.selected = 1
|
||||
fc.width.text = 1000
|
||||
fc.height.text = 1000
|
||||
save_preferences()
|
||||
end
|
||||
|
||||
-- Check if image has ignored tag attached
|
||||
local function ignoreByTag (image, ignoreTags)
|
||||
local tags = image:get_tags ()
|
||||
local ignoreImage = false
|
||||
-- For each image tag
|
||||
for _,t in ipairs (tags) do
|
||||
-- Check if it contains a ignore tag
|
||||
for _,it in ipairs (ignoreTags) do
|
||||
if string.find (t.name, it, 1, true) then
|
||||
-- The image has ignored tag attached
|
||||
ignoreImage = true
|
||||
dt.print_log ("Face recognition: Ignored tag: " .. it .. " found in " .. image.id .. ":" .. t.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return ignoreImage
|
||||
end
|
||||
|
||||
local function cleanup(img_list)
|
||||
for _, img in ipairs(img_list) do
|
||||
os.remove(img)
|
||||
end
|
||||
os.remove(OUTPUT)
|
||||
end
|
||||
|
||||
local function face_recognition ()
|
||||
|
||||
local bin_path = df.check_if_bin_exists("face_recognition")
|
||||
|
||||
if not bin_path then
|
||||
dt.print(_("face recognition not found"))
|
||||
return
|
||||
end
|
||||
|
||||
save_preferences()
|
||||
|
||||
-- Get preferences
|
||||
local knownPath = dt.preferences.read(MODULE, "known_image_path", "directory")
|
||||
local nrCores = dt.preferences.read(MODULE, "num_cores", "integer")
|
||||
local ignoreTagString = dt.preferences.read(MODULE, "ignore_tags", "string")
|
||||
local categoryTagString = dt.preferences.read(MODULE, "category_tags", "string")
|
||||
local unknownTag = dt.preferences.read(MODULE, "unknown_tag", "string")
|
||||
local nonpersonsfoundTag = dt.preferences.read(MODULE, "no_persons_found_tag", "string")
|
||||
|
||||
-- face_recognition uses -1 for all cores, we use 0 in preferences
|
||||
if nrCores < 1 then
|
||||
nrCores = -1
|
||||
end
|
||||
|
||||
-- Split ignore tags (if any)
|
||||
ignoreTags = {}
|
||||
for tag in string.gmatch(ignoreTagString, '([^,]+)') do
|
||||
table.insert (ignoreTags, tag)
|
||||
dt.print_log ("Face recognition: Ignore tag: " .. tag)
|
||||
end
|
||||
|
||||
-- list of exported images
|
||||
|
||||
local image_table, cnt = build_image_table(dt.gui.action_images)
|
||||
|
||||
if cnt > 0 then
|
||||
local success = do_export(image_table, cnt)
|
||||
if success then
|
||||
-- do the face recognition
|
||||
local img_list = {}
|
||||
|
||||
for v,_ in pairs(image_table) do
|
||||
table.insert (img_list, v)
|
||||
end
|
||||
|
||||
-- Get path of exported images
|
||||
local path = df.get_path (img_list[1])
|
||||
dt.print_log ("Face recognition: Path to known faces: " .. knownPath)
|
||||
dt.print_log ("Face recognition: Path to unknown images: " .. path)
|
||||
dt.print_log ("Face recognition: Tag used for unknown faces: " .. unknownTag)
|
||||
dt.print_log ("Face recognition: Tag used if non person is found: " .. nonpersonsfoundTag)
|
||||
os.setlocale("C")
|
||||
local tolerance = dt.preferences.read(MODULE, "tolerance", "float")
|
||||
|
||||
local command = bin_path .. " --cpus " .. nrCores .. " --tolerance " .. tolerance .. " " .. knownPath .. " " .. path .. " > " .. OUTPUT
|
||||
os.setlocale()
|
||||
dt.print_log("Face recognition: Running command: " .. command)
|
||||
dt.print(_("starting face recognition..."))
|
||||
|
||||
dtsys.external_command(command)
|
||||
|
||||
-- Open output file
|
||||
local f = io.open(OUTPUT, "rb")
|
||||
|
||||
if not f then
|
||||
dt.print(_("face recognition failed"))
|
||||
else
|
||||
dt.print(_("face recognition finished"))
|
||||
f:close ()
|
||||
end
|
||||
|
||||
-- Read output
|
||||
dt.print(_("processing results..."))
|
||||
local result = {}
|
||||
local tags_list = {}
|
||||
local tag_object = {}
|
||||
for line in io.lines(OUTPUT) do
|
||||
if not string.match(line, "^WARNING:") and line ~= "" and line ~= nil then
|
||||
local file, tag = string.match (line, "(.*),(.*)$")
|
||||
tag = string.gsub (tag, "%d*$", "")
|
||||
dt.print_log ("File:"..file .." Tag:".. tag)
|
||||
tag_object = {}
|
||||
if result[file] == nil then
|
||||
tag_object[tag] = true
|
||||
result[file] = tag_object
|
||||
else
|
||||
tag_object = result[file]
|
||||
tag_object[tag] = true
|
||||
result[file] = tag_object
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Attach tags
|
||||
local result_index = 0
|
||||
for file,tags in pairs(result) do
|
||||
result_index = result_index +1
|
||||
-- Find image in table
|
||||
img = image_table[file]
|
||||
if img == nil then
|
||||
dt.print_log("Face recognition: Ignoring face recognition entry: " .. file)
|
||||
else
|
||||
for t,_ in pairs (tags) do
|
||||
-- Check if image is ignored
|
||||
if ignoreByTag (img, ignoreTags) then
|
||||
dt.print_log("Face recognition: Ignoring image with ID " .. img.id)
|
||||
else
|
||||
-- Check of unrecognized unknown_person
|
||||
if t == "unknown_person" then
|
||||
t = unknownTag
|
||||
end
|
||||
-- Check of unrecognized no_persons_found
|
||||
if t == "no_persons_found" then
|
||||
t = nonpersonsfoundTag
|
||||
end
|
||||
if t ~= "" and t ~= nil then
|
||||
if categoryTagString ~= "" and t ~= nonpersonsfoundTag then
|
||||
t = categoryTagString .. "|" .. t
|
||||
end
|
||||
dt.print_log ("ImgId:" .. img.id .. " Tag:".. t)
|
||||
-- Create tag if it does not exist
|
||||
if tags_list[t] == nil then
|
||||
tag = dt.tags.create (t)
|
||||
tags_list[t] = tag
|
||||
else
|
||||
tag = tags_list[t]
|
||||
end
|
||||
img:attach_tag (tag)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
cleanup(img_list)
|
||||
dt.print_log("img_list cleaned-up")
|
||||
dt.print_log("face recognition complete")
|
||||
dt.print(_("face recognition complete"))
|
||||
else
|
||||
dt.print(_("image export failed"))
|
||||
return
|
||||
end
|
||||
else
|
||||
dt.print(_("no images selected"))
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local function install_module()
|
||||
if not fc.module_installed then
|
||||
dt.register_lib(
|
||||
MODULE, -- Module name
|
||||
_("face recognition"), -- Visible name
|
||||
true, -- expandable
|
||||
true, -- resetable
|
||||
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 300}}, -- containers
|
||||
fc.widget,
|
||||
nil,-- view_enter
|
||||
nil -- view_leave
|
||||
)
|
||||
fc.module_installed = true
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.gui.libs[MODULE].visible = false
|
||||
end
|
||||
|
||||
local function restart()
|
||||
dt.gui.libs[MODULE].visible = true
|
||||
end
|
||||
|
||||
-- build the interface
|
||||
|
||||
fc.unknown_tag = dt.new_widget("entry"){
|
||||
text = dt.preferences.read(MODULE, "unknown_tag", "string"),
|
||||
tooltip = _("tag to be used for unknown person"),
|
||||
editable = true,
|
||||
}
|
||||
|
||||
fc.no_persons_found_tag = dt.new_widget("entry"){
|
||||
text = dt.preferences.read(MODULE, "no_persons_found_tag", "string"),
|
||||
tooltip = _("tag to be used when no persons are found"),
|
||||
editable = true,
|
||||
}
|
||||
|
||||
fc.ignore_tags = dt.new_widget("entry"){
|
||||
text = dt.preferences.read(MODULE, "ignore_tags", "string"),
|
||||
tooltip = _("tags of images to ignore"),
|
||||
editable = true,
|
||||
}
|
||||
|
||||
fc.category_tags = dt.new_widget("entry"){
|
||||
text = dt.preferences.read(MODULE, "category_tags", "string"),
|
||||
tooltip = _("tag category"),
|
||||
editable = true,
|
||||
}
|
||||
|
||||
fc.tolerance = dt.new_widget("slider"){
|
||||
label = _("tolerance"),
|
||||
tooltip = _("detection tolerance - 0.6 default - lower if too many faces detected"),
|
||||
soft_min = 0.0,
|
||||
hard_min = 0.0,
|
||||
soft_max = 1.0,
|
||||
soft_min = 1.0,
|
||||
step = 0.1,
|
||||
digits = 1,
|
||||
value = 0.0,
|
||||
}
|
||||
|
||||
fc.num_cores = dt.new_widget("slider"){
|
||||
label = _("processor cores"),
|
||||
tooltip = _("number of processor cores to use, 0 for all"),
|
||||
soft_min = 0,
|
||||
soft_max = 16,
|
||||
hard_min = 0,
|
||||
hard_max = 64,
|
||||
step = 1,
|
||||
digits = 0,
|
||||
value = dt.preferences.read(MODULE, "num_cores", "integer"),
|
||||
}
|
||||
|
||||
fc.known_image_path = dt.new_widget("file_chooser_button"){
|
||||
title = _("known image directory"),
|
||||
tooltip = _("face data directory"),
|
||||
value = dt.preferences.read(MODULE, "known_image_path", "directory"),
|
||||
is_directory = true,
|
||||
changed_callback = function(this)
|
||||
dt.preferences.write(MODULE, "known_image_path", "directory", this.value)
|
||||
end
|
||||
}
|
||||
|
||||
fc.export_format = dt.new_widget("combobox"){
|
||||
label = _("export image format"),
|
||||
tooltip = _("format for exported images"),
|
||||
selected = dt.preferences.read(MODULE, "export_format", "integer"),
|
||||
changed_callback = function(this)
|
||||
dt.preferences.write(MODULE, "export_format", "integer", this.selected)
|
||||
end,
|
||||
"JPEG", "PNG", "TIFF",
|
||||
}
|
||||
|
||||
fc.width = dt.new_widget("entry"){
|
||||
text = tostring(dt.preferences.read(MODULE, "max_width", "integer")),
|
||||
tooltip = _("maximum exported image width"),
|
||||
editable = true,
|
||||
}
|
||||
|
||||
fc.height = dt.new_widget("entry"){
|
||||
text = tostring(dt.preferences.read(MODULE, "max_height", "integer")),
|
||||
tooltip = _("maximum exported image height"),
|
||||
editable = true,
|
||||
}
|
||||
|
||||
fc.execute = dt.new_widget("button"){
|
||||
label = "detect faces",
|
||||
clicked_callback = function(this)
|
||||
face_recognition()
|
||||
end
|
||||
}
|
||||
|
||||
local widgets = {
|
||||
dt.new_widget("label"){ label = _("unknown person tag")},
|
||||
fc.unknown_tag,
|
||||
dt.new_widget("label"){ label = _("no persons found tag")},
|
||||
fc.no_persons_found_tag,
|
||||
dt.new_widget("label"){ label = _("tags of images to ignore")},
|
||||
fc.ignore_tags,
|
||||
dt.new_widget("label"){ label = _("tag category")},
|
||||
fc.category_tags,
|
||||
dt.new_widget("label"){ label = _("face data directory")},
|
||||
fc.known_image_path,
|
||||
}
|
||||
|
||||
if dt.configuration.running_os == "windows" or dt.configuration.running_os == "macos" then
|
||||
table.insert(widgets, df.executable_path_widget({"face_recognition"}))
|
||||
end
|
||||
table.insert(widgets, dt.new_widget("section_label"){ label = _("processing options")})
|
||||
table.insert(widgets, fc.tolerance)
|
||||
table.insert(widgets, fc.num_cores)
|
||||
table.insert(widgets, fc.export_format)
|
||||
table.insert(widgets, dt.new_widget("box"){
|
||||
orientation = "horizontal",
|
||||
dt.new_widget("label"){ label = _("width")},
|
||||
fc.width,
|
||||
})
|
||||
table.insert(widgets, dt.new_widget("box"){
|
||||
orientation = "horizontal",
|
||||
dt.new_widget("label"){ label = _("height")},
|
||||
fc.height,
|
||||
})
|
||||
table.insert(widgets, fc.execute)
|
||||
|
||||
fc.widget = dt.new_widget("box"){
|
||||
orientation = vertical,
|
||||
reset_callback = function(this)
|
||||
reset_preferences()
|
||||
end,
|
||||
table.unpack(widgets),
|
||||
}
|
||||
|
||||
if dt.gui.current_view().id == "lighttable" then
|
||||
install_module()
|
||||
else
|
||||
if not fc.event_registered then
|
||||
dt.register_event(
|
||||
MODULE, "view-changed",
|
||||
function(event, old_view, new_view)
|
||||
if new_view.name == "lighttable" and old_view.name == "darkroom" then
|
||||
install_module()
|
||||
end
|
||||
end
|
||||
)
|
||||
fc.event_registered = true
|
||||
end
|
||||
end
|
||||
|
||||
fc.tolerance.value = dt.preferences.read(MODULE, "tolerance", "float")
|
||||
|
||||
-- preferences
|
||||
|
||||
if not dt.preferences.read(MODULE, "initialized", "bool") then
|
||||
reset_preferences()
|
||||
save_preferences()
|
||||
dt.preferences.write(MODULE, "initialized", "bool", true)
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
script_data.restart = restart
|
||||
script_data.destroy_method = "hide"
|
||||
script_data.show = restart
|
||||
|
||||
return script_data
|
||||
--
|
||||
-- vim: shiftwidth=2 expandtab tabstop=2 cindent syntax=lua
|
||||
156
contrib/fujifilm_dynamic_range.lua
Normal file
156
contrib/fujifilm_dynamic_range.lua
Normal file
@@ -0,0 +1,156 @@
|
||||
--[[ fujifilm_dynamic_range-0.1
|
||||
|
||||
Compensate for Fujifilm raw files made using "dynamic range".
|
||||
|
||||
Copyright (C) 2021, 2022 Dan Torop <dant@pnym.net>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
]]
|
||||
|
||||
--[[About this Plugin
|
||||
Support for adjusting darktable exposure by Fujifilm raw exposure
|
||||
bias. This corrects for a DR100/DR200/DR400 "dynamic range" setting.
|
||||
|
||||
Dependencies:
|
||||
- exiftool (https://www.sno.phy.queensu.ca/~phil/exiftool/)
|
||||
|
||||
Based upon fujifilm_ratings by Ben Mendis
|
||||
|
||||
The relevant tag is RawExposureBias (0x9650). This appears to
|
||||
represent the shift in EV for the chosen DR setting (whether manual or
|
||||
automatic). Note that even at 100DR ("standard") there is an EV shift:
|
||||
|
||||
100 DR -> -0.72 EV
|
||||
200 DR -> -1.72 EV
|
||||
400 DR -> -2.72 EV
|
||||
|
||||
The ideal would be to use exiv2 to read this tag, as this is the same
|
||||
code which darktable import uses. Unfortunately, exiv2 as of v0.27.3
|
||||
can't read this tag. As it is encoded as a 4-byte ratio of two signed
|
||||
shorts -- a novel data type -- it will require some attention to fix
|
||||
this.
|
||||
|
||||
There is an exiv2-readable DevelopmentDynamicRange tag which maps to
|
||||
RawExposureBias as above. DevelopmentDynamicRange is only present
|
||||
when tag DynamicRangeSetting (0x1402) is Manual/Raw (0x0001). When it
|
||||
is Auto (0x0000), the equivalent data is tag AutoDynamicRange
|
||||
(0x140b). But exiv2 currently can't read that tag either.
|
||||
|
||||
Hence for now this code uses exiftool to read RawExposureBias, as a
|
||||
more general solution. As exiftool is approx. 10x slower than exiv2
|
||||
(Perl vs. C++), this may slow large imports.
|
||||
|
||||
These tags have been checked on a Fujifilm X100S and X100V. Other
|
||||
cameras may behave in other ways.
|
||||
|
||||
--]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
local dtsys = require "lib/dtutils.system"
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
du.check_min_api_version("7.0.0", "fujifilm_dynamic_range")
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("fujifilm dynamic range"),
|
||||
purpose = _("compensate for Fujifilm raw files made using \"dynamic range\""),
|
||||
author = "Dan Torop <dant@pnym.net>",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/fujifilm_dynamic_range"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
local function detect_dynamic_range(event, image)
|
||||
if image.exif_maker ~= "FUJIFILM" then
|
||||
dt.print_log("[fujifilm_dynamic_range] ignoring non-Fujifilm image")
|
||||
return
|
||||
end
|
||||
-- it would be nice to check image.is_raw but this appears to not yet be set
|
||||
if not string.match(image.filename, "%.RAF$") then
|
||||
dt.print_log("[fujifilm_dynamic_range] ignoring non-raw image")
|
||||
return
|
||||
end
|
||||
local command = df.check_if_bin_exists("exiftool")
|
||||
if not command then
|
||||
dt.print_error("[fujifilm_dynamic_range] exiftool not found")
|
||||
return
|
||||
end
|
||||
local RAF_filename = df.sanitize_filename(tostring(image))
|
||||
-- without -n flag, exiftool will round to the nearest tenth
|
||||
command = command .. " -RawExposureBias -n -t " .. RAF_filename
|
||||
dt.print_log(command)
|
||||
output = io.popen(command)
|
||||
local raf_result = output:read("*all")
|
||||
output:close()
|
||||
if #raf_result == 0 then
|
||||
dt.print_error("[fujifilm_dynamic_range] no output returned by exiftool")
|
||||
return
|
||||
end
|
||||
raf_result = string.match(raf_result, "\t(.*)")
|
||||
if not raf_result then
|
||||
dt.print_error("[fujifilm_dynamic_range] could not parse exiftool output")
|
||||
return
|
||||
end
|
||||
if image.exif_exposure_bias ~= image.exif_exposure_bias then
|
||||
-- is NAN (this is unlikely as RAFs should have ExposureBiasValue set)
|
||||
image.exif_exposure_bias = 0
|
||||
end
|
||||
-- this should be auto-applied if plugins/darkroom/workflow is scene-referred
|
||||
-- note that scene-referred workflow exposure preset also pushes exposure up by 0.5 EV
|
||||
image.exif_exposure_bias = image.exif_exposure_bias + tonumber(raf_result)
|
||||
dt.print_log("[fujifilm_dynamic_range] raw exposure bias " .. tostring(raf_result))
|
||||
-- handle any duplicates
|
||||
if #image:get_group_members() > 1 then
|
||||
local basename = df.get_basename(image.filename)
|
||||
local grouped_images = image:get_group_members()
|
||||
for _, img in ipairs(grouped_images) do
|
||||
if string.match(img.filename, basename) and img.duplicate_index > 0 then
|
||||
-- its a duplicate
|
||||
img.exif_exposure_bias = img.exif_exposure_bias + tonumber(raf_result)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.destroy_event("fujifilm_dr", "post-import-image")
|
||||
end
|
||||
|
||||
if not df.check_if_bin_exists("exiftool") then
|
||||
dt.print_log("Please install exiftool to use fujifilm_dynamic_range")
|
||||
error "[fujifilm_dynamic_range] exiftool not found"
|
||||
end
|
||||
|
||||
dt.register_event("fujifilm_dr", "post-import-image",
|
||||
detect_dynamic_range)
|
||||
|
||||
dt.print_log("[fujifilm_dynamic_range] loaded")
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
return script_data
|
||||
95
contrib/fujifilm_ratings.lua
Normal file
95
contrib/fujifilm_ratings.lua
Normal file
@@ -0,0 +1,95 @@
|
||||
--[[ fujifilm_ratings-0.1
|
||||
|
||||
Support for importing Fujifilm in-camera ratings in darktable.
|
||||
|
||||
Copyright (C) 2017 Ben Mendis <ben.mendis@gmail.com>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
Dependencies:
|
||||
- exiftool (https://www.sno.phy.queensu.ca/~phil/exiftool/)
|
||||
|
||||
--]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
local dtsys = require "lib/dtutils.system"
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
du.check_min_api_version("7.0.0", "fujifilm_ratings")
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("fujifilm ratings"),
|
||||
purpose = _("import Fujifilm in-camera ratings"),
|
||||
author = "Ben Mendis <ben.mendis@gmail.com>",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/fujifilm_ratings"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
local function detect_rating(event, image)
|
||||
if not string.match(image.filename, "%.RAF$") and not string.match(image.filename, "%.raf$") then
|
||||
return
|
||||
end
|
||||
if not df.check_if_bin_exists("exiftool") then
|
||||
dt.print_error("exiftool not found")
|
||||
return
|
||||
end
|
||||
local RAF_filename = df.sanitize_filename(tostring(image))
|
||||
local JPEG_filename = string.gsub(RAF_filename, "%.RAF$", ".JPG")
|
||||
local command = "exiftool -Rating " .. JPEG_filename
|
||||
dt.print_log(command)
|
||||
local output = io.popen(command)
|
||||
local jpeg_result = output:read("*all")
|
||||
output:close()
|
||||
if string.len(jpeg_result) > 0 then
|
||||
jpeg_result = string.gsub(jpeg_result, "^Rating.*(%d)", "%1")
|
||||
image.rating = tonumber(jpeg_result)
|
||||
dt.print_log("using JPEG rating: " .. jpeg_result)
|
||||
return
|
||||
end
|
||||
command = "exiftool -Rating " .. RAF_filename
|
||||
dt.print_log(command)
|
||||
output = io.popen(command)
|
||||
local raf_result = output:read("*all")
|
||||
output:close()
|
||||
if string.len(raf_result) > 0 then
|
||||
raf_result = string.gsub(raf_result, "^Rating.*(%d)", "%1")
|
||||
image.rating = tonumber(raf_result)
|
||||
dt.print_log("using RAF rating: " .. raf_result)
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.destroy_event("fujifilm_rat", "post-import-image")
|
||||
end
|
||||
|
||||
dt.register_event("fujifilm_rat", "post-import-image",
|
||||
detect_rating)
|
||||
|
||||
script_data.destroy = destroy
|
||||
return script_data
|
||||
350
contrib/geoJSON_export.lua
Normal file
350
contrib/geoJSON_export.lua
Normal file
@@ -0,0 +1,350 @@
|
||||
--[[
|
||||
This file is part of darktable,
|
||||
Copyright 2016 by Tobias Jakobs.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
darktable geoJSON export script
|
||||
|
||||
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
* mkdir
|
||||
* convert (ImageMagick)
|
||||
* xdg-open
|
||||
* xdg-user-dir
|
||||
|
||||
WARNING
|
||||
This script is only tested with Linux
|
||||
|
||||
USAGE
|
||||
* require this script from your main Lua file
|
||||
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
local dtsys = require "lib/dtutils.system"
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
du.check_min_api_version("7.0.0", "geoJSON_export")
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("geoJSON export"),
|
||||
purpose = _("export a geoJSON file from geo data"),
|
||||
author = "Tobias Jakobs",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/geoJSON_export"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
-- Sort a table
|
||||
local function spairs(_table, order) -- Code copied from http://stackoverflow.com/questions/15706270/sort-a-table-in-lua
|
||||
-- collect the keys
|
||||
local keys = {}
|
||||
for _key in pairs(_table) do keys[#keys + 1] = _key end
|
||||
|
||||
-- if order function given, sort by it by passing the table and keys a, b,
|
||||
-- otherwise just sort the keys
|
||||
if order then
|
||||
table.sort(keys, function(a,b) return order(_table, a, b) end)
|
||||
else
|
||||
table.sort(keys)
|
||||
end
|
||||
|
||||
-- return the iterator function
|
||||
local i = 0
|
||||
return function()
|
||||
i = i + 1
|
||||
if keys[i] then
|
||||
return keys[i], _table[keys[i]]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function show_status(storage, image, format, filename, number, total, high_quality, extra_data)
|
||||
dt.print(string.format(_("export image %i/%i"), number, total))
|
||||
end
|
||||
|
||||
local function create_geoJSON_file(storage, image_table, extra_data)
|
||||
if not df.check_if_bin_exists("mkdir") then
|
||||
dt.print_error("mkdir not found")
|
||||
return
|
||||
end
|
||||
if not df.check_if_bin_exists("convert") then
|
||||
dt.print_error("convert not found")
|
||||
return
|
||||
end
|
||||
if not df.check_if_bin_exists("xdg-open") then
|
||||
dt.print_error("xdg-open not found")
|
||||
return
|
||||
end
|
||||
if not df.check_if_bin_exists("xdg-user-dir") then
|
||||
dt.print_error("xdg-user-dir not found")
|
||||
return
|
||||
end
|
||||
|
||||
dt.print_error("Will try to export geoJSON file now")
|
||||
|
||||
local exportDirectory = dt.preferences.read("geoJSON_export","ExportDirectory","string")
|
||||
|
||||
-- Creates dir if not exsists
|
||||
local imageFoldername = "files/"
|
||||
local mkdirCommand = "mkdir -p "..exportDirectory.."/"..imageFoldername
|
||||
dt.control.execute( mkdirCommand)
|
||||
|
||||
-- Create the thumbnails
|
||||
for image,exported_image in pairs(image_table) do
|
||||
if ((image.longitude and image.latitude) and
|
||||
(image.longitude ~= 0 and image.latitude ~= 90) -- Sometimes the north-pole but most likely just wrong data
|
||||
) then
|
||||
local path, filename, filetype = string.match(exported_image, "(.-)([^\\/]-%.?([^%.\\/]*))$")
|
||||
filename = string.upper(string.gsub(filename,"%.%w*", ""))
|
||||
|
||||
-- convert -size 92x92 filename.jpg -resize 92x92 +profile "*" thumbnail.jpg
|
||||
-- In this example, '-size 120x120' gives a hint to the JPEG decoder that the image is going to be downscaled to
|
||||
-- 120x120, allowing it to run faster by avoiding returning full-resolution images to GraphicsMagick for the
|
||||
-- subsequent resizing operation. The '-resize 120x120' specifies the desired dimensions of the output image. It
|
||||
-- will be scaled so its largest dimension is 120 pixels. The '+profile "*"' removes any ICM, EXIF, IPTC, or other
|
||||
-- profiles that might be present in the input and aren't needed in the thumbnail.
|
||||
|
||||
local convertToThumbCommand = "convert -size 96x96 "..exported_image.." -resize 92x92 -mattecolor \"#FFFFFF\" -frame 2x2 +profile \"*\" "..exportDirectory.."/"..imageFoldername.."thumb_"..filename..".jpg"
|
||||
dt.control.execute( convertToThumbCommand)
|
||||
local concertCommand = "convert -size 438x438 "..exported_image.." -resize 438x438 +profile \"*\" "..exportDirectory.."/"..imageFoldername..filename..".jpg"
|
||||
dt.control.execute( concertCommand)
|
||||
end
|
||||
|
||||
-- delete the original image
|
||||
os.remove(exported_image)
|
||||
|
||||
local pattern = "[/]?([^/]+)$"
|
||||
filmName = string.match(image.film.path, pattern)
|
||||
end
|
||||
|
||||
local exportgeoJSONFilename = filmName..".geoJSON"
|
||||
local exportMapBoxHTMLFilename = filmName..".html"
|
||||
|
||||
-- Create the geoJSON file
|
||||
local geoJSON_file = [[
|
||||
{
|
||||
"type": "FeatureCollection",
|
||||
"features": [
|
||||
]]
|
||||
|
||||
for image,exported_image in pairs(image_table) do
|
||||
--filename = string.upper(string.gsub(image.filename,"%.", "_"))
|
||||
-- Extract filename, e.g DSC9784.ARW -> DSC9784
|
||||
filename = string.upper(string.gsub(image.filename,"%.%w*", ""))
|
||||
-- Extract extension from exported image (user can choose JPG or PNG), e.g DSC9784.JPG -> .JPG
|
||||
extension = string.match(exported_image,"%.%w*$")
|
||||
|
||||
if ((image.longitude and image.latitude) and
|
||||
(image.longitude ~= 0 and image.latitude ~= 90) -- Sometimes the north-pole but most likely just wrong data
|
||||
) then
|
||||
|
||||
local image_title, image_description
|
||||
if (image.title and image.title ~= "") then
|
||||
image_title = image.title
|
||||
else
|
||||
image_title = image.filename
|
||||
end
|
||||
image_description = image.description
|
||||
|
||||
geoJSON_file = geoJSON_file..
|
||||
[[ {
|
||||
"type": "Feature",
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
]]
|
||||
geoJSON_file = geoJSON_file.." \"coordinates\": ["..string.gsub(tostring(image.longitude),",", ".")..","..string.gsub(tostring(image.latitude),",", ".").."]\n"
|
||||
geoJSON_file = geoJSON_file..
|
||||
[[ },
|
||||
"properties": {
|
||||
"title": "]]..image_title..[[",
|
||||
"description": "]]..image_description..[[",
|
||||
"image": "]]..imageFoldername..filename..[[.jpg",
|
||||
"icon": {
|
||||
"iconUrl": "]]..imageFoldername.."thumb_"..filename..[[.jpg",
|
||||
"iconSize": [96, 65],
|
||||
"iconAnchor": [48, 32],
|
||||
"popupAnchor": [0, -32],
|
||||
"className": "dot"
|
||||
}
|
||||
}
|
||||
}
|
||||
,]]
|
||||
end
|
||||
end
|
||||
|
||||
geoJSON_file = geoJSON_file:sub(0,geoJSON_file:len()-1)
|
||||
geoJSON_file = geoJSON_file..
|
||||
[[
|
||||
]
|
||||
}
|
||||
]]
|
||||
|
||||
|
||||
--HTML
|
||||
if ( dt.preferences.read("geoJSON_export","CreateMapBoxHTMLFile","bool") == true ) then
|
||||
local mapBoxKey = dt.preferences.read("geoJSON_export","mapBoxKey","string")
|
||||
local mapBoxHTML_file = [[
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset=utf-8 />
|
||||
<title>]]..filmName..[[</title>
|
||||
<script src='https://api.tiles.mapbox.com/mapbox.js/v2.2.4/mapbox.js'></script>
|
||||
<link href='https://api.tiles.mapbox.com/mapbox.js/v2.2.4/mapbox.css' rel='stylesheet' />
|
||||
<style>
|
||||
body { margin:0; padding:0; }
|
||||
.map { position:absolute; top:0; bottom:0; width:100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id='map-tooltips-js' class='map'> </div>
|
||||
|
||||
<script>
|
||||
L.mapbox.accessToken = ']]..mapBoxKey..[[';
|
||||
var mapTooltipsJS = L.mapbox.map('map-tooltips-js', 'mapbox.light')
|
||||
.setView([0,0], 5);
|
||||
|
||||
var myLayer = L.mapbox.featureLayer()
|
||||
.loadURL(']]..exportgeoJSONFilename..[[')
|
||||
.addTo(mapTooltipsJS);
|
||||
|
||||
// Set a custom icon on each marker based on feature properties.
|
||||
myLayer.on('layeradd', function(e) {
|
||||
var marker = e.layer,
|
||||
feature = marker.feature;
|
||||
marker.setIcon(L.icon(feature.properties.icon));
|
||||
var content = '<h2>'+ feature.properties.title+'<\/h2>' + '<p>'+ feature.properties.description+'<\/p>' + '<img src="'+feature.properties.image+'" alt="">';
|
||||
marker.bindPopup(content,{minWidth: 457});
|
||||
});
|
||||
// Center on click
|
||||
myLayer.on('click', function(e) {
|
||||
mapTooltipsJS.panTo(e.layer.getLatLng());
|
||||
});
|
||||
|
||||
// https://www.mapbox.com/mapbox.js/example/v1.0.0/fit-map-to-markers/
|
||||
// Especially with dynamic data, it's useful to automatically fit all markers
|
||||
// in the map bounds instead of guessing and checking center and zoom values
|
||||
|
||||
// Since this layer is loaded with the asynchronous method loadURL, that uses
|
||||
// AJAX in the background, we wait for all of the markers to be loaded by
|
||||
// waiting for the ready event. If you don't load your markers with an async
|
||||
// method and instead set them with setGeoJSON or similar, you don't need
|
||||
// to do this.
|
||||
myLayer.on('ready', function() {
|
||||
// featureLayer.getBounds() returns the corners of the furthest-out markers,
|
||||
// and map.fitBounds() makes sure that the map contains these.
|
||||
mapTooltipsJS.fitBounds(myLayer.getBounds());
|
||||
});
|
||||
|
||||
// Lines
|
||||
// https://www.mapbox.com/mapbox.js/example/v1.0.0/line-marker/
|
||||
myLayer.on('ready', function(e) {
|
||||
var line = [];
|
||||
|
||||
this.eachLayer(function(marker) {
|
||||
line.push(marker.getLatLng());
|
||||
});
|
||||
|
||||
var polyline_options = {
|
||||
color: '#FF0000'
|
||||
};
|
||||
|
||||
var polyline = L.polyline(line, polyline_options).addTo(mapTooltipsJS);
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
]]
|
||||
|
||||
local file = io.open(exportDirectory.."/"..exportMapBoxHTMLFilename, "w")
|
||||
file:write(mapBoxHTML_file)
|
||||
file:close()
|
||||
end
|
||||
|
||||
local file = io.open(exportDirectory.."/"..exportgeoJSONFilename, "w")
|
||||
file:write(geoJSON_file)
|
||||
file:close()
|
||||
|
||||
dt.print(string.format(_("%s file created in %s"), "geoJSON", exportDirectory))
|
||||
|
||||
-- Open the file with the standard programm
|
||||
if ( dt.preferences.read("geoJSON_export","OpengeoJSONFile","bool") == true ) then
|
||||
local geoJSONFileOpenCommand
|
||||
geoJSONFileOpenCommand = "xdg-open "..exportDirectory.."/\""..exportgeoJSONFilename.."\""
|
||||
dt.control.execute( geoJSONFileOpenCommand)
|
||||
end
|
||||
|
||||
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.destroy_storage("geoJSON_export")
|
||||
end
|
||||
|
||||
-- Preferences
|
||||
dt.preferences.register("geoJSON_export",
|
||||
"CreateMapBoxHTMLFile",
|
||||
"bool",
|
||||
"geoJSON export: ".._("Create an additional HTML file"),
|
||||
_("creates an HTML file that loads the geoJSON file. (needs a MapBox key"),
|
||||
false )
|
||||
dt.preferences.register("geoJSON_export",
|
||||
"mapBoxKey",
|
||||
"string",
|
||||
"geoJSON export: MapBox " .. _("key"),
|
||||
"https://www.mapbox.com/studio/account/tokens",
|
||||
'' )
|
||||
dt.preferences.register("geoJSON_export",
|
||||
"OpengeoJSONFile",
|
||||
"bool",
|
||||
"geoJSON export: " .. _("open geoJSON file after export"),
|
||||
_("opens the geoJSON file after the export with the standard program for geoJSON files"),
|
||||
false )
|
||||
|
||||
local handle = io.popen("xdg-user-dir DESKTOP")
|
||||
local result = handle:read()
|
||||
handle:close()
|
||||
if (result == nil) then
|
||||
result = ""
|
||||
end
|
||||
dt.preferences.register("geoJSON_export",
|
||||
"ExportDirectory",
|
||||
"directory",
|
||||
_("geoJSON export: export directory"),
|
||||
_("a directory that will be used to export the geoJSON files"),
|
||||
result )
|
||||
|
||||
-- Register
|
||||
dt.register_storage("geoJSON_export", "geoJSON Export", nil, create_geoJSON_file)
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
return script_data
|
||||
793
contrib/geoToolbox.lua
Normal file
793
contrib/geoToolbox.lua
Normal file
@@ -0,0 +1,793 @@
|
||||
--[[
|
||||
This file is part of darktable,
|
||||
copyright (c) 2016 Tobias Jakobs
|
||||
copyright (c) 2025 Balázs Dura-Kovács
|
||||
|
||||
darktable is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
darktable is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with darktable. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
|
||||
USAGE
|
||||
* require this script from your main lua file
|
||||
To do this add this line to the file .config/darktable/luarc:
|
||||
require "geoToolbox"
|
||||
|
||||
* it creates a new geoToolbox lighttable module
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
local dtsys = require "lib/dtutils.system"
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
du.check_min_api_version("7.0.0", "geoToolbox")
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("geo toolbox"),
|
||||
purpose = _("geodata tools"),
|
||||
author = "Tobias Jakobs",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/geoToolbox"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
|
||||
local gT = {}
|
||||
gT.module_installed = false
|
||||
gT.event_registered = false
|
||||
|
||||
|
||||
-- <GUI>
|
||||
local labelDistance = dt.new_widget("label")
|
||||
labelDistance.label = _("distance:")
|
||||
|
||||
local checkbox_copy_gps_lat = dt.new_widget("check_button")
|
||||
{
|
||||
value = true
|
||||
}
|
||||
local entry_gps_lat = dt.new_widget("entry")
|
||||
{
|
||||
tooltip = _("latitude (editable)"),
|
||||
placeholder = _("latitude")
|
||||
}
|
||||
local checkbox_copy_gps_lon = dt.new_widget("check_button")
|
||||
{
|
||||
value = true
|
||||
}
|
||||
local entry_gps_lon = dt.new_widget("entry")
|
||||
{
|
||||
tooltip = _("longitude (editable)"),
|
||||
placeholder = _("longitude")
|
||||
}
|
||||
local checkbox_copy_gps_ele = dt.new_widget("check_button")
|
||||
{
|
||||
value = true
|
||||
}
|
||||
local entry_gps_ele = dt.new_widget("entry")
|
||||
{
|
||||
tooltip = _("elevation (editable)"),
|
||||
placeholder = _("elevation")
|
||||
}
|
||||
-- </GUI>
|
||||
|
||||
local function select_with_gps()
|
||||
local selection = {}
|
||||
job = dt.gui.create_job(_("GPS selection"), true, stop_selection)
|
||||
|
||||
for key,image in ipairs(dt.collection) do
|
||||
if job.valid then
|
||||
job.percent = (key-1)/#dt.collection
|
||||
|
||||
if (image.longitude and image.latitude) then
|
||||
table.insert(selection,image)
|
||||
end
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
dt.gui.selection(selection)
|
||||
job.valid = false
|
||||
end
|
||||
|
||||
local function select_without_gps()
|
||||
local selection = {}
|
||||
job = dt.gui.create_job(_("GPS selection"), true, stop_selection)
|
||||
|
||||
for key,image in ipairs(dt.collection) do
|
||||
if job.valid then
|
||||
job.percent = (key-1)/#dt.collection
|
||||
|
||||
if (not image.longitude and not image.latitude) then
|
||||
table.insert(selection,image)
|
||||
end
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
dt.gui.selection(selection)
|
||||
job.valid = false
|
||||
end
|
||||
|
||||
local function stop_selection(job)
|
||||
job.valid = false
|
||||
end
|
||||
|
||||
-- This is used for older images in the DB
|
||||
function isnan(x) return x ~= x end
|
||||
|
||||
-- Function from:
|
||||
-- https://forums.coronalabs.com/topic/29019-convert-string-to-date/
|
||||
-- This looks much scarier than it really is. The goal is to turn that string
|
||||
-- into a Unix timestamp (number of seconds since Jan 1, 1970, the standard
|
||||
-- used by most systems). So we use the string.match() method to fetch the
|
||||
-- various date and time parts into their own variables: xyear, xmonth,etc.
|
||||
function make_time_stamp(dateString)
|
||||
local convertedTimestamp
|
||||
--dt.print_error(dateString)
|
||||
if (dateString) then
|
||||
local pattern = "(%d+)%:(%d+)%:(%d+) (%d+):(%d+):(%d+)"
|
||||
local xyear, xmonth, xday, xhour, xminute, xseconds = dateString:match(pattern)
|
||||
--dt.print_error(xyear)
|
||||
convertedTimestamp = os.time({
|
||||
year = xyear,
|
||||
month = xmonth,
|
||||
day = xday,
|
||||
hour = xhour,
|
||||
min = xminute,
|
||||
sec = xseconds})
|
||||
else
|
||||
convertedTimestamp = 0
|
||||
end
|
||||
|
||||
return convertedTimestamp
|
||||
end
|
||||
|
||||
local first_have_data = false
|
||||
local first_latitude = ''
|
||||
local first_longitude = ''
|
||||
local first_elevation = ''
|
||||
local first_image_date = 0
|
||||
|
||||
local function get_first_coordinate()
|
||||
local sel_images = dt.gui.action_images
|
||||
|
||||
first_latitude = ''
|
||||
first_longitude = ''
|
||||
first_elevation = ''
|
||||
first_image_date = 0
|
||||
|
||||
for jj,image in ipairs(sel_images) do
|
||||
if not image then
|
||||
first_have_data = false
|
||||
else
|
||||
image_date = image.exif_datetime_taken
|
||||
first_have_data = true
|
||||
if (image.latitude) then
|
||||
first_latitude = image.latitude
|
||||
end
|
||||
if (image.longitude) then
|
||||
first_longitude = image.longitude
|
||||
end
|
||||
if (image.elevation) then
|
||||
first_elevation = image.elevation
|
||||
end
|
||||
if (image.exif_datetime_taken) then
|
||||
first_image_date = make_time_stamp(image.exif_datetime_taken)
|
||||
--dt.print_error(image.exif_datetime_taken)
|
||||
--dt.print_error(first_image_date)
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local second_have_data = false
|
||||
local second_latitude = ''
|
||||
local second_longitude = ''
|
||||
local second_elevation = ''
|
||||
local second_image_date = 0
|
||||
|
||||
local function get_second_coordinate()
|
||||
local sel_images = dt.gui.action_images
|
||||
|
||||
second_latitude = ''
|
||||
second_longitude = ''
|
||||
second_elevation = ''
|
||||
second_image_date = 0
|
||||
|
||||
for jj,image in ipairs(sel_images) do
|
||||
if not image then
|
||||
second_have_data = false
|
||||
else
|
||||
image_date = image.exif_datetime_taken
|
||||
second_have_data = true
|
||||
if (image.latitude) then
|
||||
second_latitude = image.latitude
|
||||
end
|
||||
if (image.longitude) then
|
||||
second_longitude = image.longitude
|
||||
end
|
||||
if (image.elevation) then
|
||||
second_elevation = image.elevation
|
||||
end
|
||||
if (image.exif_datetime_taken) then
|
||||
second_image_date = make_time_stamp(image.exif_datetime_taken)
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local calc_in_between_slider = dt.new_widget("slider")
|
||||
{
|
||||
label = "Position between",
|
||||
soft_min = 0, -- The soft minimum value for the slider, the slider can't go beyond this point
|
||||
soft_max = 100, -- The soft maximum value for the slider, the slider can't go beyond this point
|
||||
hard_min = -100, -- The hard minimum value for the slider, the user can't manually enter a value beyond this point
|
||||
hard_max = 200, -- The hard maximum value for the slider, the user can't manually enter a value beyond this point
|
||||
value = 50 -- The current value of the slider
|
||||
}
|
||||
|
||||
--ToDo: this needs more love
|
||||
local function calc_in_between()
|
||||
local sel_images = dt.gui.action_images
|
||||
for jj,image in ipairs(sel_images) do
|
||||
if image then
|
||||
image_date = make_time_stamp(image.exif_datetime_taken)
|
||||
if (first_have_data and second_have_data) then
|
||||
local start_new = 0
|
||||
local end_new = second_image_date - first_image_date
|
||||
local image_date_new = image_date - first_image_date
|
||||
|
||||
local percent_in_between
|
||||
if (end_new == 0) then
|
||||
percent_in_between = 1
|
||||
else
|
||||
percent_in_between = image_date_new/end_new
|
||||
end
|
||||
calc_in_between_slider.value = percent_in_between * 100
|
||||
dt.print_error(percent_in_between)
|
||||
local in_between_latitude = first_latitude + (second_latitude - first_latitude) * percent_in_between
|
||||
local in_between_longitude = first_longitude + (second_longitude - first_longitude) * percent_in_between
|
||||
|
||||
if (first_elevation and second_elevation) then
|
||||
local in_between_elevation = first_elevation + (second_elevation - first_elevation) * percent_in_between
|
||||
|
||||
dt.print_error(first_image_date)
|
||||
dt.print_error(second_image_date)
|
||||
dt.print_error(image_date)
|
||||
dt.print_error(first_elevation)
|
||||
dt.print_error(second_elevation)
|
||||
dt.print_error(in_between_elevation)
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local copy_gps_have_data = false
|
||||
local copy_gps_latitude = ''
|
||||
local copy_gps_longitude = ''
|
||||
local copy_gps_elevation = ''
|
||||
|
||||
local function copy_gps()
|
||||
local sel_images = dt.gui.action_images
|
||||
|
||||
copy_gps_latitude = ''
|
||||
copy_gps_longitude = ''
|
||||
copy_gps_elevation = ''
|
||||
|
||||
for jj,image in ipairs(sel_images) do
|
||||
if not image then
|
||||
copy_gps_have_data = false
|
||||
else
|
||||
copy_gps_have_data = true
|
||||
if (image.latitude and checkbox_copy_gps_lat.value) then
|
||||
copy_gps_latitude = image.latitude
|
||||
end
|
||||
if (image.longitude and checkbox_copy_gps_lon.value) then
|
||||
copy_gps_longitude = image.longitude
|
||||
end
|
||||
if (image.elevation and checkbox_copy_gps_ele.value) then
|
||||
copy_gps_elevation = image.elevation
|
||||
end
|
||||
end
|
||||
|
||||
entry_gps_lat.text = copy_gps_latitude
|
||||
entry_gps_lon.text = copy_gps_longitude
|
||||
entry_gps_ele.text = copy_gps_elevation
|
||||
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
local function paste_gps(image)
|
||||
local sel_images = dt.gui.action_images
|
||||
|
||||
for jj,image in ipairs(sel_images) do
|
||||
if (checkbox_copy_gps_lat.value) then
|
||||
image.latitude = entry_gps_lat.text
|
||||
end
|
||||
if (checkbox_copy_gps_lon.value) then
|
||||
image.longitude = entry_gps_lon.text
|
||||
end
|
||||
if (checkbox_copy_gps_ele.value) then
|
||||
image.elevation = entry_gps_ele.text
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function open_location_in_gnome_maps()
|
||||
|
||||
if not df.check_if_bin_exists("gnome-maps") then
|
||||
dt.print_error(_("gnome-maps not found"))
|
||||
return
|
||||
end
|
||||
|
||||
local sel_images = dt.gui.action_images
|
||||
|
||||
local lat1 = 0;
|
||||
local lon1 = 0;
|
||||
local i = 0;
|
||||
|
||||
-- Use the first image with geo information
|
||||
for jj,image in ipairs(sel_images) do
|
||||
if ((image.longitude and image.latitude) and
|
||||
(image.longitude ~= 0 and image.latitude ~= 90) -- Sometimes the north-pole but most likely just wrong data
|
||||
) then
|
||||
lat1 = string.gsub(image.latitude, ",", ".");
|
||||
lon1 = string.gsub(image.longitude, ",", ".");
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
local startCommand
|
||||
startCommand = "gnome-maps \"geo:" .. lat1 .. "," .. lon1 .."\""
|
||||
dt.print_error(startCommand)
|
||||
|
||||
dt.control.execute(startCommand)
|
||||
|
||||
end
|
||||
|
||||
-- Trim Funktion from: http://lua-users.org/wiki/StringTrim
|
||||
local function trim12(s)
|
||||
local from = s:match"^%s*()"
|
||||
return from > #s and "" or s:match(".*%S", from)
|
||||
end
|
||||
|
||||
local function reverse_geocode()
|
||||
|
||||
if not df.check_if_bin_exists("curl") then
|
||||
dt.print_error("curl not found")
|
||||
return
|
||||
end
|
||||
|
||||
if not df.check_if_bin_exists("jq") then
|
||||
dt.print_error("jq not found")
|
||||
return
|
||||
end
|
||||
|
||||
local sel_images = dt.gui.selection() --action_images
|
||||
|
||||
local lat1 = 0;
|
||||
local lon1 = 0;
|
||||
local i = 0;
|
||||
|
||||
-- Use the first image with geo information
|
||||
for jj,image in ipairs(sel_images) do
|
||||
if ((image.longitude and image.latitude) and
|
||||
(image.longitude ~= 0 and image.latitude ~= 90) -- Sometimes the north-pole but most likely just wrong data
|
||||
) then
|
||||
lat1 = string.gsub(image.latitude, ",", ".");
|
||||
lon1 = string.gsub(image.longitude, ",", ".");
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
local startCommand
|
||||
|
||||
local tokan = dt.preferences.read("geoToolbox","mapBoxKey","string")
|
||||
local types = "country";
|
||||
local types = "region";
|
||||
local types = "place";
|
||||
local types = "poi";
|
||||
|
||||
-- MapBox documentation
|
||||
-- https://www.mapbox.com/api-documentation/#retrieve-places-near-a-location
|
||||
-- curl -s, --silent Silent mode (don't output anything)
|
||||
-- jq could be replaced with a Lua JSON parser
|
||||
startCommand = string.format("curl --silent \"https://api.mapbox.com/geocoding/v5/mapbox.places/%s,%s.json?types=%s&access_token=%s\" | jq '.features | .[0] | '.text''", lon1, lat1, types, tokan)
|
||||
|
||||
local handle = io.popen(startCommand)
|
||||
local result = trim12(handle:read("*a"))
|
||||
handle:close()
|
||||
|
||||
-- Errorhandling would be nice
|
||||
--dt.print_error("startCommand: "..startCommand)
|
||||
--dt.print_error("result: '"..result.."'")
|
||||
|
||||
if (result ~= "null") then
|
||||
dt.print(string.sub(result, 2, string.len(result)-2))
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
-- I used code from here:
|
||||
-- http://stackoverflow.com/questions/27928/how-do-i-calculate-distance-between-two-latitude-longitude-points
|
||||
local function get_distance(lat1, lon1, ele1, lat2, lon2, ele2)
|
||||
|
||||
local earthRadius = 6371; -- Radius of the earth in km
|
||||
local dLat = math.rad(lat2-lat1); -- deg2rad below
|
||||
local dLon = math.rad(lon2-lon1);
|
||||
local a =
|
||||
math.sin(dLat/2) * math.sin(dLat/2) +
|
||||
math.cos(math.rad(lat1)) * math.cos(math.rad(lat2)) *
|
||||
math.sin(dLon/2) * math.sin(dLon/2)
|
||||
;
|
||||
local angle = 2 * math.atan(math.sqrt(a), math.sqrt(1-a));
|
||||
local distance = earthRadius * angle; -- Distance in km
|
||||
|
||||
-- Add the elevation to the calculation
|
||||
local elevation = 0
|
||||
elevation = math.abs(ele1 - ele2) / 1000; --in km
|
||||
|
||||
if (elevation > 0) then
|
||||
distance = math.sqrt(math.pow(elevation,2) + math.pow(distance,2) )
|
||||
end
|
||||
return distance
|
||||
end
|
||||
|
||||
local function calc_distance()
|
||||
|
||||
local lat1 = 0;
|
||||
local lon1 = 0;
|
||||
local lat2 = 0;
|
||||
local lon2 = 0;
|
||||
local ele1 = 0;
|
||||
local ele2 = 0;
|
||||
local i = 0;
|
||||
|
||||
local sel_images = dt.gui.selection()
|
||||
|
||||
for jj,image in ipairs(sel_images) do
|
||||
if ((image.longitude and image.latitude) and
|
||||
(image.longitude ~= 0 and image.latitude ~= 90) -- Sometimes the north-pole but most likely just wrong data
|
||||
) then
|
||||
|
||||
i = i + 1;
|
||||
|
||||
if (i == 1) then
|
||||
lat1 = image.latitude
|
||||
lon1 = image.longitude
|
||||
if (image.elevation) then
|
||||
ele1 = image.elevation
|
||||
else
|
||||
ele1 = 0
|
||||
end
|
||||
end
|
||||
|
||||
lat2 = image.latitude
|
||||
lon2 = image.longitude
|
||||
if (image.elevation) then
|
||||
ele2 = image.elevation
|
||||
else
|
||||
ele2 = 0;
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
local distance = get_distance(lat1, lon1, ele1, lat2, lon2, ele2)
|
||||
|
||||
if (distance < 1) then
|
||||
distance = distance * 1000
|
||||
distanceUnit = "m"
|
||||
else
|
||||
distanceUnit = "km"
|
||||
end
|
||||
|
||||
return string.format(_("distance: %.2f %s"), distance, distanceUnit)
|
||||
end
|
||||
|
||||
local function print_calc_distance()
|
||||
dt.print(calc_distance())
|
||||
end
|
||||
|
||||
local function toolbox_calc_distance()
|
||||
labelDistance.label = calc_distance()
|
||||
end
|
||||
|
||||
local altitude_file_chooser_button = dt.new_widget("file_chooser_button")
|
||||
{
|
||||
title = _("export altitude CSV"), -- The title of the window when choosing a file
|
||||
value = "", -- The currently selected file
|
||||
is_directory = true -- True if the file chooser button only allows directories to be selecte
|
||||
}
|
||||
|
||||
local altitude_filename = dt.new_widget("entry")
|
||||
{
|
||||
text = "altitude.csv",
|
||||
placeholder = "altitude.csv",
|
||||
editable = true,
|
||||
tooltip = _("name of the exported file"),
|
||||
reset_callback = function(self) self.text = "text" end
|
||||
}
|
||||
|
||||
local function altitude_profile()
|
||||
dt.print(_("start export"))
|
||||
local sel_images = dt.gui.action_images
|
||||
|
||||
local lat1 = 0;
|
||||
local lon1 = 0;
|
||||
local lat2 = 0;
|
||||
local lon2 = 0;
|
||||
local ele1 = 0;
|
||||
local ele2 = 0;
|
||||
local i = 0;
|
||||
local csv_file = '';
|
||||
csv_file = "km;m".."\n";
|
||||
|
||||
local distance = 0;
|
||||
local distanceFromStart = 0;
|
||||
local elevation = 0;
|
||||
local elevationAdd = 0;
|
||||
|
||||
local sel_images = dt.gui.action_images
|
||||
for jj,image in ipairs(sel_images) do
|
||||
if ((not isnan(image.longitude) and not isnan(image.latitude) and not isnan(image.elevation) and image.elevation) and
|
||||
(image.longitude ~= 0 and image.latitude ~= 90) -- Sometimes the north-pole but most likely just wrong data
|
||||
) then
|
||||
|
||||
i = i + 1;
|
||||
|
||||
if (i == 1) then
|
||||
lat1 = image.latitude
|
||||
lon1 = image.longitude
|
||||
ele1 = image.elevation
|
||||
else
|
||||
lat1 = lat2
|
||||
lon1 = lon2
|
||||
ele1 = ele2
|
||||
end
|
||||
|
||||
lat2 = image.latitude
|
||||
lon2 = image.longitude
|
||||
ele2 = image.elevation
|
||||
|
||||
if (i == 1) then
|
||||
distanceFromStart = 0
|
||||
else
|
||||
local distance = get_distance(lat1, lon1, ele1, lat2, lon2, ele2)
|
||||
distanceFromStart = distanceFromStart + distance;
|
||||
end
|
||||
|
||||
csv_file = csv_file .. distanceFromStart..";"..image.elevation.."\n";
|
||||
|
||||
end
|
||||
|
||||
end --for
|
||||
|
||||
local exportDirectory = altitude_file_chooser_button.value
|
||||
local exportFilename = altitude_filename.text
|
||||
if (exportFilename == '') then
|
||||
exportFilename = altitude_filename.placeholder
|
||||
end
|
||||
file = io.open(exportDirectory.."/"..exportFilename, "w")
|
||||
file:write(csv_file)
|
||||
file:close()
|
||||
dt.print(string.format(_("file created in %s"), exportDirectory))
|
||||
|
||||
end
|
||||
|
||||
local function install_module()
|
||||
if not gT.module_installed then
|
||||
dt.register_lib(
|
||||
"geoToolbox", -- Module name
|
||||
_("geo toolbox"), -- name
|
||||
true, -- expandable
|
||||
false, -- resetable
|
||||
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 100}}, -- containers
|
||||
gT.widget,
|
||||
nil,-- view_enter
|
||||
nil -- view_leave
|
||||
)
|
||||
gT.module_installed = true
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.gui.libs["geoToolbox"].visible = false
|
||||
dt.destroy_event("geoToolbox_cd", "shortcut")
|
||||
dt.destroy_event("geoToolbox", "mouse-over-image-changed")
|
||||
dt.destroy_event("geoToolbox_wg", "shortcut")
|
||||
dt.destroy_event("geoToolbox_ng", "shortcut")
|
||||
end
|
||||
|
||||
local function restart()
|
||||
dt.register_event("geoToolbox_cd", "shortcut",
|
||||
print_calc_distance, _("calculate the distance from latitude and longitude in km"))
|
||||
dt.register_event("geoToolbox", "mouse-over-image-changed",
|
||||
toolbox_calc_distance)
|
||||
|
||||
dt.register_event("geoToolbox_wg", "shortcut",
|
||||
select_with_gps, _("select all images with GPS information"))
|
||||
dt.register_event("geoToolbox_ng", "shortcut",
|
||||
select_without_gps, _("select all images without GPS information"))
|
||||
|
||||
dt.gui.libs["geoToolbox"].visible = true
|
||||
end
|
||||
|
||||
local function show()
|
||||
dt.gui.libs["geoToolbox"].visible = true
|
||||
end
|
||||
|
||||
|
||||
|
||||
local separator = dt.new_widget("separator"){}
|
||||
local separator2 = dt.new_widget("separator"){}
|
||||
local separator3 = dt.new_widget("separator"){}
|
||||
local separator4 = dt.new_widget("separator"){}
|
||||
local separator5 = dt.new_widget("separator"){}
|
||||
|
||||
gT.widget = dt.new_widget("box")
|
||||
{
|
||||
orientation = "vertical",
|
||||
dt.new_widget("button")
|
||||
{
|
||||
label = _("select geo images"),
|
||||
tooltip = _("select all images with GPS information"),
|
||||
clicked_callback = select_with_gps
|
||||
},
|
||||
dt.new_widget("button")
|
||||
{
|
||||
label = _("select non-geo images"),
|
||||
tooltip = _("select all images without GPS information"),
|
||||
clicked_callback = select_without_gps
|
||||
},
|
||||
separator,--------------------------------------------------------
|
||||
dt.new_widget("button")
|
||||
{
|
||||
label = _("copy GPS data"),
|
||||
tooltip = _("copy GPS data"),
|
||||
clicked_callback = copy_gps
|
||||
},
|
||||
dt.new_widget("box"){
|
||||
orientation = "horizontal",
|
||||
checkbox_copy_gps_lat,
|
||||
entry_gps_lat
|
||||
},
|
||||
dt.new_widget("box"){
|
||||
orientation = "horizontal",
|
||||
checkbox_copy_gps_lon,
|
||||
entry_gps_lon
|
||||
},
|
||||
dt.new_widget("box"){
|
||||
orientation = "horizontal",
|
||||
checkbox_copy_gps_ele,
|
||||
entry_gps_ele
|
||||
},
|
||||
dt.new_widget("button")
|
||||
{
|
||||
label = _("apply GPS data to image"),
|
||||
tooltip = _("apply GPS data to image"),
|
||||
clicked_callback = paste_gps
|
||||
},
|
||||
separator2,--------------------------------------------------------
|
||||
--ToDo: This need a better UI
|
||||
--[[
|
||||
dt.new_widget("button")
|
||||
{
|
||||
label = "get 1st coordinate",
|
||||
tooltip = "Select first image and click this button",
|
||||
clicked_callback = get_first_coordinate
|
||||
},
|
||||
dt.new_widget("button")
|
||||
{
|
||||
label = "get 2nd coordinate",
|
||||
tooltip = "Select second image and click this button",
|
||||
clicked_callback = get_second_coordinate
|
||||
},
|
||||
dt.new_widget("button")
|
||||
{
|
||||
label = "calc in between",
|
||||
tooltip = "Select third image and click this button",
|
||||
clicked_callback = calc_in_between
|
||||
},
|
||||
calc_in_between_slider,
|
||||
separator3,--------------------------------------------------------
|
||||
]]
|
||||
dt.new_widget("button")
|
||||
{
|
||||
label = _("open in Gnome Maps"),
|
||||
tooltip = _("open location in Gnome Maps"),
|
||||
clicked_callback = open_location_in_gnome_maps
|
||||
},
|
||||
separator4,--------------------------------------------------------
|
||||
dt.new_widget("button")
|
||||
{
|
||||
label = _("reverse geocode"),
|
||||
tooltip = _("this just shows the name of the location, but doesn't add it as tag"),
|
||||
clicked_callback = reverse_geocode
|
||||
},
|
||||
separator5,--------------------------------------------------------
|
||||
dt.new_widget("label"){label = _("altitude CSV export")},
|
||||
altitude_file_chooser_button,
|
||||
altitude_filename,
|
||||
dt.new_widget("button")
|
||||
{
|
||||
label = _("export altitude CSV file"),
|
||||
tooltip = _("create an altitude profile using the GPS data in the metadata"),
|
||||
clicked_callback = altitude_profile
|
||||
},
|
||||
labelDistance
|
||||
}
|
||||
|
||||
|
||||
if dt.gui.current_view().id == "lighttable" then
|
||||
install_module()
|
||||
else
|
||||
if not gT.event_registered then
|
||||
dt.register_event(
|
||||
"geoToolbox", "view-changed",
|
||||
function(event, old_view, new_view)
|
||||
if new_view.name == "lighttable" and old_view.name == "darkroom" then
|
||||
install_module()
|
||||
end
|
||||
end
|
||||
)
|
||||
gT.event_registered = true
|
||||
end
|
||||
end
|
||||
|
||||
-- Preferences
|
||||
dt.preferences.register("geoToolbox",
|
||||
"mapBoxKey",
|
||||
"string",
|
||||
_("geoToolbox export: MapBox Key"),
|
||||
"https://www.mapbox.com/studio/account/tokens",
|
||||
'' )
|
||||
|
||||
-- Register
|
||||
dt.register_event("geoToolbox_cd", "shortcut",
|
||||
print_calc_distance, _("calculate the distance from latitude and longitude in km"))
|
||||
dt.register_event("geoToolbox", "mouse-over-image-changed",
|
||||
toolbox_calc_distance)
|
||||
|
||||
dt.register_event("geoToolbox_wg", "shortcut",
|
||||
select_with_gps, _("select all images with GPS information"))
|
||||
dt.register_event("geoToolbox_ng", "shortcut",
|
||||
select_without_gps, _("select all images without GPS information"))
|
||||
|
||||
script_data.destroy = destroy
|
||||
script_data.restart = restart
|
||||
script_data.destroy_method = "hide"
|
||||
script_data.show = show
|
||||
|
||||
return script_data
|
||||
-- vim: shiftwidth=2 expandtab tabstop=2 cindent syntax=lua
|
||||
-- kate: hl Lua;
|
||||
225
contrib/gimp.lua
Normal file
225
contrib/gimp.lua
Normal file
@@ -0,0 +1,225 @@
|
||||
--[[
|
||||
|
||||
gimp.lua - export and edit with GIMP
|
||||
|
||||
Copyright (C) 2016 Bill Ferguson <wpferguson@gmail.com>.
|
||||
|
||||
Portions are lifted from hugin.lua and thus are
|
||||
|
||||
Copyright (c) 2014 Wolfgang Goetz
|
||||
Copyright (c) 2015 Christian Kanzian
|
||||
Copyright (c) 2015 Tobias Jakobs
|
||||
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
gimp - export an image and open with GIMP for editing
|
||||
|
||||
This script provides another storage (export target) for darktable. Selected
|
||||
images are exported in the specified format to temporary storage. GIMP is launched
|
||||
and opens the files. After editing, the exported images are overwritten to save the
|
||||
changes. When GIMP exits, the exported files are moved into the current collection
|
||||
and imported into the database. The imported files then show up grouped with the
|
||||
originally selected images.
|
||||
|
||||
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
* GIMP - http://www.gimp.org
|
||||
|
||||
USAGE
|
||||
* require this script from your main lua file
|
||||
* select an image or images for editing with GIMP
|
||||
* in the export dialog select "Edit with GIMP" and select the format and bit depth for the
|
||||
exported image. Check the "run_detached" button to run GIMP in detached mode. Images
|
||||
will not be returned to darktable in this mode, but additional images can be sent to
|
||||
GIMP without stopping it.
|
||||
* Press "export"
|
||||
* Edit the image with GIMP then save the changes with File->Overwrite....
|
||||
* Exit GIMP
|
||||
* The edited image will be imported and grouped with the original image
|
||||
|
||||
CAVEATS
|
||||
* Developed and tested on Ubuntu 14.04 LTS with darktable 2.0.3 and GIMP 2.9.3 (development version with
|
||||
> 8 bit color)
|
||||
* There is no provision for dealing with the xcf files generated by GIMP, since darktable doesn't deal with
|
||||
them. You may want to save the xcf file if you intend on doing further edits to the image or need to save
|
||||
the layers used. Where you save them is up to you.
|
||||
|
||||
BUGS, COMMENTS, SUGGESTIONS
|
||||
* Send to Bill Ferguson, wpferguson@gmail.com
|
||||
|
||||
CHANGES
|
||||
* 20160823 - os.rename doesn't work across filesystems. Added fileCopy and fileMove functions to move the file
|
||||
from the temporary location to the collection location irregardless of what filesystem it is on. If an
|
||||
issue is encountered, a message is printed back to the UI so the user isn't left wondering what happened.
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
local dtsys = require "lib/dtutils.system"
|
||||
local gettext = dt.gettext.gettext
|
||||
local gimp_widget = nil
|
||||
|
||||
du.check_min_api_version("7.0.0", "gimp")
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("edit with GIMP"),
|
||||
purpose = _("export and edit with GIMP"),
|
||||
author = "Bill Ferguson <wpferguson@gmail.com>",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/gimp"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
local function group_if_not_member(img, new_img)
|
||||
local image_table = img:get_group_members()
|
||||
local is_member = false
|
||||
for _,image in ipairs(image_table) do
|
||||
dt.print_log(image.filename .. " is a member")
|
||||
if image.filename == new_img.filename then
|
||||
is_member = true
|
||||
dt.print_log("Already in group")
|
||||
end
|
||||
end
|
||||
if not is_member then
|
||||
dt.print_log("group leader is "..img.group_leader.filename)
|
||||
new_img:group_with(img.group_leader)
|
||||
dt.print_log("Added to group")
|
||||
end
|
||||
end
|
||||
|
||||
local function show_status(storage, image, format, filename,
|
||||
number, total, high_quality, extra_data)
|
||||
dt.print(string.format(_("export image %i/%i"), number, total))
|
||||
end
|
||||
|
||||
local function gimp_edit(storage, image_table, extra_data) --finalize
|
||||
|
||||
local run_detached = dt.preferences.read("gimp", "run_detached", "bool")
|
||||
|
||||
local gimp_executable = df.check_if_bin_exists("gimp")
|
||||
|
||||
if not gimp_executable then
|
||||
dt.print_error("GIMP not found")
|
||||
return
|
||||
end
|
||||
|
||||
if dt.configuration.running_os == "macos" then
|
||||
if run_detached then
|
||||
gimp_executable = "open -a " .. gimp_executable
|
||||
else
|
||||
gimp_executable = "open -W -a " .. gimp_executable
|
||||
end
|
||||
end
|
||||
|
||||
-- list of exported images
|
||||
local img_list
|
||||
|
||||
-- reset and create image list
|
||||
img_list = ""
|
||||
|
||||
for _,exp_img in pairs(image_table) do
|
||||
exp_img = df.sanitize_filename(exp_img)
|
||||
img_list = img_list ..exp_img.. " "
|
||||
end
|
||||
|
||||
dt.print(_("launching GIMP..."))
|
||||
|
||||
local gimpStartCommand
|
||||
gimpStartCommand = gimp_executable .. " " .. img_list
|
||||
|
||||
if run_detached then
|
||||
if dt.configuration.running_os == "windows" then
|
||||
gimpStartCommand = "start /b \"\" " .. gimpStartCommand
|
||||
else
|
||||
gimpStartCommand = gimpStartCommand .. " &"
|
||||
end
|
||||
end
|
||||
|
||||
dt.print_log(gimpStartCommand)
|
||||
|
||||
dtsys.external_command(gimpStartCommand)
|
||||
|
||||
if not run_detached then
|
||||
|
||||
-- for each of the image, exported image pairs
|
||||
-- move the exported image into the directory with the original
|
||||
-- then import the image into the database which will group it with the original
|
||||
-- and then copy over any tags other than darktable tags
|
||||
|
||||
for image,exported_image in pairs(image_table) do
|
||||
|
||||
local myimage_name = image.path .. "/" .. df.get_filename(exported_image)
|
||||
|
||||
while df.check_if_file_exists(myimage_name) do
|
||||
myimage_name = df.filename_increment(myimage_name)
|
||||
-- limit to 99 more exports of the original export
|
||||
if string.match(df.get_basename(myimage_name), "_(d-)$") == "99" then
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
dt.print_log("moving " .. exported_image .. " to " .. myimage_name)
|
||||
local result = df.file_move(exported_image, myimage_name)
|
||||
|
||||
if result then
|
||||
dt.print_log("importing file")
|
||||
local myimage = dt.database.import(myimage_name)
|
||||
|
||||
group_if_not_member(image, myimage)
|
||||
|
||||
for _,tag in pairs(dt.tags.get_tags(image)) do
|
||||
if not (string.sub(tag.name,1,9) == "darktable") then
|
||||
dt.print_log("attaching tag")
|
||||
dt.tags.attach(tag,myimage)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.destroy_storage("module_gimp")
|
||||
end
|
||||
|
||||
-- Register
|
||||
|
||||
gimp_widget = dt.new_widget("check_button"){
|
||||
label = _("run detached"),
|
||||
tooltip = _("don't import resulting image back into darktable"),
|
||||
value = dt.preferences.read("gimp", "run_detached", "bool"),
|
||||
clicked_callback = function(this)
|
||||
dt.preferences.write("gimp", "run_detached", "bool", this.value)
|
||||
end
|
||||
}
|
||||
|
||||
dt.register_storage("module_gimp", _("edit with GIMP"), show_status, gimp_edit, nil, nil, gimp_widget)
|
||||
|
||||
--
|
||||
script_data.destroy = destroy
|
||||
|
||||
return script_data
|
||||
@@ -1,51 +0,0 @@
|
||||
--[[
|
||||
This file is part of darktable,
|
||||
Copyright 2014 by Tobias Jakobs.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
darktable select images with or without GPS informations
|
||||
|
||||
USAGE
|
||||
* require this script from your main lua file
|
||||
* register a shortcuts
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
dt.configuration.check_version(...,{2,0,1})
|
||||
table = require "table"
|
||||
|
||||
local function selectWithGPS()
|
||||
local selection = {}
|
||||
for _,image in ipairs(dt.database) do
|
||||
if (image.longitude and image.latitude) then
|
||||
table.insert(selection,image)
|
||||
end
|
||||
end
|
||||
dt.gui.selection(selection)
|
||||
end
|
||||
|
||||
local function selectWithoutGPS()
|
||||
local selection = {}
|
||||
for _,image in ipairs(dt.database) do
|
||||
if (not image.longitude and not image.latitude) then
|
||||
table.insert(selection,image)
|
||||
end
|
||||
end
|
||||
dt.gui.selection(selection)
|
||||
end
|
||||
|
||||
dt.register_event("shortcut", selectWithGPS, "Select all images with GPS information")
|
||||
dt.register_event("shortcut", selectWithoutGPS, "Select all images without GPS information")
|
||||
213
contrib/gpx_export.lua
Normal file
213
contrib/gpx_export.lua
Normal file
@@ -0,0 +1,213 @@
|
||||
--[[
|
||||
This file is part of darktable,
|
||||
copyright (c) 2017 Jannis_V
|
||||
|
||||
darktable is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
darktable is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with darktable. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
|
||||
--[[
|
||||
Simple darktable GPX generator script
|
||||
|
||||
This script generates a GPX track from all images having GPS latitude
|
||||
and longitude information.
|
||||
For each source folder, a separate <trk> is generated in the gpx file.
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local df = require "lib/dtutils.file"
|
||||
local dl = require "lib/dtutils"
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
dl.check_min_api_version("7.0.0", "gpx_export")
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("gpx export"),
|
||||
purpose = _("export gpx information to a file"),
|
||||
author = "Jannis_V",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/gpx_export"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
local gpx = {}
|
||||
gpx.module_installed = false
|
||||
gpx.event_registered = false
|
||||
|
||||
local path_entry = dt.new_widget("entry")
|
||||
{
|
||||
text = dt.preferences.read("gpx_exporter", "gpxExportPath", "string"),
|
||||
editable=true,
|
||||
reset_callback = function(self)
|
||||
self.text = "~/darktable.gpx"
|
||||
dt.preferences.write("gpx_exporter", "gpxExportPath", "string", self.text)
|
||||
end,
|
||||
tooltip = _("gpx file path"),
|
||||
}
|
||||
|
||||
local function stop_job(job)
|
||||
job.valid = false
|
||||
end
|
||||
|
||||
local function create_gpx_file()
|
||||
dt.preferences.write("gpx_exporter", "gpxExportPath", "string", path_entry.text)
|
||||
|
||||
path = path_entry.text:gsub("^~", os.getenv("HOME")) -- Expand ~ to home
|
||||
path = path:gsub("//", "/")
|
||||
|
||||
dt.print(_("exporting gpx file..."))
|
||||
|
||||
job = dt.gui.create_job(_("gpx export"), true, stop_job)
|
||||
|
||||
local sel_images = dt.gui.action_images
|
||||
local segments = {}
|
||||
for key,image in dl.spairs(sel_images, function(t, a, b) return t[b].path > t[a].path end) do
|
||||
|
||||
print(image.path)
|
||||
|
||||
if (job.valid) then
|
||||
job.percent = (key - 1) / #sel_images
|
||||
|
||||
if ((image.longitude and image.latitude) and
|
||||
(image.longitude ~= 0 and image.latitude ~= 90) -- Just in case
|
||||
) then
|
||||
if (segments[image.path] == nil) then
|
||||
segments[image.path] = {}
|
||||
end
|
||||
|
||||
if (image.exif_datetime_taken == "") then
|
||||
dt.print(image.path.."/"..image.filename.._(" does not have date information and won't be processed"))
|
||||
print(image.path.."/"..image.filename.._(" does not have date information and won't be processed")) -- Also print to terminal
|
||||
else
|
||||
segments[image.path][image.filename] = image
|
||||
end
|
||||
end
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
local gpx_file = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\" ?>\n"
|
||||
gpx_file = gpx_file.."<gpx xmlns=\"http://www.topografix.com/GPX/1/1\" creator=\"Darktable GPX Exporter\"\n"
|
||||
gpx_file = gpx_file.."version=\"1.1\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"\n"
|
||||
gpx_file = gpx_file.."xsi:schemaLocation=\"http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd\">\n"
|
||||
|
||||
for key, folder in dl.spairs(segments) do
|
||||
gpx_file = gpx_file.."\t<trk>\n"
|
||||
gpx_file = gpx_file.."\t\t<name>"..key.."</name>\n";
|
||||
gpx_file = gpx_file.."\t\t<trkseg>\n";
|
||||
for key2, image in dl.spairs(folder, function(t, a, b) return t[b].exif_datetime_taken > t[a].exif_datetime_taken end) do
|
||||
date_format = "(%d+):(%d+):(%d+) (%d+):(%d+):(%d+)"
|
||||
my_year, my_month, my_day, my_hour, my_min, my_sec = image.exif_datetime_taken:match(date_format)
|
||||
|
||||
local my_timestamp = os.time({year=my_year, month=my_month, day=my_day, hour=my_hour, min=my_min, sec=my_sec})
|
||||
|
||||
gpx_file = gpx_file.."\t\t\t<trkpt lat=\""..string.gsub(tostring(image.latitude), ",", ".").."\" lon=\""..string.gsub(tostring(image.longitude), ",", ".").."\">\n"
|
||||
gpx_file = gpx_file.."\t\t\t\t<time>"..os.date("!%Y-%m-%dT%H:%M:%SZ", my_timestamp).."</time>\n"
|
||||
gpx_file = gpx_file.."\t\t\t\t<name>"..image.path.."/"..image.filename.."</name>\n"
|
||||
gpx_file = gpx_file.."\t\t\t</trkpt>\n"
|
||||
end
|
||||
gpx_file = gpx_file.."\t\t</trkseg>\n";
|
||||
gpx_file = gpx_file.."\t</trk>\n";
|
||||
end
|
||||
|
||||
job.valid = false
|
||||
|
||||
gpx_file = gpx_file.."</gpx>\n";
|
||||
|
||||
local file = io.open(path, "w")
|
||||
if (file == nil) then
|
||||
dt.print(string.format(_("invalid path: %s"), path))
|
||||
else
|
||||
file:write(gpx_file)
|
||||
file:close()
|
||||
dt.print(string.format(_("gpx file created: "), path))
|
||||
end
|
||||
end
|
||||
|
||||
local function install_module()
|
||||
if not gpx.module_installed then
|
||||
dt.register_lib(
|
||||
"gpx_exporter",
|
||||
_("gpx export"),
|
||||
true, -- expandable
|
||||
true, -- resetable
|
||||
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 100}}, -- containers
|
||||
gpx.widget,
|
||||
nil,-- view_enter
|
||||
nil -- view_leave
|
||||
)
|
||||
gpx.module_installed = true
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.gui.libs["gpx_exporter"].visible = false
|
||||
end
|
||||
|
||||
local function restart()
|
||||
dt.gui.libs["gpx_exporter"].visible = true
|
||||
end
|
||||
|
||||
gpx.widget = dt.new_widget("box")
|
||||
{
|
||||
orientation = "vertical",
|
||||
dt.new_widget("button")
|
||||
{
|
||||
label = _("export"),
|
||||
tooltip = _("export gpx file"),
|
||||
clicked_callback = create_gpx_file
|
||||
},
|
||||
dt.new_widget("box")
|
||||
{
|
||||
orientation = "horizontal",
|
||||
dt.new_widget("label")
|
||||
{
|
||||
label = _("file:"),
|
||||
},
|
||||
path_entry
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if dt.gui.current_view().id == "lighttable" then
|
||||
install_module()
|
||||
else
|
||||
if not gpx.event_registered then
|
||||
dt.register_event(
|
||||
"gpx_export", "view-changed",
|
||||
function(event, old_view, new_view)
|
||||
if new_view.name == "lighttable" and old_view.name == "darkroom" then
|
||||
install_module()
|
||||
end
|
||||
end
|
||||
)
|
||||
gpx.event_registered = true
|
||||
end
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
script_data.restart = restart
|
||||
script_data.destroy_method = "hide"
|
||||
script_data.show = restart
|
||||
|
||||
return script_data
|
||||
97
contrib/harmonic_armature_guide.lua
Normal file
97
contrib/harmonic_armature_guide.lua
Normal file
@@ -0,0 +1,97 @@
|
||||
--[[
|
||||
harmonic artmature guide for darktable
|
||||
|
||||
copyright (c) 2021 Hubert Kowalski
|
||||
|
||||
darktable is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
darktable is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with darktable. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
|
||||
--[[
|
||||
HARMONIC ARMATURE GUIDE
|
||||
Harmonic Armature (also known as 14 line armature)
|
||||
|
||||
INSTALLATION
|
||||
* copy this file in $CONFIGDIR/lua/contrib where CONFIGDIR is your darktable configuration directory
|
||||
* add the following line in the file $CONFIGDIR/luarc
|
||||
require "contrib/harmonic_armature_guide"
|
||||
|
||||
USAGE
|
||||
* when using guides, select "harmonic armature" as guide
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
du.check_min_api_version("2.0.0", "harmonic_armature_guide")
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("harmonic armature guide"),
|
||||
purpose = _("harmonic artmature guide"),
|
||||
author = "Hubert Kowalski",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/harmonic_armature_guide"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
dt.guides.register_guide("harmonic armature",
|
||||
-- draw
|
||||
function(cairo, x, y, width, height, zoom_scale)
|
||||
cairo:save()
|
||||
|
||||
cairo:translate(x, y)
|
||||
cairo:scale(width, height)
|
||||
|
||||
cairo:move_to(0,0)
|
||||
cairo:line_to(1, 0.5)
|
||||
cairo:line_to(0.5, 1)
|
||||
cairo:line_to(0,0)
|
||||
cairo:line_to(1, 1)
|
||||
cairo:line_to(0.5, 0)
|
||||
cairo:line_to(0, 0.5)
|
||||
cairo:line_to(1, 1)
|
||||
|
||||
cairo:move_to(1, 0)
|
||||
cairo:line_to(0, 0.5)
|
||||
cairo:line_to(0.5, 1)
|
||||
cairo:line_to(1, 0)
|
||||
cairo:line_to(0, 1)
|
||||
cairo:line_to(0.5, 0)
|
||||
cairo:line_to(1, 0.5)
|
||||
cairo:line_to(0, 1)
|
||||
|
||||
cairo:restore()
|
||||
end,
|
||||
-- gui
|
||||
function()
|
||||
return dt.new_widget("label"){label = _("harmonic armature"), halign = "start"}
|
||||
end
|
||||
)
|
||||
|
||||
local function destroy()
|
||||
-- nothing to destroy
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
return script_data
|
||||
200
contrib/hif_group_leader.lua
Normal file
200
contrib/hif_group_leader.lua
Normal file
@@ -0,0 +1,200 @@
|
||||
--[[
|
||||
|
||||
hif_group_leader.lua - Make hif image group leader
|
||||
|
||||
Copyright (C) 2024 Bill Ferguson <wpferguson@gmail.com>.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
hif_group_leader - Make hif image group leader
|
||||
|
||||
After a film roll is imported, check for RAW-JPG image groups
|
||||
and make the JPG image the group leader. This is on by default
|
||||
but can be disabled in preferences.
|
||||
|
||||
Shortcuts are included to filter existing collections or
|
||||
selections of images and make the hif the group leader.
|
||||
|
||||
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
None
|
||||
|
||||
USAGE
|
||||
Start script from script_manager
|
||||
Assign keys to the shortcuts
|
||||
|
||||
BUGS, COMMENTS, SUGGESTIONS
|
||||
Bill Ferguson <wpferguson@gmail.com>
|
||||
|
||||
CHANGES
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- C O N S T A N T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local MODULE = "hif_group_leader"
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- A P I C H E C K
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
du.check_min_api_version("7.0.0", MODULE)
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- I 1 8 N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- S C R I P T M A N A G E R I N T E G R A T I O N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("HIF group leader"),
|
||||
purpose = _("make hif image group leader"),
|
||||
author = "Bill Ferguson <wpferguson@gmail.com>",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/hif_group_leader"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- P R E F E R E N C E S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
dt.preferences.register(MODULE, "on_import", "bool", _("make hif group leader on import"), _("automatically make the hif file the group leader when raw + hif are imported"), true)
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- N A M E S P A C E
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local jgloi = {}
|
||||
jgloi.images = {}
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- F U N C T I O N S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function toggle_global_toolbox_grouping()
|
||||
dt.gui.libs.global_toolbox.grouping = false
|
||||
dt.gui.libs.global_toolbox.grouping = true
|
||||
end
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- M A I N P R O G R A M
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function make_hif_group_leader(images)
|
||||
-- if the image is part of a group, make it the leader
|
||||
for _, image in ipairs(images) do
|
||||
if #image:get_group_members() > 1 then
|
||||
image:make_group_leader()
|
||||
end
|
||||
end
|
||||
if dt.gui.libs.global_toolbox.grouping then
|
||||
-- toggle the grouping to make the new leader show
|
||||
toggle_global_toolbox_grouping()
|
||||
end
|
||||
end
|
||||
|
||||
local function make_existing_hif_group_leader(images)
|
||||
for _, image in ipairs(images) do
|
||||
if string.lower(df.get_filetype(image.filename)) == "hif" then
|
||||
if #image:get_group_members() > 1 then
|
||||
image:make_group_leader()
|
||||
end
|
||||
end
|
||||
end
|
||||
if dt.gui.libs.global_toolbox.grouping then
|
||||
-- toggle the grouping to make the new leader show
|
||||
toggle_global_toolbox_grouping()
|
||||
end
|
||||
end
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- D A R K T A B L E I N T E G R A T I O N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function destroy()
|
||||
if dt.preferences.read(MODULE, "on_import", "bool") then
|
||||
dt.destroy_event(MODULE, "post-import-film")
|
||||
dt.destroy_event(MODULE, "post-import-image")
|
||||
end
|
||||
dt.destroy_event(MODULE .. "_collect", "shortcut")
|
||||
dt.destroy_event(MODULE .. "_select", "shortcut")
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- E V E N T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
if dt.preferences.read(MODULE, "on_import", "bool") then
|
||||
dt.register_event(MODULE, "post-import-film",
|
||||
function(event, film_roll)
|
||||
-- ignore the film roll, it contains all the images, not just the imported
|
||||
local images = jgloi.images
|
||||
if #images > 0 then
|
||||
jgloi.images = {}
|
||||
make_hif_group_leader(images)
|
||||
end
|
||||
end
|
||||
)
|
||||
|
||||
dt.register_event(MODULE, "post-import-image",
|
||||
function(event, image)
|
||||
if string.lower(df.get_filetype(image.filename)) == "hif" then
|
||||
table.insert(jgloi.images, image)
|
||||
end
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
dt.register_event(MODULE .. "_collect", "shortcut",
|
||||
function(event, shortcut)
|
||||
-- ignore the film roll, it contains all the images, not just the imported
|
||||
local images = dt.collection
|
||||
make_existing_hif_group_leader(images)
|
||||
end,
|
||||
_("make hif group leader for collection")
|
||||
)
|
||||
|
||||
dt.register_event(MODULE .. "_select", "shortcut",
|
||||
function(event, shortcut)
|
||||
local images = dt.gui.selection()
|
||||
make_existing_hif_group_leader(images)
|
||||
end,
|
||||
_("make hif group leader for selection")
|
||||
)
|
||||
|
||||
return script_data
|
||||
@@ -24,96 +24,225 @@ HUGIN
|
||||
Add a new storage option to send images to hugin.
|
||||
Images are exported to darktable tmp dir first.
|
||||
|
||||
ADDITIANAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
* hugin
|
||||
|
||||
USAGE
|
||||
* require this file from your main luarc config file.
|
||||
* require this file from your main luarc config file
|
||||
* set the hugin tool paths (on some platforms)
|
||||
* if hugin gui mode is used, save the final result in the tmp directory with the first file name and _pano as suffix for the image to be automatically imported to DT afterwards
|
||||
|
||||
This plugin will add a new storage option and calls hugin after export.
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
local log = require "lib/dtutils.log"
|
||||
local dtsys = require "lib/dtutils.system"
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
-- should work with darktable API version 2.0.0
|
||||
dt.configuration.check_version(...,{2,0,0})
|
||||
local namespace = 'module_hugin'
|
||||
local user_pref_str = 'prefer_gui'
|
||||
local user_prefer_gui = dt.preferences.read(namespace, user_pref_str, "bool")
|
||||
log.msg(log.info, "user_prefer_gui set to ", user_prefer_gui)
|
||||
local hugin_widget = nil
|
||||
local exec_widget = nil
|
||||
local executable_table = {"hugin", "hugin_executor", "pto_gen"}
|
||||
|
||||
local function checkIfBinExists(bin)
|
||||
local handle = io.popen("which "..bin)
|
||||
local result = handle:read()
|
||||
local ret
|
||||
handle:close()
|
||||
if (result) then
|
||||
dt.print_error("true checkIfBinExists: "..bin)
|
||||
ret = true
|
||||
else
|
||||
dt.print_error(bin.." not found")
|
||||
ret = false
|
||||
-- get the proper path quoting quote
|
||||
local PQ = dt.configuration.running_os == "windows" and '"' or "'"
|
||||
|
||||
-- works with darktable API version from 5.0.0 on
|
||||
du.check_min_api_version("7.0.0", "hugin")
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
return ret
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("hugin"),
|
||||
purpose = _("stitch images into a panorama"),
|
||||
author = "Wolfgang Goetz",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/hugin"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
local function user_preference_changed(widget)
|
||||
user_prefer_gui = widget.value
|
||||
dt.preferences.write(namespace, user_pref_str, "bool", user_prefer_gui)
|
||||
end
|
||||
|
||||
local function show_status(storage, image, format, filename,
|
||||
number, total, high_quality, extra_data)
|
||||
dt.print("Export to Hugin "..tostring(number).."/"..tostring(total))
|
||||
dt.print(string.format(_("exporting to hugin: %d / %d"), number, total))
|
||||
end
|
||||
|
||||
local function create_panorama(storage, image_table, extra_data) --finalize
|
||||
if not checkIfBinExists("hugin") then
|
||||
darktable.print_error("hugin not found")
|
||||
return
|
||||
end
|
||||
|
||||
-- Since Hugin 2015.0.0 hugin provides a command line tool to start the assistant
|
||||
-- http://wiki.panotools.org/Hugin_executor
|
||||
-- We need pto_gen to create pto file for hugin_executor
|
||||
-- http://hugin.sourceforge.net/docs/manual/Pto_gen.html
|
||||
|
||||
local hugin_executor = false
|
||||
if (checkIfBinExists("hugin_executor") and checkIfBinExists("pto_gen")) then
|
||||
hugin_executor = true
|
||||
-- save the current log level and set log level to
|
||||
-- log.error for normal operations
|
||||
-- log.info for more insight about what is happening
|
||||
-- log.debug for even more information
|
||||
|
||||
local saved_log_level = log.log_level()
|
||||
log.log_level(log.error)
|
||||
|
||||
local hugin = df.check_if_bin_exists("hugin")
|
||||
log.msg(log.info, "hugin set to ", hugin)
|
||||
local hugin_executor = df.check_if_bin_exists("hugin_executor")
|
||||
log.msg(log.info, "hugin_executor set to ", hugin_executor)
|
||||
local pto_gen = df.check_if_bin_exists("pto_gen")
|
||||
log.msg(log.info, "pto_gen set to ", pto_gen)
|
||||
|
||||
local gui_available = false
|
||||
if hugin then
|
||||
gui_available = true
|
||||
else
|
||||
dt.print(_("hugin is not found, did you set the path?"))
|
||||
log.msg(log.error, "hugin executable not found. Check if the executable is installed.")
|
||||
log.msg(log.error, "If the executable is installed, check that the path is set correctly.")
|
||||
return
|
||||
end
|
||||
|
||||
local cmd_line_available = false
|
||||
if hugin_executor and pto_gen then
|
||||
cmd_line_available = true
|
||||
end
|
||||
|
||||
-- list of exported images
|
||||
local img_list
|
||||
local img_list = ""
|
||||
local img_set = {}
|
||||
|
||||
-- reset and create image list
|
||||
img_list = ""
|
||||
|
||||
for _,v in pairs(image_table) do
|
||||
img_list = img_list ..v.. " "
|
||||
for k,v in pairs(image_table) do
|
||||
log.msg(log.debug, "k is ", k, " and v is ", v)
|
||||
img_list = img_list .. PQ .. v .. PQ .. ' ' -- surround the filename with single quotes to handle spaces
|
||||
table.insert(img_set, k)
|
||||
end
|
||||
|
||||
dt.print("Will try to stitch now")
|
||||
log.msg(log.info, "img_list is ", img_list)
|
||||
|
||||
local huginStartCommand
|
||||
if (hugin_executor) then
|
||||
huginStartCommand = "pto_gen "..img_list.." -o "..dt.configuration.tmp_dir.."/project.pto"
|
||||
dt.print("Creating pto file")
|
||||
coroutine.yield("RUN_COMMAND", huginStartCommand)
|
||||
-- use first file as basename for output file
|
||||
table.sort(img_set, function(a,b) return a.filename<b.filename end);
|
||||
local first_file
|
||||
for _, k in ipairs(img_set) do
|
||||
first_file = k
|
||||
break
|
||||
end
|
||||
|
||||
dt.print("Running Assistent")
|
||||
huginStartCommand = "hugin_executor --assistant "..dt.configuration.tmp_dir.."/project.pto"
|
||||
coroutine.yield("RUN_COMMAND", huginStartCommand)
|
||||
if first_file == nil then
|
||||
dt.print(_("no file selected"))
|
||||
return
|
||||
end
|
||||
|
||||
huginStartCommand = "hugin "..dt.configuration.tmp_dir.."/project.pto"
|
||||
local pto_path = dt.configuration.tmp_dir..'/project.pto'
|
||||
log.msg(log.info, "pto_path is ", pto_path)
|
||||
local filepath = df.split_filepath(first_file.filename)
|
||||
log.msg(log.info, "filepath is ", filepath)
|
||||
local tmp_filename = filepath['basename'].."_pano.tif"
|
||||
log.msg(log.info, "tmp_filename is ", tmp_filename)
|
||||
-- some versions of hugin_executor don't seem to output the result
|
||||
-- to the --prefix location, so assume we are working in the tmp
|
||||
-- directory and then move the file to the original file location afterwards
|
||||
local src_path = dt.configuration.tmp_dir.."/"..tmp_filename
|
||||
log.msg(log.info, "src_path is ", src_path)
|
||||
local dst_path = first_file.path..'/'..tmp_filename
|
||||
log.msg(log.info, "dst_path is ", dst_path)
|
||||
|
||||
if df.check_if_file_exists(dst_path) then
|
||||
dst_path = df.create_unique_filename(dst_path)
|
||||
log.msg(log.info, "dst_path updated to ", dts_path)
|
||||
end
|
||||
|
||||
dt.print(_("will try to stitch now"))
|
||||
local huginStartCommand = nil
|
||||
if (cmd_line_available and not user_prefer_gui) then
|
||||
log.msg(log.info, "using the command line tools to create panorama")
|
||||
huginStartCommand = pto_gen.." "..img_list.." -o "..pto_path
|
||||
dt.print(_("creating pto file"))
|
||||
log.msg(log.info, "pto creation command is ", huginStartCommand)
|
||||
dtsys.external_command(huginStartCommand)
|
||||
|
||||
dt.print(_("running assistant"))
|
||||
huginStartCommand = hugin_executor.." --assistant "..pto_path
|
||||
dtsys.external_command(huginStartCommand)
|
||||
|
||||
huginStartCommand = hugin_executor..' --stitching --prefix=' .. "'" .. src_path .. "'" .. ' ' .. pto_path
|
||||
log.msg(log.info, "command line huginStartCommand is ", huginStartCommand)
|
||||
elseif gui_available then
|
||||
if (user_prefer_gui) then
|
||||
dt.print(_("launching hugin"))
|
||||
else
|
||||
huginStartCommand = "hugin "..img_list
|
||||
dt.print(_("unable to find command line tools, launching hugin"))
|
||||
end
|
||||
-- the gui produces a differnt output filename than the command line
|
||||
src_path = dt.configuration.tmp_dir .. "/" .. df.get_basename(img_set[1].filename) .. " - " .. df.get_basename(img_set[#img_set].filename) .. ".tif"
|
||||
log.msg(log.info, "set src_path to ", src_path)
|
||||
huginStartCommand = hugin..' '..img_list
|
||||
log.msg(log.info, "gui huginStartCommand is ", huginStartCommand)
|
||||
else
|
||||
dt.print(_("hugin isn't available."))
|
||||
end
|
||||
|
||||
dt.print_error(huginStartCommand)
|
||||
|
||||
if coroutine.yield("RUN_COMMAND", huginStartCommand)
|
||||
then
|
||||
dt.print("Command hugin failed ...")
|
||||
if not (huginStartCommand==nil) then
|
||||
if not dtsys.external_command(huginStartCommand) then
|
||||
dt.print(_("hugin failed ..."))
|
||||
log.msg(log.error, huginStartCommand, " failed")
|
||||
else
|
||||
if df.check_if_file_exists(src_path) then
|
||||
log.msg(log.debug, "found ", src_path, " importing to ", dst_path)
|
||||
df.file_move(src_path, dst_path)
|
||||
dt.print(string.format(_("importing file %s"), dst_path))
|
||||
dt.database.import(dst_path)
|
||||
end
|
||||
end
|
||||
log.log_level(saved_log_level)
|
||||
end
|
||||
|
||||
-- cleanup the temp files
|
||||
for k,v in pairs(image_table) do
|
||||
os.remove(v)
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.destroy_storage(namespace)
|
||||
end
|
||||
|
||||
-- Register
|
||||
dt.register_storage("module_hugin", "Hugin Panorama", show_status, create_panorama)
|
||||
if dt.configuration.running_os ~= "linux" then
|
||||
exec_widget = df.executable_path_widget(executable_table)
|
||||
end
|
||||
|
||||
hugin_widget = dt.new_widget("box") {
|
||||
orientation = "vertical",
|
||||
dt.new_widget("check_button")
|
||||
{
|
||||
label = _("launch hugin gui"),
|
||||
value = user_prefer_gui,
|
||||
tooltip = _('launch hugin in gui mode'),
|
||||
clicked_callback = user_preference_changed
|
||||
},
|
||||
exec_widget
|
||||
}
|
||||
|
||||
dt.register_storage(namespace, _("hugin panorama"), show_status, create_panorama, nil, nil, hugin_widget)
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
return script_data
|
||||
--
|
||||
-- vim: shiftwidth=2 expandtab tabstop=2 cindent syntax=lua
|
||||
|
||||
607
contrib/image_stack.lua
Normal file
607
contrib/image_stack.lua
Normal file
@@ -0,0 +1,607 @@
|
||||
--[[
|
||||
|
||||
image_stack.lua - process a stack of images
|
||||
|
||||
Copyright (C) 2018 Bill Ferguson <wpferguson@gmail.com>.
|
||||
Copyright (C) 2016,2017 Holger Klemm
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
image_stack - export a stack of images and process them, returning the result
|
||||
|
||||
This script provides another storage (export target) for darktable. Selected
|
||||
images are exported in the specified format to temporary storage. The images are aligned
|
||||
if the user requests it. When the images are ready, imagemagick is launched and uses
|
||||
the selected evaluate-sequence operator to process the images. The output file is written
|
||||
to a filename representing the imput files in the format specified by the user. The resulting
|
||||
image is imported into the film roll. The source images can be tagged as part of the file
|
||||
creation so that a user can later find the contributing images.
|
||||
|
||||
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
* align_image_stack - http://www.hugin.org
|
||||
* imagemagick - http://www.imagemagick.org
|
||||
|
||||
USAGE
|
||||
* require this script from your main lua file
|
||||
* select the images to process with image_stack
|
||||
* in the export dialog select "image stack" and select the format and bit depth for the
|
||||
exported image
|
||||
* Select whether the images need to be aligned.
|
||||
* Select the stack operator
|
||||
* Select the output format
|
||||
* Select whether to tag the source images used to create the resulting file
|
||||
* Specify executable locations if necessary
|
||||
* Press "export"
|
||||
* The resulting image will be imported
|
||||
|
||||
NOTES
|
||||
Mean is a fairly quick operation. On my machine (i7-6800K, 16G) it takes a few seconds. Median, on the other hand
|
||||
takes approximately 10x longer to complete. Processing 10 and 12 image stacks took over a minute. I didn't test all
|
||||
the other functions, but the ones I did fell between Mean and Median performance wise.
|
||||
|
||||
BUGS, COMMENTS, SUGGESTIONS
|
||||
* Send to Bill Ferguson, wpferguson@gmail.com
|
||||
|
||||
CHANGES
|
||||
|
||||
THANKS
|
||||
* Thanks to Pat David and his blog entry on blending images, https://patdavid.net/2013/05/noise-removal-in-photos-with-median_6.html
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
local dtsys = require "lib/dtutils.system"
|
||||
local gettext = dt.gettext.gettext
|
||||
local job = nil
|
||||
|
||||
-- path separator constant
|
||||
local PS = dt.configuration.running_os == "windows" and "\\" or "/"
|
||||
|
||||
-- works with LUA API version 5.0.0
|
||||
du.check_min_api_version("7.0.0", "image_stack")
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("image stack"),
|
||||
purpose = _("process a stack of images"),
|
||||
author = "Bill Ferguson <wpferguson@gmail.com>",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/image_stack"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- GUI definitions
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local label_align_options= dt.new_widget("section_label"){
|
||||
label = _('image align options')
|
||||
}
|
||||
|
||||
local label_image_stack_options = dt.new_widget("section_label"){
|
||||
label = _("image stack options")
|
||||
}
|
||||
|
||||
local label_executable_locations = dt.new_widget("section_label"){
|
||||
label = _("executable locations")
|
||||
}
|
||||
|
||||
if not(dt.preferences.read("align_image_stack", "initialized", "bool")) then
|
||||
dt.preferences.write("align_image_stack", "def_radial_distortion", "bool", false)
|
||||
dt.preferences.write("align_image_stack", "def_optimize_field", "bool", false)
|
||||
dt.preferences.write("align_image_stack", "def_optimize_image_center", "bool", true)
|
||||
dt.preferences.write("align_image_stack", "def_auto_crop", "bool", true)
|
||||
dt.preferences.write("align_image_stack", "def_distortion", "bool", true)
|
||||
dt.preferences.write("align_image_stack", "def_grid_size", "integer", 5)
|
||||
dt.preferences.write("align_image_stack", "def_control_points", "integer", 8)
|
||||
dt.preferences.write("align_image_stack", "def_control_points_remove", "integer", 3)
|
||||
dt.preferences.write("align_image_stack", "def_correlation", "integer", 9)
|
||||
dt.preferences.write("align_image_stack", "initialized", "bool", true)
|
||||
end
|
||||
|
||||
if not(dt.preferences.read("image_stack", "initialized", "bool")) then
|
||||
dt.preferences.write("image_stack", "align_images", "bool", true)
|
||||
dt.preferences.write("image_stack", "stack_function", "integer", 2)
|
||||
dt.preferences.write("image_stack", "output_format", "integer", 6)
|
||||
dt.preferences.write("image_stack", "tag_images", "bool", true)
|
||||
dt.preferences.write("image_stack", "initialized", "bool", true)
|
||||
end
|
||||
|
||||
local chkbtn_will_align = dt.new_widget("check_button"){
|
||||
label = _('perform image alignment'),
|
||||
value = dt.preferences.read("image_stack", "align_images", "bool"),
|
||||
tooltip = _('align the image stack before processing')
|
||||
}
|
||||
|
||||
local chkbtn_radial_distortion = dt.new_widget("check_button"){
|
||||
label = _('optimize radial distortion for all images'),
|
||||
value = dt.preferences.read("align_image_stack", "def_radial_distortion", "bool"),
|
||||
tooltip = _('optimize radial distortion for all images, \nexcept the first'),
|
||||
}
|
||||
|
||||
local chkbtn_optimize_field = dt.new_widget("check_button"){
|
||||
label = _('optimize field of view for all images'),
|
||||
value = dt.preferences.read("align_image_stack", "def_optimize_field", "bool"),
|
||||
tooltip =_('optimize field of view for all images, except the first. \nUseful for aligning focus stacks (DFF) with slightly \ndifferent magnification.'),
|
||||
}
|
||||
|
||||
local chkbtn_optimize_image_center = dt.new_widget("check_button"){
|
||||
label = _('optimize image center shift for all images'),
|
||||
value = dt.preferences.read("align_image_stack", "def_optimize_image_center", "bool"),
|
||||
tooltip =_('optimize image center shift for all images, \nexcept the first.'),
|
||||
}
|
||||
|
||||
local chkbtn_auto_crop = dt.new_widget("check_button"){
|
||||
label = _('auto crop the image'),
|
||||
value = dt.preferences.read("align_image_stack", "def_auto_crop", "bool"),
|
||||
tooltip =_('auto crop the image to the area covered by all images.'),
|
||||
}
|
||||
|
||||
local chkbtn_distortion = dt.new_widget("check_button"){
|
||||
label = _('load distortion from lens database'),
|
||||
value = dt.preferences.read("align_image_stack", "def_distortion", "bool"),
|
||||
tooltip =_('try to load distortion information from lens database'),
|
||||
}
|
||||
|
||||
local cmbx_grid_size = dt.new_widget("combobox"){
|
||||
label = _('image grid size'),
|
||||
tooltip =_('break image into a rectangular grid \nand attempt to find num control points in each section.\ndefault: (5x5)'),
|
||||
value = dt.preferences.read("align_image_stack", "def_grid_size", "integer"), --5
|
||||
"1", "2", "3","4","5","6","7","8","9",
|
||||
reset_callback = function(self)
|
||||
self.value = dt.preferences.read("align_image_stack", "def_grid_size", "integer")
|
||||
end
|
||||
}
|
||||
|
||||
local cmbx_control_points = dt.new_widget("combobox"){
|
||||
label = _('control points/grid'),
|
||||
tooltip =_('number of control points (per grid, see option -g) \nto create between adjacent images \ndefault: (8).'),
|
||||
value = dt.preferences.read("align_image_stack", "def_control_points", "integer"), --8, "1", "2", "3","4","5","6","7","8","9",
|
||||
"1", "2", "3","4","5","6","7","8","9",
|
||||
reset_callback = function(self)
|
||||
self.value = dt.preferences.read("align_image_stack", "def_control_points", "integer")
|
||||
end
|
||||
}
|
||||
|
||||
local cmbx_control_points_remove = dt.new_widget("combobox"){
|
||||
label = _('remove control points with error'),
|
||||
tooltip =_('remove all control points with an error higher \nthan num pixels \ndefault: (3)'),
|
||||
value = dt.preferences.read("align_image_stack", "def_control_points_remove", "integer"), --3, "1", "2", "3","4","5","6","7","8","9",
|
||||
"1", "2", "3","4","5","6","7","8","9",
|
||||
reset_callback = function(self)
|
||||
self.value = dt.preferences.read("align_image_stack", "def_control_points_remove", "integer")
|
||||
end
|
||||
}
|
||||
|
||||
local cmbx_correlation = dt.new_widget("combobox"){
|
||||
label = _('correlation threshold for control points'),
|
||||
tooltip =_('correlation threshold for identifying \ncontrol points \ndefault: (0.9).'),
|
||||
value = dt.preferences.read("align_image_stack", "def_correlation", "integer"), --9, "0,1", "0,2", "0,3","0,4","0,5","0,6","0,7","0,8","0,9",
|
||||
"0.1", "0.2", "0.3","0.4","0.5","0.6","0.7","0.8","0.9","1.0",
|
||||
reset_callback = function(self)
|
||||
self.value = dt.preferences.read("align_image_stack", "def_correlation", "integer")
|
||||
end
|
||||
}
|
||||
|
||||
local cmbx_stack_function = dt.new_widget("combobox"){
|
||||
label = _("select stack function"),
|
||||
tooltip = _("select function to be \napplied to image stack"),
|
||||
value = dt.preferences.read("image_stack", "stack_function", "integer"), "Mean", "Median", "Abs", "Add", "And", "Divide", "Max", "Min",
|
||||
"Or", "Subtract", "Sum", "Xor",
|
||||
reset_callback = function(self)
|
||||
self.value = dt.preferences.read("image_stack", "stack_function", "integer")
|
||||
end
|
||||
}
|
||||
|
||||
local cmbx_output_format = dt.new_widget("combobox"){
|
||||
label = _("select output format"),
|
||||
tooltip = _("choose the format for the resulting image"),
|
||||
value = dt.preferences.read("image_stack", "output_format", "integer"), "EXR", "JPG", "PNG", "PNM", "PPM", "TIF",
|
||||
reset_callback = function(self)
|
||||
self.value = dt.preferences.read("image_stack", "output_format", "integer")
|
||||
end
|
||||
}
|
||||
|
||||
local chkbtn_tag_source_file = dt.new_widget("check_button"){
|
||||
label = _("tag source images used?"),
|
||||
value = dt.preferences.read("image_stack", "tag_images", "bool"),
|
||||
tooltip = _("tag the source images used to create the output file?")
|
||||
}
|
||||
|
||||
local image_stack_widget = dt.new_widget("box"){
|
||||
orientation = "vertical",
|
||||
label_align_options,
|
||||
chkbtn_will_align,
|
||||
chkbtn_radial_distortion,
|
||||
chkbtn_optimize_field,
|
||||
chkbtn_optimize_image_center,
|
||||
chkbtn_auto_crop,
|
||||
chkbtn_distortion,
|
||||
cmbx_grid_size,
|
||||
cmbx_control_points,
|
||||
cmbx_control_points_remove,
|
||||
cmbx_correlation,
|
||||
dt.new_widget("separator"){},
|
||||
dt.new_widget("separator"){},
|
||||
label_image_stack_options,
|
||||
cmbx_stack_function,
|
||||
cmbx_output_format,
|
||||
chkbtn_tag_source_file,
|
||||
}
|
||||
|
||||
local executables = {"align_image_stack", "convert"}
|
||||
|
||||
if dt.configuration.running_os ~= "linux" then
|
||||
image_stack_widget[#image_stack_widget + 1] = df.executable_path_widget(executables)
|
||||
end
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- local functions
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function show_status(storage, image, format, filename, number, total, high_quality, extra_data)
|
||||
dt.print(string.format(_("export image %i/%i"), number, total))
|
||||
end
|
||||
|
||||
-- read the gui and populate the align_image_stack arguments
|
||||
|
||||
local function get_align_image_stack_arguments()
|
||||
|
||||
--Setup Align Image Stack Arguments--
|
||||
local align_args = ""
|
||||
if (chkbtn_radial_distortion.value) then align_args = align_args .. " -d" end
|
||||
if (chkbtn_optimize_field.value) then align_args = align_args .. " -m" end
|
||||
if (chkbtn_optimize_image_center.value) then align_args = align_args .. " -i" end
|
||||
if (chkbtn_auto_crop.value) then align_args = align_args .. " -C" end
|
||||
if (chkbtn_distortion.value) then align_args = align_args .. " --distortion" end
|
||||
|
||||
dt.preferences.write("align_image_stack", "def_radial_distortion", "bool", chkbtn_radial_distortion.value)
|
||||
dt.preferences.write("align_image_stack", "def_optimize_field", "bool", chkbtn_optimize_field.value)
|
||||
dt.preferences.write("align_image_stack", "def_optimize_image_center", "bool", chkbtn_optimize_image_center.value)
|
||||
dt.preferences.write("align_image_stack", "def_auto_crop", "bool", chkbtn_auto_crop.value)
|
||||
dt.preferences.write("align_image_stack", "def_distortion", "bool", chkbtn_distortion.value)
|
||||
|
||||
align_args = align_args.." -g "..cmbx_grid_size.value
|
||||
align_args = align_args.." -c "..cmbx_control_points.value
|
||||
align_args = align_args.." -t "..cmbx_control_points_remove.value
|
||||
align_args = align_args.." --corr="..cmbx_correlation.value
|
||||
|
||||
dt.preferences.write("align_image_stack", "def_grid_size", "integer", cmbx_grid_size.selected)
|
||||
dt.preferences.write("align_image_stack", "def_control_points", "integer", cmbx_control_points.selected)
|
||||
dt.preferences.write("align_image_stack", "def_control_points_remove", "integer", cmbx_control_points_remove.selected)
|
||||
dt.preferences.write("align_image_stack", "def_correlation", "integer", cmbx_correlation.selected)
|
||||
|
||||
if (dt.preferences.read("align_image_stack", "align_use_gpu", "bool")) then align_args = align_args .. " --gpu" end
|
||||
|
||||
align_args = align_args .. " -a " .. dt.configuration.tmp_dir .. "/aligned_ "
|
||||
|
||||
return align_args
|
||||
end
|
||||
|
||||
-- extract, and sanitize, an image list from the supplied image table
|
||||
|
||||
local function extract_image_list(image_table)
|
||||
local img_list = ""
|
||||
local result = {}
|
||||
|
||||
for img,expimg in pairs(image_table) do
|
||||
table.insert(result, expimg)
|
||||
end
|
||||
table.sort(result)
|
||||
for _,exp_img in ipairs(result) do
|
||||
img_list = img_list .. " " .. df.sanitize_filename(exp_img)
|
||||
end
|
||||
return img_list, #result
|
||||
end
|
||||
|
||||
-- don't leave files laying around the operating system
|
||||
|
||||
local function cleanup(img_list)
|
||||
dt.print_log("image list is " .. img_list)
|
||||
files = du.split(img_list, " ")
|
||||
for _,f in ipairs(files) do
|
||||
f = string.gsub(f, '[\'\"]', "")
|
||||
os.remove(f)
|
||||
end
|
||||
end
|
||||
|
||||
-- List files based on a search pattern. This is cross platform compatible
|
||||
-- but the windows version is recursive in order to get ls type listings.
|
||||
-- Normally this shouldn't be a problem, but if you use this code just beware.
|
||||
-- If you want to do it non recursively, then remove the /s argument from dir
|
||||
-- and grab the path component from the search string and prepend it to the files
|
||||
-- found.
|
||||
|
||||
local function list_files(search_string)
|
||||
local ls = "ls "
|
||||
local files = {}
|
||||
local dir_path = nil
|
||||
local count = 1
|
||||
|
||||
if dt.configuration.running_os == "windows" then
|
||||
ls = "dir /b/s "
|
||||
search_string = string.gsub(search_string, "/", "\\\\")
|
||||
end
|
||||
|
||||
local f = io.popen(ls .. search_string)
|
||||
if f then
|
||||
local found_file = f:read()
|
||||
while found_file do
|
||||
files[count] = found_file
|
||||
count = count + 1
|
||||
found_file = f:read()
|
||||
end
|
||||
f:close()
|
||||
end
|
||||
return files
|
||||
end
|
||||
|
||||
-- create a filename from a multi image set. The image list is sorted, then
|
||||
-- combined with first and last if more than 3 images or a - separated list
|
||||
-- if three images or less.
|
||||
|
||||
local function make_output_filename(image_table)
|
||||
local images = {}
|
||||
local cnt = 1
|
||||
local max_distinct_names = 3
|
||||
local name_separator = "-"
|
||||
local outputFileName = nil
|
||||
local result = {}
|
||||
|
||||
for img,expimg in pairs(image_table) do
|
||||
table.insert(result, expimg)
|
||||
end
|
||||
table.sort(result)
|
||||
for _,img in pairs(result) do
|
||||
images[cnt] = df.get_basename(img)
|
||||
cnt = cnt + 1
|
||||
end
|
||||
|
||||
cnt = cnt - 1
|
||||
|
||||
if cnt > 1 then
|
||||
if cnt > max_distinct_names then
|
||||
-- take the first and last
|
||||
outputFileName = images[1] .. name_separator .. images[cnt]
|
||||
else
|
||||
-- join them
|
||||
outputFileName = du.join(images, name_separator)
|
||||
end
|
||||
else
|
||||
-- return the single name
|
||||
outputFileName = images[cnt]
|
||||
end
|
||||
|
||||
return outputFileName
|
||||
end
|
||||
|
||||
-- get the path where the collection is stored
|
||||
|
||||
local function extract_collection_path(image_table)
|
||||
local collection_path = nil
|
||||
for i,_ in pairs(image_table) do
|
||||
collection_path = i.path
|
||||
break
|
||||
end
|
||||
return collection_path
|
||||
end
|
||||
|
||||
-- copy an images database attributes to another image. This only
|
||||
-- copies what the database knows, not the actual exif data in the
|
||||
-- image itself.
|
||||
|
||||
local function copy_image_attributes(from, to, ...)
|
||||
local args = {...}
|
||||
if #args == 0 then
|
||||
args[1] = "all"
|
||||
end
|
||||
if args[1] == "all" then
|
||||
args[1] = "rating"
|
||||
args[2] = "colors"
|
||||
args[3] = "exif"
|
||||
args[4] = "meta"
|
||||
args[5] = "GPS"
|
||||
end
|
||||
for _,arg in ipairs(args) do
|
||||
if arg == "rating" then
|
||||
to.rating = from.rating
|
||||
elseif arg == "colors" then
|
||||
to.red = from.red
|
||||
to.blue = from.blue
|
||||
to.green = from.green
|
||||
to.yellow = from.yellow
|
||||
to.purple = from.purple
|
||||
elseif arg == "exif" then
|
||||
to.exif_maker = from.exif_maker
|
||||
to.exif_model = from.exif_model
|
||||
to.exif_lens = from.exif_lens
|
||||
to.exif_aperture = from.exif_aperture
|
||||
to.exif_exposure = from.exif_exposure
|
||||
to.exif_focal_length = from.exif_focal_length
|
||||
to.exif_iso = from.exif_iso
|
||||
to.exif_datetime_taken = from.exif_datetime_taken
|
||||
to.exif_focus_distance = from.exif_focus_distance
|
||||
to.exif_crop = from.exif_crop
|
||||
elseif arg == "GPS" then
|
||||
to.elevation = from.elevation
|
||||
to.longitude = from.longitude
|
||||
to.latitude = from.latitude
|
||||
elseif arg == "meta" then
|
||||
to.publisher = from.publisher
|
||||
to.title = from.title
|
||||
to.creator = from.creator
|
||||
to.rights = from.rights
|
||||
to.description = from.description
|
||||
else
|
||||
dt.print_error("Unrecognized option to copy_image_attributes: " .. arg)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function stop_job()
|
||||
job.valid = false
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.destroy_storage("module_image_stack")
|
||||
end
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- main program
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function image_stack(storage, image_table, extra_data)
|
||||
|
||||
local will_align = chkbtn_will_align.value
|
||||
local img_list, image_count = extract_image_list(image_table)
|
||||
local tmp_dir = dt.configuration.tmp_dir .. PS
|
||||
local stack_function = cmbx_stack_function.value
|
||||
local output_format = cmbx_output_format.value
|
||||
local tag_source = chkbtn_tag_source_file.value
|
||||
local tasks = 3
|
||||
|
||||
if will_align then
|
||||
tasks = tasks + 1
|
||||
end
|
||||
|
||||
if tag_source then
|
||||
tasks = tasks + 1
|
||||
end
|
||||
|
||||
local percent_step = 1 / tasks
|
||||
|
||||
-- update preferences
|
||||
dt.preferences.write("image_stack", "align_images", "bool", will_align)
|
||||
dt.preferences.write("image_stack", "stack_function", "integer", cmbx_stack_function.selected)
|
||||
dt.preferences.write("image_stack", "output_format", "integer", cmbx_output_format.selected)
|
||||
|
||||
if image_count < 2 then
|
||||
dt.print(_("ERROR: at least 2 images required for image stacking, exiting..."))
|
||||
dt.print_error(image_count .. " image(s) selected, at least 2 required")
|
||||
return
|
||||
end
|
||||
|
||||
job = dt.gui.create_job(_("image stack"), true, stop_job)
|
||||
job.percent = job.percent + percent_step
|
||||
|
||||
-- align images if requested
|
||||
if will_align then
|
||||
local align_image_stack_executable = df.check_if_bin_exists("align_image_stack")
|
||||
if align_image_stack_executable then
|
||||
local align_args = get_align_image_stack_arguments()
|
||||
local align_image_stack_command = align_image_stack_executable .. align_args .. " " .. img_list
|
||||
dt.print_log(align_image_stack_command)
|
||||
dt.print(_("aligning images..."))
|
||||
local result = dtsys.external_command(align_image_stack_command)
|
||||
if result == 0 then
|
||||
dt.print(_("images aligned"))
|
||||
local files = list_files(tmp_dir .. "aligned_*")
|
||||
cleanup(img_list)
|
||||
img_list = extract_image_list(files)
|
||||
job.percent = job.percent + percent_step
|
||||
else
|
||||
dt.print(_("ERROR: image alignment failed"))
|
||||
dt.print_error("image alignment failed")
|
||||
cleanup(img_list)
|
||||
return
|
||||
end
|
||||
else
|
||||
dt.print(_("ERROR: align_image_stack not found"))
|
||||
dt.print_error("align_image_stack not found")
|
||||
cleanup(img_list)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- stack images
|
||||
local output_filename = tmp_dir .. make_output_filename(image_table) .. "." .. string.lower(output_format)
|
||||
local convert_arguments = img_list .. " -evaluate-sequence " .. stack_function .. " " .. df.sanitize_filename(output_filename)
|
||||
local convert_executable = df.check_if_bin_exists("convert")
|
||||
-- tif images exported from darktable have private tiff tags embedded in them and convert complains about them
|
||||
local ignore_tif_tags = " -quiet -define tiff:ignore-tags=40965,42032,42033,42034,42036,18246,18249,36867,34864,34866 "
|
||||
if convert_executable then
|
||||
local convert_command = convert_executable .. ignore_tif_tags .. convert_arguments
|
||||
dt.print_log("convert command is " .. convert_command)
|
||||
dt.print(_("processing image stack"))
|
||||
local result = dtsys.external_command(convert_command)
|
||||
if result == 0 then
|
||||
dt.print(_("image stack processed"))
|
||||
cleanup(img_list)
|
||||
job.percent = job.percent + percent_step
|
||||
|
||||
-- import image
|
||||
dt.print(_("importing result"))
|
||||
local film_roll_path = extract_collection_path(image_table)
|
||||
local import_filename = df.create_unique_filename(film_roll_path .. PS .. df.get_filename(output_filename))
|
||||
df.file_move(output_filename, import_filename)
|
||||
imported_image = dt.database.import(import_filename)
|
||||
local created_tag = dt.tags.create(_("created with|image_stack"))
|
||||
dt.tags.attach(created_tag, imported_image)
|
||||
-- all the images are the same except for time, so just copy the attributes
|
||||
-- from the first
|
||||
for img,_ in pairs(image_table) do
|
||||
copy_image_attributes(img, imported_image)
|
||||
break
|
||||
end
|
||||
job.percent = job.percent + percent_step
|
||||
|
||||
-- tag images if requested
|
||||
|
||||
if tag_source then
|
||||
dt.print(_("tagging source images"))
|
||||
local source_tag = dt.tags.create(_("source file|" .. imported_image.filename))
|
||||
for img, _ in pairs(image_table) do
|
||||
dt.tags.attach(source_tag, img)
|
||||
end
|
||||
job.percent = job.percent + percent_step
|
||||
end
|
||||
else
|
||||
dt.print(_("ERROR: image stack processing failed"))
|
||||
cleanup(img_list)
|
||||
end
|
||||
else
|
||||
dt.print(_("ERROR: convert executable not found"))
|
||||
dt.print_error("convert executable not found")
|
||||
cleanup(img_list)
|
||||
end
|
||||
job.valid = false
|
||||
end
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- darktable integration
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
dt.preferences.register("align_image_stack", "align_use_gpu", -- name
|
||||
"bool", -- type
|
||||
_('align image stack: use GPU for remapping'), -- label
|
||||
_('set the GPU remapping for image align'), -- tooltip
|
||||
false)
|
||||
|
||||
dt.register_storage("module_image_stack", _("image stack"), show_status, image_stack, nil, nil, image_stack_widget)
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
return script_data
|
||||
592
contrib/image_time.lua
Normal file
592
contrib/image_time.lua
Normal file
@@ -0,0 +1,592 @@
|
||||
--[[
|
||||
|
||||
image_time.lua - synchronize image time for images shot with different cameras
|
||||
|
||||
Copyright (C) 2019, 2020 Bill Ferguson <wpferguson@gmail.com>.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
image_time - non-destructively modify the image time
|
||||
|
||||
DESCRIPTION
|
||||
|
||||
image_time non destructively adjusts image times by modifying the
|
||||
database image exif_datetime_taken field. There are 4 modes: adjust time,
|
||||
set time, synchronize time, and reset time.
|
||||
|
||||
ADJUST TIME
|
||||
|
||||
adjust time mode lets you chose an offset in terms of years, months,
|
||||
days, hours, minutes, and seconds. The adjustment can be added or
|
||||
subtracted.
|
||||
|
||||
WARNING: When adding and subtracting months the result will usually
|
||||
be what is expected unless the time being adjusted is at the end of
|
||||
the month. This is because a month is a variable amount of time that
|
||||
can be 28, 29, 30 or 31 days depending on the month. Example: It's
|
||||
March 31st and I subtract a month which not sets the time to February
|
||||
31st. When that gets set to a valid time, then the date changes to
|
||||
March 3rd.
|
||||
|
||||
SET TIME
|
||||
|
||||
set time mode allows you to pick a date and time and set the image
|
||||
time accordingly. Fields may be left out. This is useful when
|
||||
importing scanned images that don't have an embedded date.
|
||||
|
||||
SYNCHRONIZE TIME
|
||||
|
||||
I recently purchased a 7DmkII to replace my aging 7D. My 7D was still
|
||||
serviceable, so I bought a remote control and figured I'd try shooting
|
||||
events from 2 different perspectives. I didn't think to synchonize the
|
||||
time between the 2 cameras, so when I loaded the images and sorted by
|
||||
time it was a disaster. I hacked a script together with hard coded values
|
||||
to adjust the exif_datetime_taken value in the database for the 7D images
|
||||
so that everything sorted properly. I've tried shooting with 2 cameras
|
||||
several times since that first attempt. I've gotten better at getting the
|
||||
camera times close, but still haven't managed to get them to sync. So I
|
||||
decided to think the problem through and write a proper script to take
|
||||
care of the problem.
|
||||
|
||||
RESET TIME
|
||||
|
||||
Select the images and click reset.
|
||||
|
||||
USAGE
|
||||
|
||||
ADJUST TIME
|
||||
|
||||
Change the year, month, day, hour, minute, second dropdowns to the amount
|
||||
of change desired. Select add or subtract. Select the images. Click
|
||||
adjust.
|
||||
|
||||
SET TIME
|
||||
|
||||
Set the time fields to the desired time. Select the images to change. Click
|
||||
set.
|
||||
|
||||
SYNCHRONIZE TIME
|
||||
|
||||
Select 2 images, one from each camera, of the same moment in time. Click
|
||||
the Calculate button to calculate the time difference. The difference is
|
||||
displayed in the difference entry. You can manually adjust it by changing
|
||||
the value if necessary.
|
||||
|
||||
Select the images that need their time adjusted. Determine which way to adjust
|
||||
adjust the time (add or subtract) and select the appropriate choice.
|
||||
|
||||
If the image times get messed up and you just want to start over, select reset time
|
||||
from the mode and reset the image times.
|
||||
|
||||
RESET TIME
|
||||
|
||||
Select the images and click reset.
|
||||
|
||||
ADDITIONAL SOFTWARE REQUIRED
|
||||
* exiv2
|
||||
|
||||
BUGS, COMMENTS, SUGGESTIONS
|
||||
* Send to Bill Ferguson, wpferguson@gmail.com
|
||||
|
||||
CHANGES
|
||||
|
||||
]]
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
local ds = require "lib/dtutils.string"
|
||||
local dtsys = require "lib/dtutils.system"
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local img_time = {}
|
||||
img_time.module_installed = false
|
||||
img_time.event_registered = false
|
||||
|
||||
du.check_min_api_version("7.0.0", "image_time")
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("image time"),
|
||||
purpose = _("synchronize image time for images shot with different cameras or adjust or set image time"),
|
||||
author = "Bill Ferguson <wpferguson@gmail.com>",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/image_time"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
|
||||
|
||||
local PS = dt.configuration.runnin_os == "windows" and "\\" or "/"
|
||||
local ERROR = -1
|
||||
|
||||
-- function to convert from exif time to system time
|
||||
local function exiftime2systime(exiftime)
|
||||
local yr,mo,dy,h,m,s = string.match(exiftime, "(%d-):(%d-):(%d-) (%d-):(%d-):(%d+)")
|
||||
return(os.time{year=yr, month=mo, day=dy, hour=h, min=m, sec=s})
|
||||
end
|
||||
|
||||
-- function to convert from systime to exif time
|
||||
local function systime2exiftime(systime)
|
||||
local t = os.date("*t", systime)
|
||||
return(string.format("%4d:%02d:%02d %02d:%02d:%02d", t.year, t.month, t.day, t.hour, t.min, t.sec))
|
||||
end
|
||||
|
||||
local function vars2exiftime(year, month, day, hour, min, sec)
|
||||
local y = tonumber(year) and string.format("%4d", year) or "0000"
|
||||
local mo = tonumber(month) and string.format("%02d", month) or "00"
|
||||
local d = tonumber(day) and string.format("%02d", day) or "00"
|
||||
local h = tonumber(hour) and string.format("%02d", hour) or "00"
|
||||
local m = tonumber(min) and string.format("%02d", min) or "00"
|
||||
local s = tonumber(sec) and string.format("%02d", sec) or "00"
|
||||
return(y .. ":" .. mo .. ":" .. d .. " " .. h .. ":" .. m .. ":" .. s)
|
||||
end
|
||||
|
||||
local function exiftime2vars(exiftime)
|
||||
return string.match(exiftime, "(%d+):(%d+):(%d+) (%d+):(%d+):(%d+)")
|
||||
end
|
||||
|
||||
local function calc_time_difference(image1, image2)
|
||||
return math.abs(exiftime2systime(image1.exif_datetime_taken) - exiftime2systime(image2.exif_datetime_taken))
|
||||
end
|
||||
|
||||
local function adjust_image_time(image, difference)
|
||||
image.exif_datetime_taken = systime2exiftime(exiftime2systime(image.exif_datetime_taken) + difference)
|
||||
return
|
||||
end
|
||||
|
||||
local function calculate_difference(images)
|
||||
if #images == 2 then
|
||||
img_time.diff_entry.text = calc_time_difference(images[1], images[2])
|
||||
img_time.btn.sensitive = true
|
||||
else
|
||||
dt.print(_("ERROR: 2 images must be selected"))
|
||||
end
|
||||
end
|
||||
|
||||
local function synchronize_times(images, difference)
|
||||
for _, image in ipairs(images) do
|
||||
adjust_image_time(image, difference)
|
||||
end
|
||||
end
|
||||
|
||||
local function synchronize_time(images)
|
||||
local sign = 1
|
||||
if img_time.sdir.value == _("subtract") then
|
||||
sign = -1
|
||||
end
|
||||
synchronize_times(images, tonumber(img_time.diff_entry.text) * sign)
|
||||
end
|
||||
|
||||
local function add_time(images)
|
||||
synchronize_times(images, tonumber(img_time.diff_entry.text))
|
||||
end
|
||||
|
||||
local function year_month2months(year, month)
|
||||
year_months = tonumber(year) and year * 12 or 0
|
||||
local months = tonumber(month) and tonumber(month) or 0
|
||||
|
||||
return year_months + months
|
||||
end
|
||||
|
||||
local function months2year_month(months)
|
||||
local year = math.floor(months / 12)
|
||||
local month = months - (year * 12)
|
||||
|
||||
return year, month
|
||||
end
|
||||
|
||||
local function get_image_taken_time(image)
|
||||
-- get original image time
|
||||
local datetime = nil
|
||||
|
||||
local exiv2 = df.check_if_bin_exists("exiv2")
|
||||
if exiv2 then
|
||||
p = io.popen(exiv2 .. " -K Exif.Image.DateTime " .. df.sanitize_filename(image.path .. PS .. image.filename))
|
||||
if p then
|
||||
for line in p:lines() do
|
||||
if string.match(line, "Exif.Image.DateTime") then
|
||||
datetime = string.match(line, "(%d-:%d-:%d- %d-:%d-:%d+)")
|
||||
end
|
||||
end
|
||||
p:close()
|
||||
end
|
||||
else
|
||||
dt.print(_("unable to detect exiv2"))
|
||||
datetime = ERROR
|
||||
end
|
||||
return datetime
|
||||
end
|
||||
|
||||
local function _get_windows_image_file_creation_time(image)
|
||||
local datetime = nil
|
||||
local p = io.popen("dir " .. image.path .. PS .. image.filename)
|
||||
if p then
|
||||
for line in p:lines() do
|
||||
if string.match(line, ds.sanitize_lua(image.filename)) then
|
||||
local mo, day, yr, hr, min, apm = string.match(line, "(%d+)/(%d+)/(%d+) (%d-):(%d+) (%S+)")
|
||||
if apm == "PM" then
|
||||
hr = hr + 12
|
||||
end
|
||||
datetime = vars2exiftime(yr, mo, day, hr, min, 0)
|
||||
end
|
||||
end
|
||||
p:close()
|
||||
else
|
||||
dt.print(string.format(_("unable to get information for %s"), image.filename))
|
||||
datetime = ERROR
|
||||
end
|
||||
return datetime
|
||||
end
|
||||
|
||||
local function _get_nix_image_file_creation_time(image)
|
||||
local datetime = nil
|
||||
local p = io.popen("ls -lL --time-style=full-iso " .. df.sanitize_filename(image.path .. PS .. image.filename))
|
||||
if p then
|
||||
for line in p:lines() do
|
||||
if string.match(line, ds.sanitize_lua(image.filename)) then
|
||||
datetime = vars2exiftime(string.match(line, "(%d+)%-(%d-)%-(%d-) (%d-):(%d-):(%d+)."))
|
||||
end
|
||||
end
|
||||
p:close()
|
||||
else
|
||||
dt.print(string.format(_("unable to get information for %s"), image.filename))
|
||||
datetime = ERROR
|
||||
end
|
||||
return datetime
|
||||
end
|
||||
|
||||
local function get_image_file_creation_time(image)
|
||||
-- no exif time in the image file so get the creation time
|
||||
local datetime = nil
|
||||
if dt.configuration.running_os == "windows" then
|
||||
datetime = _get_windows_image_file_creation_time(image)
|
||||
else
|
||||
datetime = _get_nix_image_file_creation_time(image)
|
||||
end
|
||||
return datetime
|
||||
end
|
||||
|
||||
local function get_original_image_time(image)
|
||||
local image_time = image.exif_datetime_taken
|
||||
local reset_time = nil
|
||||
|
||||
reset_time = get_image_taken_time(image)
|
||||
|
||||
if reset_time then
|
||||
if reset_time == ERROR then
|
||||
return image_time
|
||||
else
|
||||
return reset_time
|
||||
end
|
||||
else
|
||||
reset_time = get_image_file_creation_time(image)
|
||||
|
||||
if reset_time then
|
||||
if reset_time == ERROR then
|
||||
return image_time
|
||||
else
|
||||
return reset_time
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function reset_time(images)
|
||||
if #images > 0 then
|
||||
for _, image in ipairs(images) do
|
||||
image.exif_datetime_taken = get_original_image_time(image)
|
||||
end
|
||||
else
|
||||
dt.print_error("reset time: no images selected")
|
||||
dt.print(_("please select the images that need their time reset"))
|
||||
end
|
||||
end
|
||||
|
||||
local function adjust_time(images)
|
||||
local SEC_MIN = 60
|
||||
local SEC_HR = SEC_MIN * 60
|
||||
local SEC_DY = SEC_HR * 24
|
||||
|
||||
local offset = nil
|
||||
local sign = 1
|
||||
|
||||
if #images < 1 then
|
||||
dt.print(_("please select some images and try again"))
|
||||
return
|
||||
end
|
||||
|
||||
if img_time.adir.value == _("subtract") then
|
||||
sign = -1
|
||||
end
|
||||
|
||||
for _, image in ipairs(images) do
|
||||
local y, mo, d, h, m, s = exiftime2vars(image.exif_datetime_taken)
|
||||
local image_months = year_month2months(y, mo)
|
||||
local months_diff = year_month2months(img_time.ayr.value, img_time.amo.value)
|
||||
y, mo = months2year_month(image_months + (months_diff * sign))
|
||||
local exif_new = vars2exiftime(y, mo, d, h, m, s)
|
||||
offset = img_time.ady.value * SEC_DY
|
||||
offset = offset + img_time.ahr.value * SEC_HR
|
||||
offset = offset + img_time.amn.value * SEC_MIN
|
||||
offset = offset + img_time.asc.value
|
||||
offset = offset * sign
|
||||
image.exif_datetime_taken = systime2exiftime(exiftime2systime(exif_new) + offset)
|
||||
end
|
||||
end
|
||||
|
||||
local function set_time(images)
|
||||
if #images < 1 then
|
||||
dt.print(_("please select some images and try again"))
|
||||
return
|
||||
end
|
||||
|
||||
local y = img_time.syr.value
|
||||
local mo = img_time.smo.value
|
||||
local d = img_time.sdy.value
|
||||
local h = img_time.shr.value
|
||||
local m = img_time.smn.value
|
||||
local s = img_time.ssc.value
|
||||
|
||||
for _, image in ipairs(images) do
|
||||
image.exif_datetime_taken = vars2exiftime(y, mo, d, h, m, s)
|
||||
end
|
||||
end
|
||||
|
||||
local function seq(first, last)
|
||||
local result = {}
|
||||
|
||||
local num = first
|
||||
|
||||
while num <= last do
|
||||
table.insert(result, num)
|
||||
num = num + 1
|
||||
end
|
||||
|
||||
return table.unpack(result)
|
||||
end
|
||||
|
||||
local function reset_widgets()
|
||||
dt.print_log("took the reset function")
|
||||
img_time.ayr.selected = 1
|
||||
img_time.amo.selected = 1
|
||||
img_time.ady.selected = 1
|
||||
img_time.ahr.selected = 1
|
||||
img_time.ayr.selected = 1
|
||||
img_time.amn.selected = 1
|
||||
img_time.asc.selected = 1
|
||||
img_time.adir.selected = 1
|
||||
img_time.syr.selected = #img_time.syr
|
||||
img_time.smo.selected = 1
|
||||
img_time.sdy.selected = 1
|
||||
img_time.shr.selected = 1
|
||||
img_time.smn.selected = 1
|
||||
img_time.ssc.selected = 1
|
||||
img_time.adir.selected = 1
|
||||
end
|
||||
|
||||
local function install_module()
|
||||
if not img_time.module_installed then
|
||||
dt.register_lib(
|
||||
"image_time", -- Module name
|
||||
_("image time"), -- Visible name
|
||||
true, -- expandable
|
||||
true, -- resetable
|
||||
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 100}}, -- containers
|
||||
img_time.widget,
|
||||
nil,-- view_enter
|
||||
nil -- view_leave
|
||||
)
|
||||
img_time.module_installed = true
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.gui.libs["image_time"].visible = false
|
||||
end
|
||||
|
||||
local function restart()
|
||||
dt.gui.libs["image_time"].visible = true
|
||||
end
|
||||
|
||||
-- widgets
|
||||
|
||||
img_time.widgets = {
|
||||
-- name, type, tooltip, placeholder,
|
||||
{"ayr", "combobox", _("years"), _("years to adjust by, 0 - ?"), {seq(0,20)}, 1},
|
||||
{"amo", "combobox", _("months"), _("months to adjust by, 0-12"), {seq(0,12)}, 1},
|
||||
{"ady", "combobox", _("days"), _("days to adjust by, 0-31"), {seq(0,31)}, 1},
|
||||
{"ahr", "combobox", _("hours"), _("hours to adjust by, 0-23"), {seq(0,23)}, 1},
|
||||
{"amn", "combobox", _("minutes"), _("minutes to adjust by, 0-59"), {seq(0,59)}, 1},
|
||||
{"asc", "combobox", _("seconds"), _("seconds to adjust by, 0-59"), {seq(0,59)}, 1},
|
||||
{"adir", "combobox", _("add/subtract"), _("add or subtract time"), {_("add"), _("subtract")}, 1},
|
||||
{"syr", "combobox", _("year"), _("year to set, 1900 - now"), {" ", seq(1900,os.date("*t", os.time()).year)}, 1},
|
||||
{"smo", "combobox", _("month"), _("month to set, 1-12"), {" ", seq(1,12)}, 1},
|
||||
{"sdy", "combobox", _("day"), _("day to set, 1-31"), {" ", seq(1,31)}, 1},
|
||||
{"shr", "combobox", _("hour"), _("hour to set, 0-23"), {" ", seq(0,23)}, 1},
|
||||
{"smn", "combobox", _("minute"), _("minutes to set, 0-59"), {" ", seq(0, 59)}, 1},
|
||||
{"ssc", "combobox", _("seconds"), _("seconds to set, 0-59"), {" ", seq(0,59)}, 1},
|
||||
{"sdir", "combobox", _("add/subtract"), _("add or subtract time"), {_("add"), _("subtract")}, 1},
|
||||
}
|
||||
|
||||
for _, widget in ipairs(img_time.widgets) do
|
||||
img_time[widget[1]] = dt.new_widget(widget[2]){
|
||||
label = widget[3],
|
||||
tooltip = widget[4],
|
||||
selected = widget[6],
|
||||
table.unpack(widget[5])
|
||||
}
|
||||
end
|
||||
|
||||
img_time.syr.selected = #img_time.syr
|
||||
|
||||
img_time.diff_entry = dt.new_widget("entry"){
|
||||
tooltip = _("time difference between images in seconds"),
|
||||
placeholder = _("select 2 images and use the calculate button"),
|
||||
text = "",
|
||||
}
|
||||
|
||||
img_time.calc_btn = dt.new_widget("button"){
|
||||
label = _("calculate"),
|
||||
tooltip = _("calculate time difference between 2 images"),
|
||||
clicked_callback = function()
|
||||
calculate_difference(dt.gui.action_images)
|
||||
end
|
||||
}
|
||||
|
||||
img_time.btn = dt.new_widget("button"){
|
||||
label = _("synchronize image times"),
|
||||
tooltip = _("apply the time difference from selected images"),
|
||||
sensitive = false,
|
||||
clicked_callback = function()
|
||||
synchronize_time(dt.gui.action_images)
|
||||
end
|
||||
}
|
||||
|
||||
img_time.stack = dt.new_widget("stack"){
|
||||
dt.new_widget("box"){
|
||||
orientation = "vertical",
|
||||
dt.new_widget("label"){label = _("adjust time")},
|
||||
dt.new_widget("section_label"){label = _("days, months, years")},
|
||||
img_time.ady,
|
||||
img_time.amo,
|
||||
img_time.ayr,
|
||||
dt.new_widget("section_label"){label = _("hours, minutes, seconds")},
|
||||
img_time.ahr,
|
||||
img_time.amn,
|
||||
img_time.asc,
|
||||
dt.new_widget("section_label"){label = _("adjustment direction")},
|
||||
img_time.adir,
|
||||
dt.new_widget("button"){
|
||||
label = _("adjust"),
|
||||
clicked_callback = function()
|
||||
adjust_time(dt.gui.action_images)
|
||||
end
|
||||
}
|
||||
},
|
||||
dt.new_widget("box"){
|
||||
orientation = "vertical",
|
||||
dt.new_widget("label"){label = _("set time")},
|
||||
dt.new_widget("section_label"){label = _("date:")},
|
||||
img_time.sdy,
|
||||
img_time.smo,
|
||||
img_time.syr,
|
||||
dt.new_widget("section_label"){label = _("time:")},
|
||||
img_time.shr,
|
||||
img_time.smn,
|
||||
img_time.ssc,
|
||||
dt.new_widget("button"){
|
||||
label = _("set"),
|
||||
clicked_callback = function()
|
||||
set_time(dt.gui.action_images)
|
||||
end
|
||||
}
|
||||
},
|
||||
dt.new_widget("box"){
|
||||
orientation = "vertical",
|
||||
dt.new_widget("label"){label = _("synchronize image time")},
|
||||
dt.new_widget("section_label"){label = _("calculate difference between images")},
|
||||
img_time.diff_entry,
|
||||
img_time.calc_btn,
|
||||
dt.new_widget("section_label"){label = _("apply difference")},
|
||||
img_time.sdir,
|
||||
img_time.btn,
|
||||
},
|
||||
dt.new_widget("box"){
|
||||
orientation = "vertical",
|
||||
dt.new_widget("label"){label = _("reset to original time")},
|
||||
dt.new_widget("separator"){},
|
||||
dt.new_widget("button"){
|
||||
label = _("reset"),
|
||||
clicked_callback = function()
|
||||
reset_time(dt.gui.action_images)
|
||||
end
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
img_time.mode = dt.new_widget("combobox"){
|
||||
label = _("mode"),
|
||||
tooltip = _("select mode"),
|
||||
selected = 1,
|
||||
changed_callback = function(this)
|
||||
img_time.stack.active = this.selected
|
||||
end,
|
||||
_("adjust time"),
|
||||
_("set time"),
|
||||
_("synchronize time"),
|
||||
_("reset time")
|
||||
}
|
||||
|
||||
img_time.widget = dt.new_widget("box"){
|
||||
orientation = "vertical",
|
||||
reset_callback = function(this)
|
||||
reset_widgets()
|
||||
end,
|
||||
img_time.mode,
|
||||
img_time.stack,
|
||||
}
|
||||
|
||||
if dt.gui.current_view().id == "lighttable" then
|
||||
install_module()
|
||||
else
|
||||
if not img_time.event_registered then
|
||||
dt.register_event(
|
||||
"image_time", "view-changed",
|
||||
function(event, old_view, new_view)
|
||||
if new_view.name == "lighttable" and old_view.name == "darkroom" then
|
||||
install_module()
|
||||
end
|
||||
end
|
||||
)
|
||||
img_time.event_registered = true
|
||||
end
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
script_data.restart = restart
|
||||
script_data.destroy_method = "hide"
|
||||
script_data.show = restart
|
||||
|
||||
return script_data
|
||||
200
contrib/jpg_group_leader.lua
Normal file
200
contrib/jpg_group_leader.lua
Normal file
@@ -0,0 +1,200 @@
|
||||
--[[
|
||||
|
||||
jpg_group_leader.lua - Make jpg image group leader
|
||||
|
||||
Copyright (C) 2024 Bill Ferguson <wpferguson@gmail.com>.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
jpg_group_leader - Make jpg image group leader
|
||||
|
||||
After a film roll is imported, check for RAW-JPG image groups
|
||||
and make the JPG image the group leader. This is on by default
|
||||
but can be disabled in preferences.
|
||||
|
||||
Shortcuts are included to filter existing collections or
|
||||
selections of images and make the jpg the group leader.
|
||||
|
||||
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
None
|
||||
|
||||
USAGE
|
||||
Start script from script_manager
|
||||
Assign keys to the shortcuts
|
||||
|
||||
BUGS, COMMENTS, SUGGESTIONS
|
||||
Bill Ferguson <wpferguson@gmail.com>
|
||||
|
||||
CHANGES
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- C O N S T A N T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local MODULE = "jpg_group_leader"
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- A P I C H E C K
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
du.check_min_api_version("7.0.0", MODULE)
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- I 1 8 N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- S C R I P T M A N A G E R I N T E G R A T I O N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("JPG group leader"),
|
||||
purpose = _("make jpg image group leader"),
|
||||
author = "Bill Ferguson <wpferguson@gmail.com>",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/jpg_group_leader"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- P R E F E R E N C E S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
dt.preferences.register(MODULE, "on_import", "bool", _("make jpg group leader on import"), _("automatically make the jpg file the group leader when raw + jpg are imported"), true)
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- N A M E S P A C E
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local jgloi = {}
|
||||
jgloi.images = {}
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- F U N C T I O N S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function toggle_global_toolbox_grouping()
|
||||
dt.gui.libs.global_toolbox.grouping = false
|
||||
dt.gui.libs.global_toolbox.grouping = true
|
||||
end
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- M A I N P R O G R A M
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function make_jpg_group_leader(images)
|
||||
-- if the image is part of a group, make it the leader
|
||||
for _, image in ipairs(images) do
|
||||
if #image:get_group_members() > 1 then
|
||||
image:make_group_leader()
|
||||
end
|
||||
end
|
||||
if dt.gui.libs.global_toolbox.grouping then
|
||||
-- toggle the grouping to make the new leader show
|
||||
toggle_global_toolbox_grouping()
|
||||
end
|
||||
end
|
||||
|
||||
local function make_existing_jpg_group_leader(images)
|
||||
for _, image in ipairs(images) do
|
||||
if string.lower(df.get_filetype(image.filename)) == "jpg" then
|
||||
if #image:get_group_members() > 1 then
|
||||
image:make_group_leader()
|
||||
end
|
||||
end
|
||||
end
|
||||
if dt.gui.libs.global_toolbox.grouping then
|
||||
-- toggle the grouping to make the new leader show
|
||||
toggle_global_toolbox_grouping()
|
||||
end
|
||||
end
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- D A R K T A B L E I N T E G R A T I O N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function destroy()
|
||||
if dt.preferences.read(MODULE, "on_import", "bool") then
|
||||
dt.destroy_event(MODULE, "post-import-film")
|
||||
dt.destroy_event(MODULE, "post-import-image")
|
||||
end
|
||||
dt.destroy_event(MODULE .. "_collect", "shortcut")
|
||||
dt.destroy_event(MODULE .. "_select", "shortcut")
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- E V E N T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
if dt.preferences.read(MODULE, "on_import", "bool") then
|
||||
dt.register_event(MODULE, "post-import-film",
|
||||
function(event, film_roll)
|
||||
-- ignore the film roll, it contains all the images, not just the imported
|
||||
local images = jgloi.images
|
||||
if #images > 0 then
|
||||
jgloi.images = {}
|
||||
make_jpg_group_leader(images)
|
||||
end
|
||||
end
|
||||
)
|
||||
|
||||
dt.register_event(MODULE, "post-import-image",
|
||||
function(event, image)
|
||||
if string.lower(df.get_filetype(image.filename)) == "jpg" then
|
||||
table.insert(jgloi.images, image)
|
||||
end
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
dt.register_event(MODULE .. "_collect", "shortcut",
|
||||
function(event, shortcut)
|
||||
-- ignore the film roll, it contains all the images, not just the imported
|
||||
local images = dt.collection
|
||||
make_existing_jpg_group_leader(images)
|
||||
end,
|
||||
_("make jpg group leader for collection")
|
||||
)
|
||||
|
||||
dt.register_event(MODULE .. "_select", "shortcut",
|
||||
function(event, shortcut)
|
||||
local images = dt.gui.selection()
|
||||
make_existing_jpg_group_leader(images)
|
||||
end,
|
||||
_("make jpg group leader for selection")
|
||||
)
|
||||
|
||||
return script_data
|
||||
@@ -1,6 +1,7 @@
|
||||
--[[
|
||||
This file is part of darktable,
|
||||
Copyright 2014 by Tobias Jakobs.
|
||||
Copyright 2018 by Tobias Jakobs.
|
||||
Copyright 2018 by Erik Augustin.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
@@ -18,123 +19,159 @@
|
||||
--[[
|
||||
darktable KML export script
|
||||
|
||||
ADDITIANAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
* mkdir
|
||||
* zip (only if you create KMZ files)
|
||||
* convert (ImageMagick)
|
||||
* xdg-open
|
||||
* xdg-user-dir
|
||||
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
* zip (at the moment Linux only and only if you create KMZ files)
|
||||
* magick (ImageMagick)
|
||||
* xdg-user-dir (Linux)
|
||||
|
||||
WARNING
|
||||
This script is only testet with Linux
|
||||
This script is only tested with Linux
|
||||
|
||||
USAGE
|
||||
* require this script from your main lua file
|
||||
* require this script from your main Lua file
|
||||
* when choosing file format, pick JPEG or PNG as Google Earth doesn't support other formats
|
||||
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
dt.configuration.check_version(...,{2,0,1})
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
local ds = require "lib/dtutils.string"
|
||||
local dsys = require "lib/dtutils.system"
|
||||
|
||||
local function spairs(_table, order) -- Code copied from http://stackoverflow.com/questions/15706270/sort-a-table-in-lua
|
||||
-- collect the keys
|
||||
local keys = {}
|
||||
for _key in pairs(_table) do keys[#keys + 1] = _key end
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
-- if order function given, sort by it by passing the table and keys a, b,
|
||||
-- otherwise just sort the keys
|
||||
if order then
|
||||
table.sort(keys, function(a,b) return order(_table, a, b) end)
|
||||
else
|
||||
table.sort(keys)
|
||||
local PS = dt.configuration.running_os == "windows" and "\\" or "/"
|
||||
|
||||
du.check_min_api_version("7.0.0", "kml_export")
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return the iterator function
|
||||
local i = 0
|
||||
return function()
|
||||
i = i + 1
|
||||
if keys[i] then
|
||||
return keys[i], _table[keys[i]]
|
||||
end
|
||||
end
|
||||
end
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("kml export"),
|
||||
purpose = _("export KML/KMZ data to a file"),
|
||||
author = "Tobias Jakobs",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/kml_export"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
local function show_status(storage, image, format, filename, number, total, high_quality, extra_data)
|
||||
dt.print("Export Image "..tostring(number).."/"..tostring(total))
|
||||
dt.print(string.format(_("export image %i/%i"), number, total))
|
||||
end
|
||||
|
||||
local function checkIfBinExists(bin)
|
||||
local handle = io.popen("which "..bin)
|
||||
local result = handle:read()
|
||||
local ret
|
||||
handle:close()
|
||||
if (not result) then
|
||||
dt.print_error(bin.." not found")
|
||||
ret = false
|
||||
-- Add duplicate index to filename
|
||||
-- image.filename does not have index, exported_image has index
|
||||
function addDuplicateIndex( index, filename )
|
||||
if index > 0 then
|
||||
filename = filename.."_"
|
||||
if index < 10 then
|
||||
filename = filename.."0"
|
||||
end
|
||||
ret = true
|
||||
return ret
|
||||
filename = filename..index
|
||||
end
|
||||
|
||||
return filename
|
||||
end
|
||||
|
||||
local function create_kml_file(storage, image_table, extra_data)
|
||||
|
||||
if not checkIfBinExists("mkdir") then
|
||||
return
|
||||
end
|
||||
if not checkIfBinExists("convert") then
|
||||
return
|
||||
end
|
||||
if not checkIfBinExists("xdg-open") then
|
||||
return
|
||||
end
|
||||
if not checkIfBinExists("xdg-user-dir") then
|
||||
return
|
||||
local magickPath
|
||||
if dt.configuration.running_os == "linux" then
|
||||
magickPath = 'convert'
|
||||
else
|
||||
magickPath = dt.preferences.read("kml_export","magickPath","string")
|
||||
end
|
||||
|
||||
dt.print_error("Will try to export KML file now")
|
||||
if not df.check_if_bin_exists(magickPath) then
|
||||
dt.print_error("magick not found")
|
||||
return
|
||||
end
|
||||
if dt.configuration.running_os == "linux" then
|
||||
if not df.check_if_bin_exists("xdg-user-dir") then
|
||||
dt.print_error("xdg-user-dir not found")
|
||||
return
|
||||
end
|
||||
end
|
||||
dt.print_log("Will try to export KML file now")
|
||||
|
||||
if ( dt.preferences.read("kml_export","CreateKMZ","bool") == true ) then
|
||||
if not checkIfBinExists("zip") then
|
||||
local imageFoldername
|
||||
if ( dt.preferences.read("kml_export","CreateKMZ","bool") == true
|
||||
and dt.configuration.running_os == "linux") then
|
||||
if not df.check_if_bin_exists("zip") then
|
||||
dt.print_error("zip not found")
|
||||
return
|
||||
end
|
||||
exportDirectory = dt.configuration.tmp_dir
|
||||
imageFoldername = ""
|
||||
else
|
||||
exportDirectory = dt.preferences.read("kml_export","ExportDirectory","string")
|
||||
|
||||
-- Creates dir if not exsists
|
||||
imageFoldername = "files/"
|
||||
local mkdirCommand = "mkdir -p "..exportDirectory.."/"..imageFoldername
|
||||
coroutine.yield("RUN_COMMAND", mkdirCommand)
|
||||
imageFoldername = "files"..PS
|
||||
df.mkdir(df.sanitize_filename(exportDirectory..PS..imageFoldername))
|
||||
end
|
||||
|
||||
|
||||
-- Create the thumbnails
|
||||
for _,image in pairs(image_table) do
|
||||
local path, filename, filetype = string.match(image, "(.-)([^\\/]-%.?([^%.\\/]*))$")
|
||||
filename = string.upper(string.gsub(filename,"%.", "_"))
|
||||
for image,exported_image in pairs(image_table) do
|
||||
if ((image.longitude and image.latitude) and
|
||||
(image.longitude ~= 0 and image.latitude ~= 90) -- Sometimes the north-pole but most likely just wrong data
|
||||
) then
|
||||
local path, filename, filetype = string.match(exported_image, "(.-)([^\\/]-%.?([^%.\\/]*))$")
|
||||
filename = string.upper(string.gsub(filename,"%.%w*", ""))
|
||||
|
||||
-- convert -size 92x92 filename.jpg -resize 92x92 +profile "*" thumbnail.jpg
|
||||
-- magick -size 92x92 filename.jpg -resize 92x92 +profile "*" thumbnail.jpg
|
||||
-- In this example, '-size 120x120' gives a hint to the JPEG decoder that the image is going to be downscaled to
|
||||
-- 120x120, allowing it to run faster by avoiding returning full-resolution images to GraphicsMagick for the
|
||||
-- subsequent resizing operation. The '-resize 120x120' specifies the desired dimensions of the output image. It
|
||||
-- will be scaled so its largest dimension is 120 pixels. The '+profile "*"' removes any ICM, EXIF, IPTC, or other
|
||||
-- profiles that might be present in the input and aren't needed in the thumbnail.
|
||||
|
||||
local concertToThumbCommand = "convert -size 96x96 "..image.." -resize 92x92 -mattecolor \"#FFFFFF\" -frame 2x2 +profile \"*\" "..exportDirectory.."/"..imageFoldername.."thumb_"..filename..".jpg"
|
||||
-- USE coroutine.yield. It does not block the UI
|
||||
coroutine.yield("RUN_COMMAND", concertToThumbCommand)
|
||||
local concertCommand = "convert -size 438x438 "..image.." -resize 438x438 +profile \"*\" "..exportDirectory.."/"..imageFoldername..filename..".jpg"
|
||||
coroutine.yield("RUN_COMMAND", concertCommand)
|
||||
local convertToThumbCommand = ds.sanitize(magickPath) .. " -size 96x96 " .. exported_image .. " -resize 92x92 -mattecolor \"#FFFFFF\" -frame 2x2 +profile \"*\" " .. exportDirectory .. PS .. imageFoldername .. "thumb_" .. filename .. ".jpg"
|
||||
|
||||
if (exported_image ~= exportDirectory..PS..imageFoldername..filename.."."..filetype) then
|
||||
df.file_copy(exported_image, exportDirectory..PS..imageFoldername..filename.."."..filetype)
|
||||
end
|
||||
dsys.external_command(convertToThumbCommand)
|
||||
|
||||
else
|
||||
-- Remove exported image if it has no GPS data
|
||||
os.remove(exported_image)
|
||||
end
|
||||
|
||||
local pattern = "[/]?([^/]+)$"
|
||||
filmName = string.match(_.film.path, pattern)
|
||||
filmName = string.match(image.film.path, pattern)
|
||||
|
||||
-- Strip accents from the filename, because GoogleEarth can't open them
|
||||
-- https://github.com/darktable-org/lua-scripts/issues/54
|
||||
filmName = ds.strip_accents(filmName)
|
||||
|
||||
-- Remove chars we don't like to have in filenames
|
||||
filmName = string.gsub(filmName, [[\]], "")
|
||||
filmName = string.gsub(filmName, [[/]], "")
|
||||
filmName = string.gsub(filmName, [[:]], "")
|
||||
filmName = string.gsub(filmName, [["]], "")
|
||||
filmName = string.gsub(filmName, "<", "")
|
||||
filmName = string.gsub(filmName, ">", "")
|
||||
filmName = string.gsub(filmName, "|", "")
|
||||
filmName = string.gsub(filmName, "*", "")
|
||||
filmName = string.gsub(filmName, "?", "")
|
||||
filmName = string.gsub(filmName,'[.]', "") -- At least Windwows has problems with the "." and the start command
|
||||
end
|
||||
|
||||
exportKMLFilename = filmName..".kml"
|
||||
exportKMZFilename = filmName..".kmz"
|
||||
|
||||
-- Create the KML file
|
||||
kml_file = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||||
local kml_file = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||||
kml_file = kml_file.."<kml xmlns=\"http://www.opengis.net/kml/2.2\">\n"
|
||||
kml_file = kml_file.."<Document>\n"
|
||||
|
||||
@@ -142,16 +179,30 @@ local function create_kml_file(storage, image_table, extra_data)
|
||||
kml_file = kml_file.."<name>"..filmName.."</name>\n"
|
||||
kml_file = kml_file.." <description>Exported from darktable</description>\n"
|
||||
|
||||
for image,_ in pairs(image_table) do
|
||||
filename = string.upper(string.gsub(image.filename,"%.", "_"))
|
||||
for image,exported_image in pairs(image_table) do
|
||||
-- Extract filename, e.g DSC9784.ARW -> DSC9784
|
||||
filename = string.upper(string.gsub(image.filename,"%.%w*", ""))
|
||||
-- Handle duplicates
|
||||
filename = addDuplicateIndex( image.duplicate_index, filename )
|
||||
-- Extract extension from exported image (user can choose JPG or PNG), e.g DSC9784.JPG -> .JPG
|
||||
extension = string.match(exported_image,"%.%w*$")
|
||||
|
||||
if ((image.longitude and image.latitude) and
|
||||
(image.longitude ~= 0 and image.latitude ~= 90) -- Sometimes the north-pole but most likely just wrong data
|
||||
) then
|
||||
kml_file = kml_file.." <Placemark>\n"
|
||||
kml_file = kml_file.." <name>"..image.filename.."</name>\n"
|
||||
--kml_file = kml_file.." <description></description>\n"
|
||||
|
||||
local image_title, image_description
|
||||
if (image.title and image.title ~= "") then
|
||||
image_title = ds.escape_xml_characters(image.title)
|
||||
else
|
||||
image_title = filename..extension
|
||||
end
|
||||
-- Characters should not be escaped in CDATA, but we are using HTML fragment, so we must escape them
|
||||
image_description = ds.escape_xml_characters(image.description)
|
||||
|
||||
kml_file = kml_file.." <name>"..image_title.."</name>\n"
|
||||
kml_file = kml_file.." <description>"..image_description.."</description>\n"
|
||||
kml_file = kml_file.." <Style>\n"
|
||||
kml_file = kml_file.." <IconStyle>\n"
|
||||
kml_file = kml_file.." <Icon>\n"
|
||||
@@ -159,7 +210,7 @@ local function create_kml_file(storage, image_table, extra_data)
|
||||
kml_file = kml_file.." </Icon>\n"
|
||||
kml_file = kml_file.." </IconStyle>\n"
|
||||
kml_file = kml_file.." <BalloonStyle>\n"
|
||||
kml_file = kml_file.." <text><![CDATA[<img src=\""..imageFoldername..filename..".jpg\"><br/>]]></text>\n"
|
||||
kml_file = kml_file.." <text><![CDATA[<p><b>"..image_title.."</b></p><img src=\""..imageFoldername..filename..extension.."\"><p>"..image_description.."</p>]]></text>\n"
|
||||
kml_file = kml_file.." <textColor>ff000000</textColor>\n"
|
||||
kml_file = kml_file.." <displayMode>default</displayMode>\n"
|
||||
kml_file = kml_file.." </BalloonStyle>\n"
|
||||
@@ -193,13 +244,19 @@ local function create_kml_file(storage, image_table, extra_data)
|
||||
kml_file = kml_file.." <LineString>\n"
|
||||
kml_file = kml_file.." <coordinates>\n"
|
||||
|
||||
for image,_ in spairs(image_table, function(t,a,b) return t[b] < t[a] end) do
|
||||
for image,exported_image in du.spairs(image_table, function(t,a,b) return b.exif_datetime_taken > a.exif_datetime_taken end) do
|
||||
if ((image.longitude and image.latitude) and
|
||||
(image.longitude ~= 0 and image.latitude ~= 90) -- Sometimes the north-pole but most likely just wrong data
|
||||
) then
|
||||
kml_file = kml_file.." "..string.gsub(tostring(image.longitude),",", ".")..","..string.gsub(tostring(image.latitude),",", ".")..",0\n"
|
||||
local altitude = 0;
|
||||
if (image.elevation) then
|
||||
altitude = image.elevation;
|
||||
end
|
||||
|
||||
kml_file = kml_file.." "..string.gsub(tostring(image.longitude),",", ".")..","..string.gsub(tostring(image.latitude),",", ".")..",altitude\n"
|
||||
end
|
||||
end
|
||||
|
||||
kml_file = kml_file.." </coordinates>\n"
|
||||
kml_file = kml_file.." </LineString>\n"
|
||||
|
||||
@@ -209,47 +266,129 @@ local function create_kml_file(storage, image_table, extra_data)
|
||||
kml_file = kml_file.."</Document>\n"
|
||||
kml_file = kml_file.."</kml>"
|
||||
|
||||
file = io.open(exportDirectory.."/"..exportKMLFilename, "w")
|
||||
local file = io.open(exportDirectory..PS..exportKMLFilename, "w")
|
||||
|
||||
file:write(kml_file)
|
||||
file:close()
|
||||
|
||||
dt.print("KML file created in "..exportDirectory)
|
||||
|
||||
-- Compress the files to create a KMZ file
|
||||
if ( dt.preferences.read("kml_export","CreateKMZ","bool") == true ) then
|
||||
if ( dt.preferences.read("kml_export","CreateKMZ","bool") == true
|
||||
and dt.configuration.running_os == "linux") then
|
||||
exportDirectory = dt.preferences.read("kml_export","ExportDirectory","string")
|
||||
-- USE coroutine.yield. It does not block the UI
|
||||
|
||||
local createKMZCommand = "zip --test --move --junk-paths "
|
||||
createKMZCommand = createKMZCommand .."\""..exportDirectory.."/"..exportKMZFilename.."\" "
|
||||
createKMZCommand = createKMZCommand .."\""..dt.configuration.tmp_dir.."/"..exportKMLFilename.."\" \""..dt.configuration.tmp_dir.."/"..imageFoldername.."\"*"
|
||||
coroutine.yield("RUN_COMMAND", createKMZCommand)
|
||||
createKMZCommand = createKMZCommand .."\""..exportDirectory..PS..exportKMZFilename.."\" " -- KMZ filename
|
||||
createKMZCommand = createKMZCommand .."\""..dt.configuration.tmp_dir..PS..exportKMLFilename.."\" " -- KML file
|
||||
|
||||
for image,exported_image in pairs(image_table) do
|
||||
if ((image.longitude and image.latitude) and
|
||||
(image.longitude ~= 0 and image.latitude ~= 90) -- Sometimes the north-pole but most likely just wrong data
|
||||
) then
|
||||
local filename = string.upper(string.gsub(image.filename,"%.%w*", ""))
|
||||
-- Handle duplicates
|
||||
filename = addDuplicateIndex( image.duplicate_index, filename )
|
||||
|
||||
createKMZCommand = createKMZCommand .."\""..dt.configuration.tmp_dir..PS..imageFoldername.."thumb_"..filename..".jpg\" " -- thumbnails
|
||||
createKMZCommand = createKMZCommand .."\""..exported_image.."\" " -- images
|
||||
end
|
||||
end
|
||||
|
||||
dt.control.execute(createKMZCommand)
|
||||
end
|
||||
|
||||
-- Open the file with the standard programm
|
||||
if ( dt.preferences.read("kml_export","OpenKmlFile","bool") == true ) then
|
||||
local kmlFileOpenCommand
|
||||
local path
|
||||
|
||||
if ( dt.preferences.read("kml_export","CreateKMZ","bool") == true ) then
|
||||
kmlFileOpenCommand = "xdg-open "..exportDirectory.."/\""..exportKMZFilename.."\""
|
||||
if ( dt.preferences.read("kml_export","CreateKMZ","bool") == true
|
||||
and dt.configuration.running_os == "linux") then
|
||||
path = exportDirectory..PS..exportKMZFilename
|
||||
else
|
||||
kmlFileOpenCommand = "xdg-open "..exportDirectory.."/\""..exportKMLFilename.."\""
|
||||
end
|
||||
coroutine.yield("RUN_COMMAND", kmlFileOpenCommand)
|
||||
path = exportDirectory..PS..exportKMLFilename
|
||||
end
|
||||
|
||||
dsys.launch_default_app(df.sanitize_filename(path))
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.destroy_storage("kml_export")
|
||||
end
|
||||
|
||||
-- Preferences
|
||||
dt.preferences.register("kml_export", "OpenKmlFile", "bool", "KML export: Open KML/KMZ file after export", "Opens the KML file after the export with the standard programm for KML files", false )
|
||||
if dt.configuration.running_os == "windows" then
|
||||
dt.preferences.register("kml_export",
|
||||
"OpenKmlFile",
|
||||
"bool",
|
||||
_("KML export: Open KML file after export"),
|
||||
_("opens the KML file after the export with the standard program for KML files"),
|
||||
false )
|
||||
else
|
||||
dt.preferences.register("kml_export",
|
||||
"OpenKmlFile",
|
||||
"bool",
|
||||
_("KML export: open KML/KMZ file after export"),
|
||||
_("opens the KML file after the export with the standard program for KML files"),
|
||||
false )
|
||||
end
|
||||
|
||||
local defaultDir = ''
|
||||
if dt.configuration.running_os == "windows" then
|
||||
defaultDir = os.getenv("USERPROFILE")
|
||||
elseif dt.configuration.running_os == "macos" then
|
||||
defaultDir = os.getenv("HOME")
|
||||
else
|
||||
local handle = io.popen("xdg-user-dir DESKTOP")
|
||||
local result = handle:read()
|
||||
defaultDir = handle:read()
|
||||
handle:close()
|
||||
dt.preferences.register("kml_export", "ExportDirectory", "directory", "KML export: Export directory", "A directory that will be used to export the KML/KMZ files", result )
|
||||
end
|
||||
|
||||
dt.preferences.register("kml_export", "CreatePath", "bool", "KML export: Connect images with path", "connect all images with a path", false )
|
||||
dt.preferences.register("kml_export", "CreateKMZ", "bool", "KML export: Create KMZ file", "Compress all imeges to one KMZ file", true )
|
||||
|
||||
dt.preferences.register("kml_export",
|
||||
"ExportDirectory",
|
||||
"directory",
|
||||
_("KML export: export directory"),
|
||||
_("a directory that will be used to export the KML/KMZ files"),
|
||||
defaultDir )
|
||||
|
||||
if dt.configuration.running_os ~= "linux" then
|
||||
dt.preferences.register("kml_export",
|
||||
"magickPath", -- name
|
||||
"file", -- type
|
||||
_("KML export: ImageMagick binary location"), -- label
|
||||
_("install location of magick[.exe], requires restart to take effect"), -- tooltip
|
||||
"magick") -- default
|
||||
end
|
||||
|
||||
dt.preferences.register("kml_export",
|
||||
"CreatePath",
|
||||
"bool",
|
||||
_("KML export: connect images with path"),
|
||||
_("connect all images with a path"),
|
||||
false )
|
||||
|
||||
if dt.configuration.running_os == "linux" then
|
||||
dt.preferences.register("kml_export",
|
||||
"CreateKMZ",
|
||||
"bool",
|
||||
_("KML export: create KMZ file"),
|
||||
_("compress all imeges to one KMZ file"),
|
||||
true )
|
||||
end
|
||||
|
||||
-- Register
|
||||
dt.register_storage("kml_export", "KML/KMZ Export", nil, create_kml_file)
|
||||
if dt.configuration.running_os == "windows" then
|
||||
dt.register_storage("kml_export", _("KML export"), nil, create_kml_file)
|
||||
else
|
||||
dt.register_storage("kml_export", _("KML/KMZ export"), nil, create_kml_file)
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
return script_data
|
||||
|
||||
-- vim: shiftwidth=2 expandtab tabstop=2 cindent syntax=lua
|
||||
-- kate: hl Lua;
|
||||
|
||||
114
contrib/passport_guide.lua
Normal file
114
contrib/passport_guide.lua
Normal file
@@ -0,0 +1,114 @@
|
||||
--[[
|
||||
Passport cropping guide for darktable
|
||||
|
||||
copyright (c) 2017 Kåre Hampf
|
||||
|
||||
darktable is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
darktable is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with darktable. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
|
||||
--[[
|
||||
PASSPORT CROPPING GUIDE
|
||||
guides for cropping passport photos based on documents from the Finnish police
|
||||
(https://poliisi.fi/documents/25235045/31329600/Passport-photograph-instructions-by-the-police-2020-EN-fixed.pdf/1eec2f4c-aed7-68e0-c112-0a8f25e0328d/Passport-photograph-instructions-by-the-police-2020-EN-fixed.pdf) describing passport photo dimensions of 47x36 mm and 500x653 px for digital biometric data stored in passports. They use ISO 19794-5 standard based on ICAO 9303 regulations which should also be compliant for all of Europe.
|
||||
|
||||
INSTALLATION
|
||||
* copy this file in $CONFIGDIR/lua/ where CONFIGDIR is your darktable configuration directory
|
||||
* add the following line in the file $CONFIGDIR/luarc
|
||||
require "passport_guide"
|
||||
* (optional) add the line:
|
||||
"plugins/darkroom/clipping/extra_aspect_ratios/passport 36x47mm=47:36"
|
||||
to $CONFIGDIR/darktablerc
|
||||
|
||||
USAGE
|
||||
* when using the cropping tool, select "passport" as guide and if you added the line in yout rc
|
||||
select "passport 36x47mm" as aspect
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
du.check_min_api_version("2.0.0", "passport_guide")
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("passport guide"),
|
||||
purpose = _("guides for cropping passport photos"),
|
||||
author = "Kåre Hampf",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/passport_guide"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
dt.guides.register_guide("passport",
|
||||
-- draw
|
||||
function(cairo, x, y, width, height, zoom_scale)
|
||||
local _width, _height
|
||||
|
||||
-- get the max 36x47 rectangle
|
||||
local aspect_ratio = 47 / 36
|
||||
if width * aspect_ratio > height then
|
||||
_width = height / aspect_ratio
|
||||
_height = height
|
||||
else
|
||||
_width = width
|
||||
_height = width * aspect_ratio
|
||||
end
|
||||
|
||||
cairo:save()
|
||||
|
||||
cairo:translate(x + (width - _width) / 2, y + (height - _height) / 2)
|
||||
cairo:scale(_width / 36, _height / 47)
|
||||
|
||||
-- the outer rectangle
|
||||
cairo:rectangle( 0, 0, 36, 47)
|
||||
|
||||
-- vertical bars
|
||||
cairo:draw_line(16.5, 8, 16.5, 36)
|
||||
cairo:draw_line(19.5, 8, 19.5, 36)
|
||||
|
||||
-- long horisontal bars
|
||||
cairo:draw_line(6, 4, 30, 4)
|
||||
cairo:draw_line(6, 40, 30, 40)
|
||||
|
||||
-- short horisontal bars
|
||||
cairo:draw_line(9, 6, 27, 6)
|
||||
cairo:draw_line(9, 38, 27, 38)
|
||||
|
||||
cairo:restore()
|
||||
end,
|
||||
-- gui
|
||||
function()
|
||||
return dt.new_widget("label"){label = _("ISO 19794-5/ICAO 9309 passport"), halign = "start"}
|
||||
end
|
||||
)
|
||||
|
||||
local function destroy()
|
||||
-- nothing to destroy
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
return script_data
|
||||
|
||||
-- kate: tab-indents: off; indent-width 2; replace-tabs on; remove-trailing-space on; hl Lua;
|
||||
-- vim: shiftwidth=2 expandtab tabstop=2 cindent syntax=lua
|
||||
125
contrib/passport_guide_germany.lua
Normal file
125
contrib/passport_guide_germany.lua
Normal file
@@ -0,0 +1,125 @@
|
||||
--[[
|
||||
German passport photo cropping guide for darktable.
|
||||
Derived from the passport cropping guide by Kåre Hampf.
|
||||
|
||||
copyright (c) 2017 Kåre Hampf
|
||||
copyright (c) 2024 Christian Sültrop
|
||||
|
||||
darktable is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
darktable is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with darktable. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
|
||||
--[[
|
||||
PASSPORT CROPPING GUIDE
|
||||
Guides for cropping passport and ID card ("Personalausweis") photos based on the "Passbild-Schablone"
|
||||
from the German Federal Ministry of the Interior and Community.
|
||||
(https://www.bmi.bund.de/SharedDocs/downloads/DE/veroeffentlichungen/themen/moderne-verwaltung/ausweise/passbild-schablone-erwachsene.pdf?__blob=publicationFile&v=3)
|
||||
|
||||
INSTALLATION
|
||||
* copy this file in $CONFIGDIR/lua/ where CONFIGDIR is your darktable configuration directory
|
||||
* add the following line in the file $CONFIGDIR/luarc
|
||||
require "passport_guide_germany"
|
||||
* (optional) add the line:
|
||||
"plugins/darkroom/clipping/extra_aspect_ratios/passport 35x45mm=45:35"
|
||||
to $CONFIGDIR/darktablerc
|
||||
|
||||
USAGE
|
||||
* when using the cropping tool, select "Passport Photo Germany" as guide and if you added the line in yout rc
|
||||
select "passport 35x45mm" as aspect
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
du.check_min_api_version("2.0.0", "passport_guide_germany")
|
||||
|
||||
-- Tell gettext where to find the .mo file translating messages for a particular domain
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("passport guide Germany"),
|
||||
purpose = _("guides for cropping passport and ID card (\"Personalausweis\") photos based on the \"Passbild-Schablone\" from the German Federal Ministry of the Interior and Community"),
|
||||
author = "menschmachine",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/passport_guide_germany"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
dt.guides.register_guide("Passport Photo Germany",
|
||||
-- draw
|
||||
function(cairo, x, y, width, height, zoom_scale)
|
||||
local _width, _height
|
||||
|
||||
-- get the max 35x45 rectangle
|
||||
local aspect_ratio = 45 / 35
|
||||
if width * aspect_ratio > height then
|
||||
_width = height / aspect_ratio
|
||||
_height = height
|
||||
else
|
||||
_width = width
|
||||
_height = width * aspect_ratio
|
||||
end
|
||||
|
||||
cairo:save()
|
||||
|
||||
cairo:translate(x + (width - _width) / 2, y + (height - _height) / 2)
|
||||
cairo:scale(_width / 35, _height / 45)
|
||||
|
||||
-- the outer rectangle
|
||||
cairo:rectangle( 0, 0, 35, 45)
|
||||
|
||||
-- Nose position: The nose tip must be between these lines
|
||||
cairo:draw_line(15.5, 45, 15.5, 13)
|
||||
cairo:draw_line(35-15.5, 45, 35-15.5, 13)
|
||||
|
||||
-- Face height
|
||||
-- optimum face height: The upper end of the head should be between these lines
|
||||
cairo:draw_line(0, 4, 35, 4)
|
||||
cairo:draw_line(0, 8, 35, 8)
|
||||
|
||||
-- tolerated face height: The upper end of the head must not be below this line
|
||||
cairo:draw_line(6, 13, 30, 13)
|
||||
|
||||
-- Eye area: The eyes must be between these lines
|
||||
cairo:draw_line(0, 13, 35, 13)
|
||||
cairo:draw_line(0, 23, 35, 23)
|
||||
|
||||
-- Cheek line: The cheek must lie on this line
|
||||
cairo:draw_line(9, 45-5, 27, 45-5)
|
||||
|
||||
cairo:restore()
|
||||
end,
|
||||
-- gui
|
||||
function()
|
||||
return dt.new_widget("label"){label = _("Passport Photo Germany"), halign = "start"}
|
||||
end
|
||||
)
|
||||
|
||||
local function destroy()
|
||||
-- noting to destroy
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
return script_data
|
||||
|
||||
-- kate: tab-indents: off; indent-width 2; replace-tabs on; remove-trailing-space on; hl Lua;
|
||||
-- vim: shiftwidth=2 expandtab tabstop=2 cindent syntax=lua
|
||||
267
contrib/pdf_slideshow.lua
Normal file
267
contrib/pdf_slideshow.lua
Normal file
@@ -0,0 +1,267 @@
|
||||
--[[
|
||||
This file is part of darktable,
|
||||
copyright (c) 2017 Pascal Obry
|
||||
|
||||
darktable is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
darktable is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with darktable. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
SELECTION_TO_PDF
|
||||
Generates a PDF slideshow (via Latex) containing all selected images
|
||||
one per slide.
|
||||
|
||||
ADDITIANAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
* a PDF-Viewer
|
||||
* pdflatex (Latex)
|
||||
|
||||
USAGE
|
||||
* require this file from your main lua config file:
|
||||
|
||||
This plugin will add a new exporter that will allow you to generate a pdf slideshow.
|
||||
The interface will let you add:
|
||||
- a global title for the slideshow (prefix in all slide label)
|
||||
- a delay for the transition between each slide
|
||||
|
||||
Each slide will contain a single picture with a label at the bottom with the
|
||||
format (all fields can be the empty string):
|
||||
|
||||
<global title> / <image creator> / <image title>
|
||||
|
||||
]]
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
if not df.check_if_bin_exists("pdflatex") then
|
||||
dt.print_error("pdflatex not found")
|
||||
return
|
||||
end
|
||||
|
||||
du.check_min_api_version("7.0.0", "pdf_slideshow")
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("PDF slideshow"),
|
||||
purpose = _("generates a PDF slideshow (via Latex) containing all selected images one per slide"),
|
||||
author = "Pascal Obry",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/pdf_slideshow"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
dt.preferences.register
|
||||
("pdf_slideshow","open with","string",
|
||||
_("a pdf viewer"),
|
||||
_("can be an absolute pathname or the tool may be in the PATH"),
|
||||
"xdg-open")
|
||||
|
||||
local title_widget = dt.new_widget("entry") {
|
||||
placeholder=_("slideshow title")
|
||||
}
|
||||
local delay_widget = dt.new_widget("slider")
|
||||
{
|
||||
label = _("transition delay (s)"),
|
||||
soft_min = 1, -- The soft minimum value for the slider, the slider can't go beyond this point
|
||||
soft_max = 20, -- The soft maximum value for the slider, the slider can't go beyond this point
|
||||
hard_min = 1, -- The hard minimum value for the slider, the user can't manually enter a value beyond this point
|
||||
hard_max = 60, -- The hard maximum value for the slider, the user can't manually enter a value beyond this point
|
||||
value = 5, -- The current value of the slider
|
||||
step = 1,
|
||||
digits = 0
|
||||
}
|
||||
local check_button_title = dt.new_widget("check_button")
|
||||
{
|
||||
label = _('include image title'),
|
||||
value = true,
|
||||
tooltip = _('whether to include the image title (if defined) into the slide')
|
||||
}
|
||||
local check_button_author = dt.new_widget("check_button")
|
||||
{
|
||||
label = _('include image author'),
|
||||
value = true,
|
||||
tooltip = _('whether to include the image author (if defined) into the slide')
|
||||
}
|
||||
local widget = dt.new_widget("box") {
|
||||
orientation=horizontal,
|
||||
dt.new_widget("label"){label = _("slideshow title")},
|
||||
title_widget,
|
||||
dt.new_widget("label"){label = ""},
|
||||
delay_widget,
|
||||
check_button_author,
|
||||
check_button_title
|
||||
}
|
||||
|
||||
local ending = [[
|
||||
\end{document}
|
||||
]]
|
||||
|
||||
-- only jpeg files are supported by latex
|
||||
local function support_format(storage, format)
|
||||
fmt = string.lower(format.name)
|
||||
if string.match(fmt,"jpeg") ~= nil and string.match(fmt,"2000") == nil then
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
local filename =dt.configuration.tmp_dir.."/pdfout.tex"
|
||||
|
||||
local function my_write(latexfile,arg)
|
||||
local res,errmsg = latexfile:write(arg)
|
||||
if not res then
|
||||
error(errmsg)
|
||||
end
|
||||
end
|
||||
|
||||
local function img_filename(file,i)
|
||||
local dir=string.gsub(file, "(.*)(%/.*%..*)", "%1")
|
||||
local ext=string.gsub(file, "(.*)(%..*)", "%2")
|
||||
local lfile = dir.."/slide"..i..ext
|
||||
return lfile
|
||||
end
|
||||
|
||||
local function slide(latexfile,i,image,file)
|
||||
local title = ""
|
||||
if title_widget.text ~= "" then
|
||||
title = title_widget.text
|
||||
end
|
||||
if check_button_author.value == true and image.creator ~= "" then
|
||||
if title ~= "" then
|
||||
title = title.." / "
|
||||
end
|
||||
title = title..image.creator
|
||||
end
|
||||
if check_button_title.value == true and image.title ~= "" then
|
||||
if title ~= "" then
|
||||
title = title.." / "
|
||||
end
|
||||
title = title..image.title
|
||||
end
|
||||
|
||||
-- make sure there is no non-printable characters in the title
|
||||
title = string.gsub(title, "\n", "")
|
||||
title = string.gsub(title, "\r", "")
|
||||
title = string.gsub(title, "\t", "")
|
||||
|
||||
local lfile = img_filename(file,i)
|
||||
os.rename(file, lfile)
|
||||
|
||||
my_write(latexfile,"\\begin{figure}\n")
|
||||
my_write(latexfile,"\\begin{measuredfigure}\n")
|
||||
my_write(latexfile,"\\includegraphics[height=0.92\\textheight]{"..lfile.."}\n")
|
||||
my_write(latexfile,"\\end{measuredfigure}\n")
|
||||
my_write(latexfile,"\\caption{{\\color{white}"..title.."}}\n")
|
||||
my_write(latexfile,"\\end{figure}\n")
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.destroy_storage("pdf_slideshow")
|
||||
end
|
||||
|
||||
dt.register_storage("pdf_slideshow",_("pdf slideshow"),
|
||||
nil,
|
||||
function(storage,image_table)
|
||||
|
||||
local preamble = [[
|
||||
\documentclass[a4paper,12pt,landscape]{beamer}
|
||||
\usetheme{default}
|
||||
\usepackage[utf8]{inputenc}
|
||||
\usepackage{graphicx}
|
||||
\usepackage{threeparttable}
|
||||
\usepackage[space]{grffile} % needed to support filename with spaces
|
||||
\pagestyle{empty}
|
||||
\parindent0pt
|
||||
\usepackage{geometry}
|
||||
\usepackage{color}
|
||||
\pagecolor{black!100}
|
||||
\color{white}
|
||||
\geometry{a4paper,landscape,left=2mm,right=2mm, top=2mm, bottom=2mm}
|
||||
\mode<presentation>
|
||||
\transduration{]]..string.gsub(delay_widget.value, ",", ".")..[[}
|
||||
\setbeamertemplate{footline}{}
|
||||
\setbeamertemplate{headline}{}
|
||||
\setbeamertemplate{frametitle}{}
|
||||
\setbeamertemplate{navigation symbols}{}
|
||||
\setbeamercolor{background canvas}{fg=white,bg=black!100}
|
||||
\setbeamercolor{normal text}{fg=white}
|
||||
\hypersetup{pdfstartpage=1,pdfpagemode=FullScreen}
|
||||
\setbeamertemplate{caption}{\insertcaption}
|
||||
\begin{document}
|
||||
]]
|
||||
|
||||
local errmsg
|
||||
local latexfile
|
||||
latexfile,errmsg=io.open(filename,"w")
|
||||
if not latexfile then
|
||||
error(errmsg)
|
||||
end
|
||||
my_write(latexfile,preamble)
|
||||
local i = 1
|
||||
for img,file in pairs(image_table) do
|
||||
slide(latexfile,i,img,file)
|
||||
my_write(latexfile,"\n\\bigskip\n")
|
||||
i = i+1
|
||||
end
|
||||
my_write(latexfile,ending)
|
||||
latexfile:close()
|
||||
|
||||
-- convert to PDF
|
||||
local dir=string.gsub(filename, "(.*/)(.*)", "%1")
|
||||
local locfile=string.gsub(filename, "(.*/)(.*)", "%2")
|
||||
local command = "pdflatex -halt-on-error -output-directory "..dir.." "..locfile
|
||||
local result = dt.control.execute(command)
|
||||
if result ~= 0 then
|
||||
dt.print(_("problem running pdflatex")) -- this one is probably usefull to the user
|
||||
error("problem running "..command)
|
||||
end
|
||||
|
||||
-- open the PDF
|
||||
local pdffile=string.gsub(filename, ".tex", ".pdf")
|
||||
command = dt.preferences.read("pdf_slideshow","open with","string")
|
||||
command = command.." "..pdffile
|
||||
local result = dt.control.execute(command)
|
||||
if result ~= 0 then
|
||||
dt.print(_("problem running pdf viewer")) -- this one is probably usefull to the user
|
||||
error("problem running "..command)
|
||||
end
|
||||
|
||||
-- finally do some clean-up
|
||||
local i = 1
|
||||
for img,file in pairs(image_table) do
|
||||
local lfile = img_filename(file,i)
|
||||
os.remove(lfile)
|
||||
i = i+1
|
||||
end
|
||||
end,
|
||||
support_format,
|
||||
nil,
|
||||
widget)
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
return script_data
|
||||
--
|
||||
-- vim: shiftwidth=2 expandtab tabstop=2 cindent syntax=lua
|
||||
507
contrib/photils.lua
Normal file
507
contrib/photils.lua
Normal file
@@ -0,0 +1,507 @@
|
||||
--[[ photils Auto Tagging plugin
|
||||
copyright (c) 2020 Tobias Scheck
|
||||
|
||||
darktable is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
darktable is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with darktable. If not, see <http://www.gnu.org/licenses/>.
|
||||
--]]
|
||||
|
||||
--[[
|
||||
A darktable plugin that tries to predict keywords based on the selected image.
|
||||
This plugin uses photils-cli to handle this task. Photils-cli is an application
|
||||
that passes the image through a neural network, classifies it, and extracts the
|
||||
suggested tags. Everything happens offline without the need that your data are
|
||||
sent over the internet.
|
||||
|
||||
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
* photils-cli - https://github.com/scheckmedia/photils-cli at the moment only
|
||||
available for Linux and MacOS
|
||||
|
||||
USAGE
|
||||
* require this script from your main lua file
|
||||
To do this add this line to the file .config/darktable/luarc:
|
||||
require "contrib/photils"
|
||||
* Select an image
|
||||
* Press "get tags"
|
||||
* Select the tags you want from a list of suggestions
|
||||
* Press "Attach .. Tags" to add the selected tags to your image
|
||||
--]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
local dtsys = require "lib/dtutils.system"
|
||||
|
||||
local MODULE_NAME = "photils"
|
||||
du.check_min_api_version("7.0.0", MODULE_NAME)
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("photils"),
|
||||
purpose = _("suggest tags based on image classification"),
|
||||
author = "Tobias Scheck",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/photils"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
|
||||
local PS = dt.configuration.running_os == "windows" and "\\" or "/"
|
||||
|
||||
local exporter = dt.new_format("jpeg")
|
||||
exporter.quality = 80
|
||||
exporter.max_height = 224
|
||||
exporter.max_width = 224
|
||||
|
||||
-- helper functions
|
||||
|
||||
local function num_keys(tbl)
|
||||
local num = 0
|
||||
for _ in pairs(tbl) do num = num + 1 end
|
||||
return num
|
||||
end
|
||||
|
||||
local function has_key(tbl, value)
|
||||
for k, _ in pairs(tbl) do
|
||||
if k == value then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
local photils_installed = df.check_if_bin_exists("photils-cli")
|
||||
|
||||
--[[
|
||||
local state object
|
||||
|
||||
maybe per_page is a preference variable but I think 10
|
||||
is a good value for the ui
|
||||
]]
|
||||
local PHOTILS = {
|
||||
tags = {},
|
||||
confidences = {},
|
||||
page = 1,
|
||||
per_page = 10,
|
||||
selected_tags = {},
|
||||
in_pagination = false,
|
||||
tagged_image = "",
|
||||
module_installed = false,
|
||||
event_registered = false,
|
||||
plugin_display_views = {
|
||||
[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 100},
|
||||
[dt.gui.views.darkroom] = {"DT_UI_CONTAINER_PANEL_LEFT_CENTER", 100}
|
||||
},
|
||||
}
|
||||
|
||||
local GUI = {
|
||||
container = dt.new_widget("box") {
|
||||
orientation = "vertical",
|
||||
sensitive = true,
|
||||
dt.new_widget("button") {
|
||||
label = _("get tags"),
|
||||
sensitive = photils_installed,
|
||||
clicked_callback = function() PHOTILS.on_tags_clicked() end
|
||||
},
|
||||
reset_callback = function() PHOTILS.on_reset(true) end
|
||||
},
|
||||
stack = dt.new_widget("stack"),
|
||||
prev_button = dt.new_widget("button") {
|
||||
label = "<",
|
||||
sensitive = false,
|
||||
clicked_callback = function()
|
||||
PHOTILS.page = PHOTILS.page - 1
|
||||
PHOTILS.paginate()
|
||||
end
|
||||
},
|
||||
next_button = dt.new_widget("button") {
|
||||
label = ">",
|
||||
sensitive = false,
|
||||
clicked_callback = function()
|
||||
PHOTILS.page = PHOTILS.page + 1
|
||||
PHOTILS.paginate()
|
||||
end
|
||||
},
|
||||
tag_box = dt.new_widget("box") {orientation = "vertical"},
|
||||
tag_view = dt.new_widget("box") {orientation = "vertical"},
|
||||
page_label = dt.new_widget("label") {label = ""},
|
||||
error_view = dt.new_widget("box") {orientation = "vertical"},
|
||||
warning_label = dt.new_widget("label") {
|
||||
label = ""
|
||||
},
|
||||
restart_required_label = dt.new_widget("label") {
|
||||
label = _("requires a restart to be applied")
|
||||
},
|
||||
attach_button = dt.new_widget("button") {
|
||||
label = "",
|
||||
sensitive = false,
|
||||
clicked_callback = function(self) PHOTILS.attach_tags() end
|
||||
},
|
||||
confidence_slider = dt.new_widget("slider") {
|
||||
step = 1,
|
||||
digits = 0,
|
||||
value = 90,
|
||||
hard_max = 100,
|
||||
hard_min = 0,
|
||||
soft_max = 100,
|
||||
soft_min = 0,
|
||||
label = _("min confidence value")
|
||||
},
|
||||
warning = dt.new_widget("label")
|
||||
}
|
||||
|
||||
function PHOTILS.image_changed()
|
||||
local current_image = tostring(dt.gui.selection()[1])
|
||||
if current_image ~= PHOTILS.tagged_image then
|
||||
if PHOTILS.tagged_image ~= "" then
|
||||
PHOTILS.tagged_image_has_changed()
|
||||
end
|
||||
|
||||
PHOTILS.tagged_image = tostring(current_image)
|
||||
end
|
||||
end
|
||||
|
||||
function PHOTILS.tagged_image_has_changed()
|
||||
GUI.warning.label = _("the suggested tags were not generated\n for the currently selected image!")
|
||||
end
|
||||
|
||||
function PHOTILS.paginate()
|
||||
PHOTILS.in_pagination = true
|
||||
local num_pages = math.ceil(#PHOTILS.tags / PHOTILS.per_page)
|
||||
GUI.page_label.label = string.format(_(" page %s of %s "), PHOTILS.page,
|
||||
num_pages)
|
||||
|
||||
if PHOTILS.page <= 1 then
|
||||
PHOTILS.page = 1
|
||||
GUI.prev_button.sensitive = false
|
||||
else
|
||||
GUI.prev_button.sensitive = true
|
||||
end
|
||||
|
||||
if PHOTILS.page > num_pages - 1 then
|
||||
PHOTILS.page = num_pages
|
||||
GUI.next_button.sensitive = false
|
||||
else
|
||||
GUI.next_button.sensitive = true
|
||||
end
|
||||
|
||||
--[[
|
||||
calculates the start positon in the tag array based on the current page
|
||||
and takes N tags from that array to show these in darktable
|
||||
e.g. page 1 goes from 1 to 10, page 2 from 11 to 20 a.s.o.
|
||||
the paginaton approach is related to a problem with the dynamic addition
|
||||
of mutliple widgets https://github.com/darktable-org/darktable/issues/4934#event-3318100463
|
||||
]]--
|
||||
local offset = ((PHOTILS.page - 1) * PHOTILS.per_page) + 1
|
||||
local tag_index = 1
|
||||
for i = offset, offset + PHOTILS.per_page - 1, 1 do
|
||||
local tag = PHOTILS.tags[i]
|
||||
local conf = PHOTILS.confidences[i]
|
||||
|
||||
GUI.tag_box[tag_index].value = has_key(PHOTILS.selected_tags, tag)
|
||||
|
||||
if tag then
|
||||
if dt.preferences.read(MODULE_NAME, "show_confidence", "bool") then
|
||||
tag = tag .. string.format(" (%.3f)", conf)
|
||||
end
|
||||
|
||||
GUI.tag_box[tag_index].label = tag
|
||||
GUI.tag_box[tag_index].sensitive = true
|
||||
else
|
||||
GUI.tag_box[tag_index].label = ""
|
||||
GUI.tag_box[tag_index].sensitive = false
|
||||
end
|
||||
tag_index = tag_index + 1
|
||||
end
|
||||
|
||||
PHOTILS.in_pagination = false
|
||||
end
|
||||
|
||||
function PHOTILS.attach_tags()
|
||||
local num_selected = #dt.gui.selection()
|
||||
local job = dt.gui.create_job(_("apply tag to image"), true)
|
||||
|
||||
for i = 1, num_selected, 1 do
|
||||
local image = dt.gui.selection()[i]
|
||||
for tag, _ in pairs(PHOTILS.selected_tags) do
|
||||
local dt_tag = dt.tags.create(tag)
|
||||
dt.tags.attach(dt_tag, image)
|
||||
end
|
||||
|
||||
job.percent = i / num_selected
|
||||
end
|
||||
|
||||
dt.print(_("tags successfully attached to image"))
|
||||
job.valid = false
|
||||
end
|
||||
|
||||
function PHOTILS.get_tags(image, with_export)
|
||||
|
||||
local tmp_file = df.create_tmp_file()
|
||||
local in_arg = df.sanitize_filename(tostring(image))
|
||||
local out_arg = df.sanitize_filename(tmp_file)
|
||||
local executable = photils_installed
|
||||
|
||||
if with_export then
|
||||
dt.print_log("use export to for prediction")
|
||||
local export_file = df.create_tmp_file()
|
||||
exporter:write_image(image, export_file)
|
||||
in_arg = df.sanitize_filename(tostring(export_file))
|
||||
end
|
||||
|
||||
local command = executable .. " -c " .. " -i " .. in_arg .. " -o " .. out_arg
|
||||
|
||||
local ret = dtsys.external_command(command)
|
||||
if ret > 0 then
|
||||
dt.print_error(string.format("command %s returned error code %d", command, ret))
|
||||
os.remove(tmp_file)
|
||||
|
||||
-- try to export the image and run tagging
|
||||
if not with_export then
|
||||
return PHOTILS.get_tags(image, true)
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
for i = #PHOTILS.tags, 1, -1 do
|
||||
PHOTILS.tags[i] = nil
|
||||
PHOTILS.confidences[i] = nil
|
||||
end
|
||||
|
||||
for tag in io.lines(tmp_file) do
|
||||
local splitted = du.split(tag, ":")
|
||||
if 100 * tonumber(splitted[2]) >= GUI.confidence_slider.value then
|
||||
PHOTILS.tags[#PHOTILS.tags + 1] = splitted[1]
|
||||
PHOTILS.confidences[#PHOTILS.confidences+1] = splitted[2]
|
||||
end
|
||||
end
|
||||
|
||||
dt.print(string.format(_("%s found %d tags for your image"), MODULE_NAME,
|
||||
#PHOTILS.tags))
|
||||
os.remove(tmp_file)
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
function PHOTILS.on_tags_clicked()
|
||||
PHOTILS.page = 1
|
||||
GUI.warning.label = ""
|
||||
|
||||
PHOTILS.on_reset(false)
|
||||
|
||||
local images = dt.gui.selection()
|
||||
|
||||
if #images == 0 then
|
||||
dt.print(_("no image selected."))
|
||||
dt.control.sleep(2000)
|
||||
else
|
||||
if #images > 1 then
|
||||
dt.print(_("this plugin can only handle a single image."))
|
||||
dt.gui.selection({images[1]})
|
||||
dt.control.sleep(2000)
|
||||
end
|
||||
|
||||
with_export = dt.preferences.read(MODULE_NAME, "export_image_before_for_tags", "bool")
|
||||
if not PHOTILS.get_tags(images[1], with_export) then
|
||||
local msg = string.format(_("%s failed, see terminal output for details"), MODULE_NAME)
|
||||
GUI.warning_label.label = msg
|
||||
GUI.stack.active = GUI.error_view
|
||||
dt.print(msg)
|
||||
return
|
||||
end
|
||||
|
||||
if #PHOTILS.tags == 0 then
|
||||
local msg = string.format(_("no tags were found"), MODULE_NAME)
|
||||
GUI.warning_label.label = msg
|
||||
GUI.stack.active = GUI.error_view
|
||||
return
|
||||
end
|
||||
|
||||
GUI.stack.active = GUI.tag_view
|
||||
PHOTILS.paginate()
|
||||
end
|
||||
end
|
||||
|
||||
function PHOTILS.tag_selected(tag_button)
|
||||
if PHOTILS.in_pagination then return end
|
||||
|
||||
if tag_button.value then
|
||||
local tag = tag_button.label
|
||||
if dt.preferences.read(MODULE_NAME, "show_confidence", "bool") then
|
||||
local idx = string.find(tag, "%(") - 2
|
||||
tag = string.sub(tag, 0, idx)
|
||||
end
|
||||
|
||||
PHOTILS.selected_tags[tag] = tag
|
||||
else
|
||||
PHOTILS.selected_tags[tag_button.label] = nil
|
||||
end
|
||||
|
||||
local num_selected = num_keys(PHOTILS.selected_tags)
|
||||
if num_selected == 0 then
|
||||
GUI.attach_button.label = ""
|
||||
GUI.attach_button.sensitive = false
|
||||
else
|
||||
GUI.attach_button.label = string.format(_("attach %d tags"),
|
||||
num_selected)
|
||||
GUI.attach_button.sensitive = true
|
||||
end
|
||||
end
|
||||
|
||||
function PHOTILS.on_reset(with_view)
|
||||
if with_view then GUI.stack.active = 1 end
|
||||
|
||||
for k, _ in pairs(PHOTILS.selected_tags) do
|
||||
PHOTILS.selected_tags[k] = nil
|
||||
end
|
||||
|
||||
for _, v in ipairs(GUI.tag_box) do
|
||||
v.value = false
|
||||
end
|
||||
|
||||
GUI.attach_button.label = ""
|
||||
GUI.attach_button.sensitive = false
|
||||
end
|
||||
|
||||
local function install_module()
|
||||
if not PHOTILS.module_installed then
|
||||
dt.register_lib(MODULE_NAME,
|
||||
_("photils auto-tagger"),
|
||||
true,
|
||||
true,
|
||||
PHOTILS.plugin_display_views,
|
||||
GUI.container,
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
PHOTILS.module_installed = true
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.gui.libs[MODULE_NAME].visible = false
|
||||
dt.destroy_event("photils", "mouse-over-image-changed")
|
||||
end
|
||||
|
||||
local function restart()
|
||||
dt.gui.libs[MODULE_NAME].visible = true
|
||||
dt.register_event("photils", "mouse-over-image-changed",
|
||||
PHOTILS.image_changed)
|
||||
end
|
||||
|
||||
local function show()
|
||||
dt.gui.libs[MODULE_NAME].visible = true
|
||||
end
|
||||
|
||||
|
||||
-- add a fix number of buttons
|
||||
for _ = 1, PHOTILS.per_page, 1 do
|
||||
local btn_tag = dt.new_widget("check_button") {
|
||||
label = "",
|
||||
sensitive = false,
|
||||
clicked_callback = PHOTILS.tag_selected
|
||||
}
|
||||
|
||||
table.insert(GUI.tag_box, btn_tag)
|
||||
end
|
||||
|
||||
if not photils_installed then
|
||||
GUI.warning_label.label = _("photils-cli not found")
|
||||
dt.print_log("photils-cli not found")
|
||||
else
|
||||
GUI.warning_label.label = _("select an image, click \"get tags\" and get \nsuggestions for tags.")
|
||||
end
|
||||
|
||||
GUI.pagination = dt.new_widget("box") {
|
||||
orientation = "horizontal",
|
||||
GUI.prev_button,
|
||||
GUI.page_label,
|
||||
GUI.next_button
|
||||
}
|
||||
|
||||
|
||||
table.insert(GUI.error_view, GUI.warning_label)
|
||||
if not photils_installed then
|
||||
table.insert(GUI.error_view, df.executable_path_widget({"photils-cli"}))
|
||||
table.insert(GUI.error_view, GUI.restart_required_label)
|
||||
end
|
||||
table.insert(GUI.stack, GUI.error_view)
|
||||
table.insert(GUI.stack, GUI.tag_view)
|
||||
|
||||
table.insert(GUI.tag_view, GUI.pagination)
|
||||
table.insert(GUI.tag_view, GUI.tag_box)
|
||||
table.insert(GUI.tag_view, GUI.attach_button)
|
||||
table.insert(GUI.tag_view, GUI.warning)
|
||||
|
||||
table.insert(GUI.container, GUI.confidence_slider)
|
||||
table.insert(GUI.container, GUI.stack)
|
||||
|
||||
GUI.stack.active = 1
|
||||
|
||||
|
||||
|
||||
-- uses photils: prefix because script settings are all together and not seperated by script
|
||||
dt.preferences.register(MODULE_NAME,
|
||||
"show_confidence",
|
||||
"bool",
|
||||
_("photils: show confidence value"),
|
||||
_("if enabled, the confidence value for each tag is displayed"),
|
||||
true)
|
||||
|
||||
dt.preferences.register(MODULE_NAME,
|
||||
"export_image_before_for_tags",
|
||||
"bool",
|
||||
_("photils: use exported image for tag request"),
|
||||
_("if enabled, the image passed to photils for tag suggestion is based on the exported, already edited image. " ..
|
||||
"otherwise, the embedded thumbnail of the RAW file will be used for tag suggestion." ..
|
||||
"the embedded thumbnail could speedup the tag suggestion but can fail if the RAW file is not supported."),
|
||||
true)
|
||||
|
||||
dt.register_event("photils", "mouse-over-image-changed",
|
||||
PHOTILS.image_changed)
|
||||
|
||||
if dt.gui.current_view().id == "lighttable" then
|
||||
install_module()
|
||||
else
|
||||
if not PHOTILS.event_registered then
|
||||
dt.register_event(
|
||||
"photils", "view-changed",
|
||||
function(event, old_view, new_view)
|
||||
if new_view.name == "lighttable" and old_view.name == "darkroom" then
|
||||
install_module()
|
||||
end
|
||||
end
|
||||
)
|
||||
PHOTILS.event_registered = true
|
||||
end
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
script_data.restart = restart
|
||||
script_data.destroy_method = "hide"
|
||||
script_data.show = show
|
||||
|
||||
return script_data
|
||||
318
contrib/quicktag.lua
Normal file
318
contrib/quicktag.lua
Normal file
@@ -0,0 +1,318 @@
|
||||
--[[
|
||||
This file is part of darktable,
|
||||
Copyright (C) 2017 by Christian Kanzian
|
||||
|
||||
Thanks to
|
||||
Copyright (C) 2016 Bill Ferguson
|
||||
Copyright (C) 2017 Tobias Jakobs
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
|
||||
--[[
|
||||
QUICKTAG
|
||||
For faster attaching your favorite tags, the script adds shortcuts and
|
||||
the module "quicktag" in lighttable mode with a changeable number of buttons.
|
||||
To each button a tag can be assigned. If the tags do not exist in your database,
|
||||
they are added to the database once they get the first time attached to an image.
|
||||
|
||||
The number of buttons/shortcuts can be changed in the lua preferences.
|
||||
Changes in the number require a restart of darktable.
|
||||
|
||||
INSTALATION
|
||||
* copy this file in $CONFIGDIR/lua/ where CONFIGDIR is your darktable configuration directory
|
||||
* add the following line in the file $CONFIGDIR/luarc require "quicktag"
|
||||
|
||||
USAGE
|
||||
* set the number of quicktags between 2 and 10 in the preferences dialog and restart darktable
|
||||
* if wanted set the shortcuts in the preferences dialog
|
||||
* to add or change a quicktag, first select the old tag with the combobox "old quicktag",
|
||||
enter a new tag in the "new quicktag" filed and press "set quicktag"
|
||||
* use a shortcut or button to attach the tag to selected images
|
||||
]]
|
||||
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local debug = require "darktable.debug"
|
||||
|
||||
du.check_min_api_version("7.0.0", "quicktag")
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("quick tag"),
|
||||
purpose = _("use buttons to quickly apply tags assigned to them"),
|
||||
author = "Christian Kanzian",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/quicktag"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
|
||||
local qt = {}
|
||||
qt.module_installed = false
|
||||
qt.event_registered = false
|
||||
qt.widget_table = {}
|
||||
|
||||
-- maximum length of button labels
|
||||
dt.preferences.register("quickTag",
|
||||
"labellength",
|
||||
"integer", -- type
|
||||
_("max. length of button labels"), -- label
|
||||
_("may range from 15 to 60 - needs a restart"), -- tooltip
|
||||
25, -- default
|
||||
15, -- min
|
||||
60)
|
||||
|
||||
|
||||
|
||||
local max_label_length = dt.preferences.read("quickTag", "labellength", "integer")
|
||||
|
||||
-- register number of quicktags
|
||||
dt.preferences.register("quickTag",
|
||||
"quickTagnumber",
|
||||
"integer", -- type
|
||||
_("number of quicktag fields"), -- label
|
||||
_("may range from 2 to 20 - needs a restart"), -- tooltip
|
||||
5, -- default
|
||||
2, -- min
|
||||
20)
|
||||
|
||||
local qnr = dt.preferences.read("quickTag", "quickTagnumber", "integer")
|
||||
|
||||
|
||||
|
||||
-- get quicktags from the preferences
|
||||
local quicktag_table = {}
|
||||
|
||||
|
||||
local function read_pref_tags()
|
||||
for j=1,qnr do
|
||||
quicktag_table[j] = dt.preferences.read("quickTag", "quicktag"..j, "string")
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
local quicktag_label = {}
|
||||
|
||||
|
||||
local function abbrevate_tags(t)
|
||||
for i=1,qnr do
|
||||
local taglength = string.len(t[i])
|
||||
if taglength > max_label_length then
|
||||
local max_2 = math.floor(max_label_length/2)
|
||||
quicktag_label[i] = string.sub(t[i],0,max_2).."..."..string.sub(t[i],taglength - max_2)
|
||||
else
|
||||
quicktag_label[i] = t[i]
|
||||
end
|
||||
-- print(quicktag_label[i])
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- quicktag function to attach tags
|
||||
local function tagattach(tag,qtagnr)
|
||||
if tag == "" then
|
||||
dt.print(string.format(_("quicktag %i is empty, please set a tag"), qtagnr))
|
||||
return true
|
||||
end
|
||||
|
||||
local tagnr = dt.tags.find(tag)
|
||||
|
||||
--create tag if it does not exist
|
||||
if tagnr == nil then
|
||||
dt.tags.create(tag)
|
||||
tagnr = dt.tags.find(tag)
|
||||
end
|
||||
|
||||
local sel_images = dt.gui.action_images
|
||||
|
||||
if next(sel_images) == nil then
|
||||
dt.print(_("no images selected"))
|
||||
return true
|
||||
end
|
||||
|
||||
local counter = 0
|
||||
|
||||
for _,image in ipairs(sel_images) do
|
||||
dt.tags.attach(tagnr,image)
|
||||
counter = counter+1
|
||||
end
|
||||
dt.print(string.format(_("tag \"%s\" attached to %i image(s)"), tag, counter))
|
||||
end
|
||||
|
||||
|
||||
-- create quicktag module elements
|
||||
-- read tags from preferences for initialization
|
||||
read_pref_tags()
|
||||
abbrevate_tags(quicktag_table)
|
||||
|
||||
|
||||
local button = {}
|
||||
|
||||
-- function to create buttons with tags as labels
|
||||
for j = 1, qnr do
|
||||
button[#button+1] = dt.new_widget("button") {
|
||||
label = j..": "..quicktag_table[j],
|
||||
clicked_callback = function() tagattach(tostring(quicktag_table[j]),j) end}
|
||||
end
|
||||
|
||||
|
||||
local old_quicktag = dt.new_widget("combobox"){
|
||||
label = _("old tag"),
|
||||
tooltip = _("select the quicktag to replace")
|
||||
}
|
||||
|
||||
local function update_quicktag_list()
|
||||
-- abrevate long tags for the labels
|
||||
abbrevate_tags(quicktag_table)
|
||||
for j=1,qnr do
|
||||
button[j].label = j..": "..quicktag_label[j]
|
||||
old_quicktag[j] = j..": "..quicktag_table[j]
|
||||
end
|
||||
end
|
||||
|
||||
local function install_module()
|
||||
if not qt.module_installed then
|
||||
dt.register_lib(
|
||||
"quicktag", -- Module name
|
||||
_("quick tag"), -- name
|
||||
true, -- expandable
|
||||
false, -- resetable
|
||||
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 490}},
|
||||
|
||||
dt.new_widget("box"){
|
||||
orientation = "vertical",
|
||||
table.unpack(qt.widget_table),
|
||||
|
||||
},
|
||||
nil,-- view_enter
|
||||
nil -- view_leave
|
||||
)
|
||||
qt.module_installed = true
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.gui.libs["quicktag"].visible = false
|
||||
for i=1,qnr do
|
||||
dt.destroy_event("quicktag " .. tostring(i), "shortcut")
|
||||
end
|
||||
end
|
||||
|
||||
local function restart()
|
||||
dt.gui.libs["quicktag"].visible = true
|
||||
for i = 1 ,qnr do
|
||||
dt.register_event("quicktag", "shortcut",
|
||||
function(event, shortcut) tagattach(tostring(quicktag_table[i])) end,
|
||||
string.format(_("quicktag %i"),i))
|
||||
end
|
||||
end
|
||||
|
||||
local function show()
|
||||
dt.gui.libs["quicktag"].visible = true
|
||||
end
|
||||
|
||||
|
||||
|
||||
|
||||
update_quicktag_list()
|
||||
|
||||
local new_quicktag = dt.new_widget("entry"){
|
||||
text = "",
|
||||
placeholder = _("new tag"),
|
||||
is_password = false,
|
||||
editable = true,
|
||||
tooltip = _("enter your tag here")
|
||||
}
|
||||
|
||||
local set_quicktag_button = dt.new_widget("button") {
|
||||
label = _("set tag"),
|
||||
clicked_callback = function()
|
||||
local old_tag = quicktag_table[old_quicktag.selected]
|
||||
if new_quicktag.text == "" then
|
||||
dt.print(_("new quicktag is empty!"))
|
||||
else
|
||||
quicktag_table[old_quicktag.selected] = new_quicktag.text
|
||||
dt.preferences.write("quickTag", "quicktag"..old_quicktag.selected, "string", new_quicktag.text)
|
||||
dt.print(string.format(_("quicktag \"%s\" replaced by \"%s\""), old_tag, new_quicktag.text))
|
||||
update_quicktag_list()
|
||||
new_quicktag.text = ""
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
local new_qt_widget = dt.new_widget ("box") {
|
||||
orientation = "horizontal",
|
||||
dt.new_widget("label") { label = _("new tag") },
|
||||
new_quicktag,
|
||||
set_quicktag_button
|
||||
}
|
||||
|
||||
|
||||
-- back UI elements in a table
|
||||
-- thanks to wpferguson for the hint
|
||||
|
||||
for i=1,qnr do
|
||||
qt.widget_table[#qt.widget_table + 1] = button[i]
|
||||
end
|
||||
|
||||
qt.widget_table[#qt.widget_table + 1] = dt.new_widget("separator"){}
|
||||
qt.widget_table[#qt.widget_table + 1] = old_quicktag
|
||||
qt.widget_table[#qt.widget_table + 1] = new_qt_widget
|
||||
|
||||
|
||||
--create module
|
||||
if dt.gui.current_view().id == "lighttable" then
|
||||
install_module()
|
||||
else
|
||||
if not qt.event_registered then
|
||||
dt.register_event(
|
||||
"quicktag", "view-changed",
|
||||
function(event, old_view, new_view)
|
||||
if new_view.name == "lighttable" and old_view.name == "darkroom" then
|
||||
install_module()
|
||||
end
|
||||
end
|
||||
)
|
||||
qt.event_registered = true
|
||||
end
|
||||
end
|
||||
|
||||
-- create shortcuts
|
||||
for i=1,qnr do
|
||||
dt.register_event("quicktag " .. tostring(i), "shortcut",
|
||||
function(event, shortcut) tagattach(tostring(quicktag_table[i])) end,
|
||||
string.format(_("quick tag %i"),i))
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
script_data.restart = restart
|
||||
script_data.destroy_method = "hide"
|
||||
script_data.show = show
|
||||
|
||||
return script_data
|
||||
|
||||
-- vim: shiftwidth=2 expandtab tabstop=2 cindent syntax=lua
|
||||
-- kate: tab-indents: off; indent-width 2; replace-tabs on; remove-trailing-space on;
|
||||
132
contrib/rate_group.lua
Normal file
132
contrib/rate_group.lua
Normal file
@@ -0,0 +1,132 @@
|
||||
--[[
|
||||
|
||||
Rate Group:
|
||||
|
||||
Script to provide shortcuts for rating or rejecting all images within a group;
|
||||
particularly useful for RAW+JPEG shooters employing a star rating workflow
|
||||
like the below:
|
||||
|
||||
http://blog.chasejarvis.com/blog/2011/03/photo-editing-101/
|
||||
|
||||
|
||||
Installation and usage:
|
||||
|
||||
1. Copy this file into ~/.config/darktable/lua/
|
||||
|
||||
2. Add `require "rate_group"` to new line in ~/.config/darktable/luarc
|
||||
|
||||
3. Restart darktable
|
||||
|
||||
4. Assign a keyboard shortcut to each action via settings > shortcuts > lua
|
||||
|
||||
I use the following shortcuts:
|
||||
|
||||
* Reject group: Ctrl+R
|
||||
* Rate group 1: Ctrl+1
|
||||
* Rate group 2: Ctrl+2
|
||||
* Rate group 3: Ctrl+3
|
||||
* Rate group 4: Ctrl+4
|
||||
* Rate group 5: Ctrl+5
|
||||
* Rate group 0: Ctrl+0
|
||||
|
||||
|
||||
Author: Dom H (dom@hxy.io)
|
||||
License: GPLv2
|
||||
|
||||
This script is based on Thibault Jouannic's `reject_group` script:
|
||||
http://redmine.darktable.org/issues/8968#note-20
|
||||
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
|
||||
-- added version check
|
||||
du.check_min_api_version("7.0.0", "rate_group")
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("rate group"),
|
||||
purpose = _("rate all images in a group"),
|
||||
author = "Dom H (dom@hxy.io)",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/rate_group"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
local function apply_rating(rating)
|
||||
local images = dt.gui.action_images
|
||||
for _, i in ipairs(images) do
|
||||
local members = i:get_group_members()
|
||||
for _, m in ipairs(members) do
|
||||
m.rating = rating
|
||||
end
|
||||
end
|
||||
if rating < 0 then
|
||||
dt.print(_("rejecting group(s)"))
|
||||
else
|
||||
dt.print(string.format(_("applying rating %d to group(s)"), rating))
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.destroy_event("rg_reject", "shortcut")
|
||||
dt.destroy_event("rg0", "shortcut")
|
||||
dt.destroy_event("rg1", "shortcut")
|
||||
dt.destroy_event("rg2", "shortcut")
|
||||
dt.destroy_event("rg3", "shortcut")
|
||||
dt.destroy_event("rg4", "shortcut")
|
||||
dt.destroy_event("rg5", "shortcut")
|
||||
end
|
||||
|
||||
dt.register_event("rg_reject", "shortcut",
|
||||
function(event, shortcut)
|
||||
apply_rating(-1)
|
||||
end, _("reject group"))
|
||||
|
||||
dt.register_event("rg0", "shortcut",
|
||||
function(event, shortcut)
|
||||
apply_rating(0)
|
||||
end, string.format(_("rate group %d"), 0)
|
||||
)
|
||||
|
||||
dt.register_event("rg1", "shortcut",
|
||||
function(event, shortcut)
|
||||
apply_rating(1)
|
||||
end, string.format(_("rate group %d"), 1))
|
||||
|
||||
dt.register_event("rg2", "shortcut",
|
||||
function(event, shortcut)
|
||||
apply_rating(2)
|
||||
end, string.format(_("rate group %d"), 2))
|
||||
|
||||
dt.register_event("rg3", "shortcut",
|
||||
function(event, shortcut)
|
||||
apply_rating(3)
|
||||
end, string.format(_("rate group %d"), 3))
|
||||
|
||||
dt.register_event("rg4", "shortcut",
|
||||
function(event, shortcut)
|
||||
apply_rating(4)
|
||||
end, string.format(_("rate group %d"), 4))
|
||||
|
||||
dt.register_event("rg5", "shortcut",
|
||||
function(event, shortcut)
|
||||
apply_rating(5)
|
||||
end, string.format(_("rate group %d"), 5))
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
return script_data
|
||||
188
contrib/rename-tags.lua
Normal file
188
contrib/rename-tags.lua
Normal file
@@ -0,0 +1,188 @@
|
||||
--[[
|
||||
Copyright (c) 2016 Sebastian Witt <se.witt@gmx.net>
|
||||
Copyright (c) 2018 Bill Ferguson <wpferguson@gmail.com>
|
||||
]]
|
||||
--[[
|
||||
Rename tags
|
||||
|
||||
AUTHOR
|
||||
Sebastian Witt (se.witt@gmx.net)
|
||||
|
||||
INSTALLATION
|
||||
* copy this file in $CONFIGDIR/lua/ where CONFIGDIR
|
||||
is your darktable configuration directory
|
||||
* add the following line in the file $CONFIGDIR/luarc
|
||||
require "rename-tags"
|
||||
|
||||
USAGE
|
||||
* In lighttable there is a new entry: 'rename tag'
|
||||
* Enter old tag (this one gets deleted!)
|
||||
* Enter new tag name
|
||||
|
||||
LICENSE
|
||||
GPLv2
|
||||
|
||||
Changes
|
||||
* 20180912 - Did an RTFM and read a bug (12277, thanks Christian G) that showed
|
||||
a way to get the images containing the old tag one by one instead of searching
|
||||
the entire database. Changed rename_tags() function to use this method.
|
||||
]]
|
||||
|
||||
local darktable = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local debug = require "darktable.debug"
|
||||
|
||||
-- check API version
|
||||
du.check_min_api_version("7.0.0", "rename-tags")
|
||||
du.deprecated("contrib/rename-tags.lua","darktable release 4.0")
|
||||
|
||||
local gettext = darktable.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("rename tags"),
|
||||
purpose = _("rename an existing tag"),
|
||||
author = "Sebastian Witt (se.witt@gmx.net)",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/rename-tags"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
local rt = {}
|
||||
rt.module_installed = false
|
||||
rt.event_registered = false
|
||||
|
||||
-- GUI entries
|
||||
local old_tag = darktable.new_widget("entry") { tooltip = _("enter old tag") }
|
||||
local new_tag = darktable.new_widget("entry") { tooltip = _("enter new tag") }
|
||||
|
||||
local function rename_reset()
|
||||
old_tag.text = ''
|
||||
new_tag.text = ''
|
||||
end
|
||||
|
||||
-- This function does the renaming
|
||||
local function rename_tags()
|
||||
-- If entries are empty, return
|
||||
if old_tag.text == '' then
|
||||
darktable.print (_("old tag can't be empty"))
|
||||
return
|
||||
end
|
||||
if new_tag.text == '' then
|
||||
darktable.print (_("new tag can't be empty"))
|
||||
return
|
||||
end
|
||||
|
||||
local count = 0
|
||||
|
||||
-- Check if old tag exists
|
||||
local ot = darktable.tags.find (old_tag.text)
|
||||
|
||||
if not ot then
|
||||
darktable.print (_("old tag does not exist"))
|
||||
return
|
||||
end
|
||||
|
||||
-- Show job
|
||||
local job = darktable.gui.create_job (_("renaming tag"), true)
|
||||
|
||||
old_tag.editable = false
|
||||
new_tag.editable = false
|
||||
|
||||
-- Create if it does not exists
|
||||
local nt = darktable.tags.create (new_tag.text)
|
||||
|
||||
-- Get number of images for old tag
|
||||
local dbcount = #ot
|
||||
|
||||
-- loop through the images containing the old tag, and attach the new tag
|
||||
for i=1, #ot do
|
||||
-- Update progress bar
|
||||
job.percent = i / dbcount
|
||||
ot[i]:attach_tag(nt)
|
||||
count = count + 1
|
||||
end
|
||||
|
||||
-- Delete old tag, this removes it from all images
|
||||
darktable.tags.delete (ot)
|
||||
|
||||
job.valid = false
|
||||
darktable.print (string.format(_("renamed tags for %d images"), count))
|
||||
old_tag.editable = true
|
||||
new_tag.editable = true
|
||||
|
||||
-- reset the gui fields
|
||||
|
||||
rename_reset()
|
||||
end
|
||||
|
||||
local function install_module()
|
||||
if not rt.module_installed then
|
||||
darktable.register_lib ("rename_tags", _("rename tag"), true, true, {[darktable.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 20},}, rt.rename_widget, nil, nil)
|
||||
rt.module_installed = true
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
darktable.gui.libs["rename_tags"].visible = false
|
||||
end
|
||||
|
||||
local function restart()
|
||||
darktable.gui.libs["rename_tags"].visible = true
|
||||
end
|
||||
|
||||
-- GUI
|
||||
local old_widget = darktable.new_widget ("box") {
|
||||
orientation = "horizontal",
|
||||
darktable.new_widget("label") { label = _("old tag") },
|
||||
old_tag
|
||||
}
|
||||
|
||||
local new_widget = darktable.new_widget ("box") {
|
||||
orientation = "horizontal",
|
||||
darktable.new_widget("label") { label = _("new tag") },
|
||||
new_tag
|
||||
}
|
||||
|
||||
rt.rename_widget = darktable.new_widget ("box") {
|
||||
orientation = "vertical",
|
||||
reset_callback = rename_reset,
|
||||
old_widget,
|
||||
new_widget,
|
||||
darktable.new_widget("button") { label = _("go"), clicked_callback = rename_tags }
|
||||
}
|
||||
|
||||
if darktable.gui.current_view().id == "lighttable" then
|
||||
install_module()
|
||||
else
|
||||
if not rt.event_registered then
|
||||
darktable.register_event(
|
||||
"rename_tags", "view-changed",
|
||||
function(event, old_view, new_view)
|
||||
if new_view.name == "lighttable" and old_view.name == "darkroom" then
|
||||
install_module()
|
||||
end
|
||||
end
|
||||
)
|
||||
rt.event_registered = true
|
||||
end
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
script_data.restart = restart
|
||||
script_data.destroy_method = "hide"
|
||||
script_data.show = restart
|
||||
|
||||
return script_data
|
||||
|
||||
|
||||
236
contrib/rename_images.lua
Normal file
236
contrib/rename_images.lua
Normal file
@@ -0,0 +1,236 @@
|
||||
--[[
|
||||
|
||||
rename.lua - rename image file(s)
|
||||
|
||||
Copyright (C) 2020, 2021 Bill Ferguson <wpferguson@gmail.com>.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
rename - rename an image file or files
|
||||
|
||||
This shortcut resets the GPS information to that contained within
|
||||
the image file. If no GPS info is in the image file, the GPS data
|
||||
is cleared.
|
||||
|
||||
USAGE
|
||||
* require this script from your luarc file or start it from script_manager
|
||||
* select an image or images
|
||||
* enter a renaming pattern
|
||||
* click the button to rename the files
|
||||
|
||||
BUGS, COMMENTS, SUGGESTIONS
|
||||
* Send to Bill Ferguson, wpferguson@gmail.com
|
||||
|
||||
CHANGES
|
||||
|
||||
TODO
|
||||
* Add pattern builder
|
||||
* Add new name preview
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
local ds = require "lib/dtutils.string"
|
||||
|
||||
du.check_min_api_version("7.0.0", "rename_images")
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- namespace variable
|
||||
local rename = {
|
||||
presets = {},
|
||||
widgets = {},
|
||||
}
|
||||
rename.module_installed = false
|
||||
rename.event_registered = false
|
||||
|
||||
-- script_manager integration
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("rename images"),
|
||||
purpose = _("rename an image file or files"),
|
||||
author = "Bill Ferguson <wpferguson@gmail.com>",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/rename_images"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- C O N S T A N T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local MODULE_NAME = "rename_images"
|
||||
local PS = dt.configuration.running_os == "windows" and "\\" or "/"
|
||||
local USER = os.getenv("USERNAME")
|
||||
local HOME = os.getenv(dt.configuration.running_os == "windows" and "HOMEPATH" or "HOME")
|
||||
local PICTURES = HOME .. PS .. dt.configuration.running_os == "windows" and _("My Pictures") or _("Pictures")
|
||||
local DESKTOP = HOME .. PS .. "Desktop"
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- F U N C T I O N S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function stop_job(job)
|
||||
job.valid = false
|
||||
end
|
||||
|
||||
local function install_module()
|
||||
if not rename.module_installed then
|
||||
dt.register_lib(
|
||||
MODULE_NAME,
|
||||
_("rename images"),
|
||||
true,
|
||||
true,
|
||||
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER",700}},
|
||||
dt.new_widget("box"){
|
||||
orientation = "vertical",
|
||||
rename.widgets.pattern,
|
||||
rename.widgets.button,
|
||||
},
|
||||
nil,
|
||||
nil
|
||||
)
|
||||
rename.module_installed = true
|
||||
end
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.gui.libs[MODULE_NAME].visible = false
|
||||
end
|
||||
|
||||
local function restart()
|
||||
dt.gui.libs[MODULE_NAME].visible = true
|
||||
end
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- M A I N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function do_rename(images)
|
||||
if #images > 0 then
|
||||
local first_image = images[1]
|
||||
local pattern = rename.widgets.pattern.text
|
||||
dt.preferences.write(MODULE_NAME, "pattern", "string", pattern)
|
||||
dt.print_log("pattern is " .. pattern)
|
||||
if string.len(pattern) > 0 then
|
||||
local datetime = os.date("*t")
|
||||
|
||||
local job = dt.gui.create_job(_("renaming images"), true, stop_job)
|
||||
for i, image in ipairs(images) do
|
||||
if job.valid then
|
||||
job.percent = i / #images
|
||||
ds.build_substitute_list(image, i, pattern, USER, PICTURES, HOME, DESKTOP)
|
||||
local new_name = ds.substitute_list(pattern)
|
||||
if new_name == -1 then
|
||||
dt.print(_("unable to do variable substitution, exiting..."))
|
||||
stop_job(job)
|
||||
return
|
||||
end
|
||||
ds.clear_substitute_list()
|
||||
local args = {}
|
||||
local path = string.sub(df.get_path(new_name), 1, -2)
|
||||
if string.len(path) == 0 then
|
||||
path = image.path
|
||||
end
|
||||
local filename = df.get_filename(new_name)
|
||||
local filmname = image.path
|
||||
if path ~= image.path then
|
||||
if not df.check_if_file_exists(df.sanitize_filename(path)) then
|
||||
df.mkdir(df.sanitize_filename(path))
|
||||
end
|
||||
filmname = path
|
||||
end
|
||||
args[1] = dt.films.new(filmname)
|
||||
args[2] = image
|
||||
if filename ~= image.filename then
|
||||
args[3] = filename
|
||||
end
|
||||
dt.database.move_image(table.unpack(args))
|
||||
end
|
||||
end
|
||||
stop_job(job)
|
||||
local collect_rules = dt.gui.libs.collect.filter()
|
||||
dt.gui.libs.collect.filter(collect_rules)
|
||||
dt.gui.views.lighttable.set_image_visible(first_image)
|
||||
dt.print(string.format(_("renamed %d images"), #images))
|
||||
else -- pattern length
|
||||
dt.print_error("no pattern supplied, returning...")
|
||||
dt.print(_("please enter the new name or pattern"))
|
||||
end
|
||||
else -- image count
|
||||
dt.print_error("no images selected, returning...")
|
||||
dt.print(_("please select some images and try again"))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- W I D G E T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
rename.widgets.pattern = dt.new_widget("entry"){
|
||||
tooltip = ds.get_substitution_tooltip(),
|
||||
placeholder = _("enter pattern") .. "$(FILE_FOLDER)/$(FILE_NAME)",
|
||||
text = "",
|
||||
reset_callback = function(self)
|
||||
self.text = ""
|
||||
dt.preferences.write(MODULE_NAME, "pattern", "string", self.text)
|
||||
end
|
||||
}
|
||||
|
||||
local pattern_pref = dt.preferences.read(MODULE_NAME, "pattern", "string")
|
||||
if pattern_pref then
|
||||
rename.widgets.pattern.text = pattern_pref
|
||||
end
|
||||
|
||||
rename.widgets.button = dt.new_widget("button"){
|
||||
label = _("rename"),
|
||||
clicked_callback = function(this)
|
||||
do_rename(dt.gui.action_images)
|
||||
end
|
||||
}
|
||||
|
||||
if dt.gui.current_view().id == "lighttable" then
|
||||
install_module()
|
||||
else
|
||||
if not rename.event_registered then
|
||||
dt.register_event(
|
||||
"rename_images", "view-changed",
|
||||
function(event, old_view, new_view)
|
||||
if new_view.name == "lighttable" and old_view.name == "darkroom" then
|
||||
install_module()
|
||||
end
|
||||
end
|
||||
)
|
||||
rename.event_registered = true
|
||||
end
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
script_data.restart = restart
|
||||
script_data.destroy_method = "hide"
|
||||
script_data.show = restart
|
||||
|
||||
return script_data
|
||||
124
contrib/select_non_existing.lua
Normal file
124
contrib/select_non_existing.lua
Normal file
@@ -0,0 +1,124 @@
|
||||
--[[
|
||||
This file is part of darktable,
|
||||
copyright (c) 2023 Dirk Dittmar
|
||||
|
||||
darktable is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
darktable is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with darktable. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
Enable selection of non-existing images in the the currently worked on images, e.g. the ones selected by the collection module.
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
local ds = require "lib/dtutils.string"
|
||||
local log = require "lib/dtutils.log"
|
||||
|
||||
-- module name
|
||||
local MODULE <const> = "select_non_existing"
|
||||
|
||||
du.check_min_api_version("9.1.0", MODULE)
|
||||
|
||||
-- figure out the path separator
|
||||
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
|
||||
local DEFAULT_LOG_LEVEL <const> = log.info
|
||||
|
||||
log.log_level(DEFAULT_LOG_LEVEL)
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
local function stop_job(job)
|
||||
job.valid = false
|
||||
end
|
||||
|
||||
local function get_file_list(dir)
|
||||
log.log_level(DEFAULT_LOG_LEVEL)
|
||||
local file_list = {}
|
||||
local cmd = nil
|
||||
log.msg(log.debug, "called get_file_list with " .. dir)
|
||||
|
||||
if dt.configuration.running_os == "windows" then
|
||||
cmd = "forfiles /P " .. ds.sanitize(dir) .. " /M * /C \"cmd /c echo @file\""
|
||||
else
|
||||
cmd = "cd " .. ds.sanitize(dir) ..";ls -1"
|
||||
end
|
||||
log.msg(log.info, "cmd is " .. cmd)
|
||||
|
||||
local p = io.popen(cmd)
|
||||
if p then
|
||||
for line in p:lines() do
|
||||
if line:len() > 4 then
|
||||
if not string.match(line, "xmp$") and not string.match(line, "XMP$") then
|
||||
line = line:gsub("\"", "")
|
||||
file_list[line] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
p:close()
|
||||
end
|
||||
|
||||
return file_list
|
||||
end
|
||||
|
||||
local function select_nonexisting_images(event, images)
|
||||
log.log_level(DEFAULT_LOG_LEVEL)
|
||||
local selection = {}
|
||||
local known_files = {}
|
||||
|
||||
local job = dt.gui.create_job(_("select non existing images"), true, stop_job)
|
||||
for key,image in ipairs(images) do
|
||||
if(job.valid) then
|
||||
if not known_files[image.path] then
|
||||
known_files[image.path] = get_file_list(image.path)
|
||||
end
|
||||
if key % 10 == 0 then
|
||||
job.percent = key/#images
|
||||
end
|
||||
local file_exists = known_files[image.path][image.filename]
|
||||
log.msg(log.debug, image.path .. PS .. image.filename .." exists? => "..tostring(file_exists))
|
||||
if (not file_exists) then
|
||||
table.insert(selection, image)
|
||||
end
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
stop_job(job)
|
||||
|
||||
return selection
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.gui.libs.select.destroy_selection(MODULE)
|
||||
end
|
||||
|
||||
dt.gui.libs.select.register_selection(
|
||||
MODULE,
|
||||
_("select non existing"),
|
||||
select_nonexisting_images,
|
||||
_("select all non-existing images in the current images"))
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("select non existing"),
|
||||
purpose = _("enable selection of non-existing images"),
|
||||
author = "Dirk Dittmar",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/select_non_existing"
|
||||
}
|
||||
|
||||
script_data.destroy = destroy
|
||||
return script_data
|
||||
90
contrib/select_untagged.lua
Normal file
90
contrib/select_untagged.lua
Normal file
@@ -0,0 +1,90 @@
|
||||
--[[
|
||||
This file is part of darktable,
|
||||
copyright (c) 2017 Jannis_V
|
||||
|
||||
darktable is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
darktable is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with darktable. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
|
||||
--[[
|
||||
Enable selection of untagged images (darktable|* tags are ignored)
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
du.check_min_api_version("7.0.0", "select_untagged")
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("select untagged"),
|
||||
purpose = _("enable selection of untagged images"),
|
||||
author = "Jannis_V",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/select_untagged"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
local function stop_job(job)
|
||||
job.valid = false
|
||||
end
|
||||
|
||||
local function select_untagged_images(event, images)
|
||||
job = dt.gui.create_job(_("select untagged images"), true, stop_job)
|
||||
|
||||
local selection = {}
|
||||
|
||||
for key,image in ipairs(images) do
|
||||
if(job.valid) then
|
||||
job.percent = (key - 1)/#images
|
||||
local tags = dt.tags.get_tags(image)
|
||||
local hasTags = false
|
||||
for _,tag in ipairs(tags) do
|
||||
if not string.match(tag.name, "darktable|") then
|
||||
hasTags = true
|
||||
end
|
||||
end
|
||||
if hasTags == false then
|
||||
table.insert(selection, image)
|
||||
end
|
||||
else
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
job.valid = false
|
||||
-- return table of images to set the selection to
|
||||
return selection
|
||||
end
|
||||
|
||||
local function destroy()
|
||||
dt.gui.libs.select.destroy_selection("select_untagged")
|
||||
end
|
||||
|
||||
dt.gui.libs.select.register_selection(
|
||||
"select_untagged", _("select untagged"),
|
||||
select_untagged_images,
|
||||
_("select all images containing no tags or only tags added by darktable"))
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
return script_data
|
||||
199
contrib/selected_image_visible.lua
Normal file
199
contrib/selected_image_visible.lua
Normal file
@@ -0,0 +1,199 @@
|
||||
--[[
|
||||
|
||||
selected_image_visible.lua - make the selected image visible in lighttable
|
||||
|
||||
Copyright (C) 2026 Bill Ferguson <wpferguson@gmail.com>.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
selected_image_visible - make the selected image visible in lighttable
|
||||
|
||||
selected_image_visible adds a bshortcut to make the currently
|
||||
selected image or the first image of a selection visible in lighttable
|
||||
|
||||
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
None
|
||||
|
||||
USAGE
|
||||
* enable the script in script_manager
|
||||
|
||||
BUGS, COMMENTS, SUGGESTIONS
|
||||
Bill Ferguson <wpferguson@gmail.com>
|
||||
|
||||
CHANGES
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
-- local df = require "lib/dtutils.file"
|
||||
-- local ds = require "lib/dtutils.string"
|
||||
-- local dtsys = require "lib/dtutils.system"
|
||||
local log = require "lib/dtutils.log"
|
||||
-- local debug = require "darktable.debug"
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- A P I C H E C K
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
du.check_min_api_version("9.6.0", MODULE) -- choose the minimum version that contains the features you need
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- I 1 8 N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- S C R I P T M A N A G E R I N T E G R A T I O N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("selected image visible"), -- visible name of script
|
||||
purpose = _("make the selected image visible in lighttable"), -- purpose of script
|
||||
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/selected_image_visible/" -- URL to help/documentation
|
||||
}
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- C O N S T A N T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local MODULE <const> = "selected_image_visible"
|
||||
local DEFAULT_LOG_LEVEL <const> = log.info
|
||||
local TMP_DIR <const> = dt.configuration.tmp_dir
|
||||
|
||||
-- path separator
|
||||
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
|
||||
|
||||
-- command separator
|
||||
local CS <const> = dt.configuration.running_os == "windows" and "&" or ";"
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- L O G L E V E L
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
log.log_level(DEFAULT_LOG_LEVEL)
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- N A M E S P A C E
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local selected_image_visible = {}
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- G L O B A L V A R I A B L E S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
selected_image_visible.log_level = DEFAULT_LOG_LEVEL
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- P R E F E R E N C E S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- A L I A S E S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local namespace = selected_image_visible
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- F U N C T I O N S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function set_image_visible()
|
||||
if dt.gui.current_view().name == "lighttable" and #dt.gui.action_images > 0 then
|
||||
dt.gui.views.lighttable.set_image_visible(dt.gui.action_images[1])
|
||||
end
|
||||
end
|
||||
|
||||
local function set_button_sensitive()
|
||||
if dt.gui.current_view().name == "lighttable"
|
||||
and #dt.gui.selection() > 0
|
||||
and not dt.gui.views.lighttable.is_image_visible(dt.gui.selection()[1]) then
|
||||
dt.gui.libs.image.set_sensitive(MODULE, true)
|
||||
else
|
||||
dt.gui.libs.image.set_sensitive(MODULE, false)
|
||||
end
|
||||
end
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- M A I N P R O G R A M
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- U S E R I N T E R F A C E
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
dt.gui.libs.image.register_action(
|
||||
MODULE, _("selected image visible"),
|
||||
function(event, images) set_image_visible() end,
|
||||
_("make selected image visible in lighttable")
|
||||
)
|
||||
|
||||
set_button_sensitive()
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- D A R K T A B L E I N T E G R A T I O N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function destroy()
|
||||
-- put things to destroy (events, storages, etc) here
|
||||
dt.destroy_event(MODULE, "selection-changed")
|
||||
dt.destroy_event(MODULE, "mouse-over-image-changed")
|
||||
dt.gui.libs.image.destroy_action(MODULE)
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- E V E N T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
if not dt.query_event(MODULE, "shortcut") then
|
||||
dt.register_event(MODULE, "shortcut",
|
||||
function(event, shortcut)
|
||||
set_image_visible()
|
||||
end, _("make selected image visible in lighttable")
|
||||
)
|
||||
end
|
||||
|
||||
dt.register_event(MODULE, "selection-changed",
|
||||
function(event)
|
||||
set_button_sensitive()
|
||||
end
|
||||
)
|
||||
|
||||
dt.register_event(MODULE, "mouse-over-image-changed",
|
||||
function(event, image)
|
||||
set_button_sensitive()
|
||||
end
|
||||
)
|
||||
|
||||
return script_data
|
||||
@@ -25,28 +25,40 @@ USAGE
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
dt.configuration.check_version(...,{2,0,2})
|
||||
local du = require "lib/dtutils"
|
||||
local df = require "lib/dtutils.file"
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
local function checkIfBinExists(bin)
|
||||
local handle = io.popen("which "..bin)
|
||||
local result = handle:read()
|
||||
local ret
|
||||
handle:close()
|
||||
if (not result) then
|
||||
dt.print_error(bin.." not found")
|
||||
ret = false
|
||||
end
|
||||
ret = true
|
||||
return ret
|
||||
du.check_min_api_version("7.0.0", "slideshowMusic")
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(msgid)
|
||||
end
|
||||
|
||||
-- return data structure for script_manager
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.metadata = {
|
||||
name = _("slideshow music"),
|
||||
purpose = _("play music during a slideshow"),
|
||||
author = "Tobias Jakobs",
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/slideshowMusic"
|
||||
}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet, otherwise leave as nil
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
local function playSlideshowMusic(_, old_view, new_view)
|
||||
local filename, playMusic
|
||||
|
||||
filename = dt.preferences.read("slideshowMusic","SlideshowMusic","string")
|
||||
playMusic = dt.preferences.read("slideshowMusic","PlaySlideshowMusic","bool")
|
||||
|
||||
if not checkIfBinExists("rhythmbox-client") then
|
||||
if not df.check_if_bin_exists("rhythmbox-client") then
|
||||
dt.print_error("rhythmbox-client not found")
|
||||
return
|
||||
end
|
||||
|
||||
@@ -57,24 +69,35 @@ local function playSlideshowMusic(_, old_view, new_view)
|
||||
playCommand = 'rhythmbox-client --play-uri="'..filename..'"'
|
||||
|
||||
--dt.print_error(playCommand)
|
||||
coroutine.yield("RUN_COMMAND", playCommand)
|
||||
dt.control.execute( playCommand)
|
||||
else
|
||||
if (old_view.id == "slideshow") then
|
||||
if (old_view and old_view.id == "slideshow") then
|
||||
stopCommand = "rhythmbox-client --pause"
|
||||
--dt.print_error(stopCommand)
|
||||
coroutine.yield("RUN_COMMAND", stopCommand)
|
||||
dt.control.execute(stopCommand)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function destroy()
|
||||
dt.destroy_event("slideshow_music", "view-changed")
|
||||
dt.preferences.destroy("slideshowMusic", "SlideshowMusic")
|
||||
dt.preferences.destroy("slideshowMusic", "PlaySlideshowMusic")
|
||||
end
|
||||
|
||||
-- Preferences
|
||||
dt.preferences.register("slideshowMusic", "SlideshowMusic", "file", "Slideshow background music file", "", "")
|
||||
dt.preferences.register("slideshowMusic", "SlideshowMusic", "file", _("slideshow background music file"), "", "")
|
||||
dt.preferences.register("slideshowMusic",
|
||||
"PlaySlideshowMusic",
|
||||
"bool",
|
||||
"Play slideshow background music",
|
||||
"Plays music with rhythmbox if a slideshow starts",
|
||||
_("play slideshow background music"),
|
||||
_("plays music with rhythmbox if a slideshow starts"),
|
||||
true)
|
||||
-- Register
|
||||
dt.register_event("view-changed",playSlideshowMusic)
|
||||
dt.register_event("slideshow_music", "view-changed",
|
||||
playSlideshowMusic)
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
return script_data
|
||||
|
||||
313
contrib/toggle_group_view.lua
Normal file
313
contrib/toggle_group_view.lua
Normal file
@@ -0,0 +1,313 @@
|
||||
--[[
|
||||
|
||||
toggle_group_view.lua - toggle only group visible in lighttable
|
||||
|
||||
Copyright (C) 2024 Bill Ferguson <wpferguson.com>.
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
]]
|
||||
--[[
|
||||
toggle_group_view - toggle only group visible in lighttable
|
||||
|
||||
toggle_group_view provides a way to "separate" a group from
|
||||
the lighttable display and limit the display to just the group.
|
||||
Pressing the shortcut again returns to the full lighttable view.
|
||||
|
||||
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
||||
None
|
||||
|
||||
USAGE
|
||||
* place the script in your lua scripts folder
|
||||
* start the script with script manager
|
||||
* assign a key sequence to the shortcut
|
||||
|
||||
BUGS, COMMENTS, SUGGESTIONS
|
||||
Bill Ferguson <wpferguson.com>
|
||||
|
||||
CHANGES
|
||||
]]
|
||||
|
||||
local dt = require "darktable"
|
||||
local du = require "lib/dtutils"
|
||||
local log = require "lib/dtutils.log"
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- C O N S T A N T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local MODULE <const> = "toggle_group_view"
|
||||
local DEFAULT_LOG_LEVEL <const> = log.error
|
||||
local TMP_DIR <const> = dt.configuration.tmp_dir
|
||||
|
||||
-- path separator
|
||||
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
|
||||
|
||||
-- command separator
|
||||
local CS <const> = dt.configuration.running_os == "windows" and "&" or ";"
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- A P I C H E C K
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
du.check_min_api_version("7.0.0", MODULE) -- choose the minimum version that contains the features you need
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- I 1 8 N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local gettext = dt.gettext.gettext
|
||||
|
||||
dt.gettext.bindtextdomain(MODULE , dt.configuration.config_dir .. "/lua/locale/")
|
||||
|
||||
local function _(msgid)
|
||||
return gettext(MODULE, msgid)
|
||||
end
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- S C R I P T M A N A G E R I N T E G R A T I O N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local script_data = {}
|
||||
|
||||
script_data.destroy = nil -- function to destory the script
|
||||
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet
|
||||
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again
|
||||
script_data.show = nil -- only required for libs since the destroy_method only hides them
|
||||
|
||||
script_data.metadata = {
|
||||
name = "toggle_group_view", -- name of script
|
||||
purpose = _("toggle only group visible in lighttable"), -- purpose of script
|
||||
author = "Bill Ferguson <wpferguson.com>", -- your name and optionally e-mail address
|
||||
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/toggle_group_view/" -- URL to help/documentation
|
||||
}
|
||||
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- L O G L E V E L
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
log.log_level(DEFAULT_LOG_LEVEL)
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- N A M E S P A C E
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local toggle_group_view = {}
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- G L O B A L V A R I A B L E S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local group_active = false
|
||||
local filmroll = ""
|
||||
local collection_rules = {}
|
||||
local hover_active = true
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- F U N C T I O N S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function is_group_member(image)
|
||||
if image then
|
||||
if #image:get_group_members() > 1 then
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function is_group_displayed()
|
||||
local rules = dt.gui.libs.collect.filter()
|
||||
if rules[1].item == "DT_COLLECTION_PROP_GROUP_ID" then
|
||||
local rule = dt.gui.libs.collect.new_rule()
|
||||
|
||||
rule.mode = "DT_LIB_COLLECT_MODE_AND"
|
||||
rule.data = dt.collection[1].path
|
||||
rule.item = "DT_COLLECTION_PROP_FILMROLL"
|
||||
|
||||
table.insert(collection_rules, rule)
|
||||
group_active = true
|
||||
end
|
||||
end
|
||||
|
||||
local function set_button_sensitive(image)
|
||||
local selected_images = dt.gui.selection()
|
||||
local sensitive
|
||||
|
||||
|
||||
if group_active then
|
||||
dt.gui.libs.image.set_sensitive(MODULE, true)
|
||||
return
|
||||
end
|
||||
|
||||
if #selected_images > 0 then
|
||||
if is_group_member(selected_images[1]) then
|
||||
sensitive = true
|
||||
end
|
||||
else
|
||||
if image then
|
||||
if is_group_member(image) then
|
||||
sensitive = true
|
||||
else
|
||||
sensitive = false
|
||||
end
|
||||
else
|
||||
sensitive = false
|
||||
end
|
||||
end
|
||||
|
||||
dt.gui.libs.image.set_sensitive(MODULE, sensitive)
|
||||
end
|
||||
|
||||
local function add_collection_rule(rule_string)
|
||||
|
||||
local rule = dt.gui.libs.collect.new_rule()
|
||||
|
||||
rule.mode = "DT_LIB_COLLECT_MODE_AND"
|
||||
rule.data = rule_string
|
||||
rule.item = "DT_COLLECTION_PROP_FILENAME"
|
||||
|
||||
table.insert(collection_rules, rule)
|
||||
end
|
||||
|
||||
local function show_group(image)
|
||||
|
||||
local collection_rule_string = ""
|
||||
|
||||
local first = true
|
||||
|
||||
collection_rules = {}
|
||||
|
||||
local group_id = image.group_leader
|
||||
|
||||
local rule = dt.gui.libs.collect.new_rule()
|
||||
|
||||
rule.mode = "DT_LIB_COLLECT_MODE_AND"
|
||||
rule.data = tostring(group_id.id)
|
||||
rule.item = "DT_COLLECTION_PROP_GROUP_ID"
|
||||
|
||||
table.insert(collection_rules, rule)
|
||||
|
||||
-- dt.gui.libs.collect.filter returns the previous rule set when you install another
|
||||
collection_rules = dt.gui.libs.collect.filter(collection_rules)
|
||||
end
|
||||
|
||||
local function reset(image)
|
||||
dt.gui.libs.collect.filter(collection_rules)
|
||||
if image then
|
||||
dt.gui.views.lighttable.set_image_visible(image)
|
||||
else
|
||||
log.msg(log.screen, _("no image supplied"))
|
||||
end
|
||||
set_button_sensitive(image)
|
||||
end
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- M A I N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
-- check if we are starting with a group already displayed and
|
||||
-- configure appropriately
|
||||
|
||||
if not dt.preferences.read("darktable", "plugins/lighttable/act_on", "bool") then
|
||||
hover_active = false
|
||||
end
|
||||
|
||||
is_group_displayed()
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- U S E R I N T E R F A C E
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
dt.gui.libs.image.register_action(
|
||||
MODULE, "toggle group view",
|
||||
function(event, images)
|
||||
if group_active then
|
||||
local image
|
||||
if dt.gui.action_images[1] then
|
||||
image = dt.gui.action_images[1]
|
||||
reset(dt.gui.action_images[1])
|
||||
else
|
||||
image = dt.collection[1]
|
||||
reset(dt.collection[1])
|
||||
end
|
||||
group_active = false
|
||||
set_button_sensitive(image)
|
||||
else
|
||||
show_group(dt.gui.action_images[1])
|
||||
group_active = true
|
||||
end
|
||||
end,
|
||||
_("toggle group view for selected image")
|
||||
)
|
||||
|
||||
set_button_sensitive(nil)
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- D A R K T A B L E I N T E G R A T I O N
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
local function destroy()
|
||||
if hover_active then
|
||||
dt.destroy_event(MODULE, "mouse-over-image-changed")
|
||||
end
|
||||
dt.destroy_event(MODULE, "selection_changed")
|
||||
dt.gui.libs.image.destroy_action(MODULE, "toggle group view")
|
||||
-- leave the shortcut so it doesn't need to be reassigned
|
||||
-- the next time this module starts
|
||||
end
|
||||
|
||||
script_data.destroy = destroy
|
||||
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
-- E V E N T S
|
||||
-- - - - - - - - - - - - - - - - - - - - - - - -
|
||||
|
||||
dt.register_event(
|
||||
MODULE, "shortcut",
|
||||
function(event, shortcut)
|
||||
if #dt.gui.action_images > 0 then
|
||||
if group_active then
|
||||
reset(dt.gui.action_images[1])
|
||||
group_active = false
|
||||
else
|
||||
show_group(dt.gui.action_images[1])
|
||||
group_active = true
|
||||
end
|
||||
end
|
||||
end,
|
||||
_("toggle group view for selected image")
|
||||
)
|
||||
|
||||
---[[
|
||||
if hover_active then
|
||||
dt.register_event(MODULE, "mouse-over-image-changed",
|
||||
function(event, image)
|
||||
set_button_sensitive(image)
|
||||
end
|
||||
)
|
||||
end
|
||||
--]]
|
||||
|
||||
dt.register_event(MODULE, "selection-changed",
|
||||
function(event)
|
||||
set_button_sensitive(dt.gui.action_images[1])
|
||||
end
|
||||
)
|
||||
|
||||
return script_data
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user