139 Commits

Author SHA1 Message Date
wpferguson
d109612045 Merge pull request #683 from andriiryzhkov/fix_lua_win
Fix stray "File Not Found" stderr from dir on Windows startup
2026-06-05 10:59:10 -04:00
Andrii Ryzhkov
0f47f46b35 Fix stray "File Not Found" stderr from dir on Windows startup 2026-06-05 16:10:49 +02:00
wpferguson
1a44df7d3c Merge pull request #677 from andriiryzhkov/fix_raw_denoise
Fix wrong colors in ai_raw_denoise.lua
2026-06-01 12:42:09 -04:00
wpferguson
aa62473cbf Merge pull request #681 from darktable-org/bug_fix_and_internal_metadata
Bug fix and internal metadata
2026-06-01 12:38:10 -04:00
Bill Ferguson
f7faad0962 official/extract_burst_roll_images - added check for internal metadata
so that can be used instead of spawning exiv2 or exiftool to detect
RawBurstRollImages.

Added select button to select burst roll images.

Added action button to extract burst roll images from selected burst
roll image container
2026-05-30 15:06:37 -04:00
Bill Ferguson
53549f16b5 official/auto_straighten - Added check for Nikon cameras where
rotation is opposite of Canon and thus requires the sign to be
changed.
2026-05-30 15:04:47 -04:00
Bill Ferguson
7531a29235 contrib/image_time - Use internal metadata if available instead of
resorting to calling exiv2 or exiftool
2026-05-30 15:03:40 -04:00
Andrii Ryzhkov
a4dce23ef8 Fix wrong colors on non-RGGB sensors via RGGB origin shift and daylight WB round-trip 2026-05-16 17:04:28 +02:00
wpferguson
8abc350d68 Merge pull request #676 from andriiryzhkov/ai_scripts
Add ai_denoise and ai_raw_denoise example scripts for the darktable.ai Lua API
2026-05-15 14:58:08 -04:00
Andrii Ryzhkov
078011d6b4 Add ai_denoise and ai_raw_denoise example scripts for the darktable.ai Lua API 2026-05-14 16:32:34 +02:00
wpferguson
2a16599f91 Merge pull request #675 from wpferguson/script_manager_match_fix
script_manager - fix system scripts detection
2026-05-11 12:32:38 -04:00
Bill Ferguson
14646cf8e6 tools/script_manager - sanitized system directory string for lua pattern
matching so system lua scripts detection works when lua special characters
are in the path (such as -)
2026-05-11 12:24:30 -04:00
wpferguson
5823d015ac Merge pull request #674 from wpferguson/fix_translations_and_bug
Fix translations and bug
2026-04-29 14:47:12 -04:00
Bill Ferguson
30e6b9459a official scripts - clean up translations 2026-04-28 22:36:45 -04:00
Bill Ferguson
d3e15065d2 official/extract_burst_roll_images - fix string length 2026-04-28 22:35:54 -04:00
wpferguson
fe722f8409 Merge pull request #670 from wpferguson/fix_pdf_PDF
fix official/selection_to_pdf translatable strings
2026-04-20 19:32:26 -04:00
Bill Ferguson
b91baebdeb official/selection_to_pdf - change pdf to PDF in translatable strings 2026-04-20 19:27:56 -04:00
wpferguson
5ebadd83ea Merge pull request #669 from wpferguson/sm_logging
tools/script_manager - changed log msg from debug to info
2026-04-19 16:00:05 -04:00
Bill Ferguson
01c3b7d2f6 tools/script_manager - changed log msg from debug to info 2026-04-19 15:58:35 -04:00
wpferguson
bfee6b12dc Merge pull request #668 from wpferguson/script_manager_fix
tools/script_manager - fixed gui safety test for gui installation
2026-04-19 14:56:04 -04:00
Bill Ferguson
503a59d590 tools/script_manager - fixed gui safety test for gui installation 2026-04-19 14:54:25 -04:00
wpferguson
78176e32e8 Merge pull request #631 from wpferguson/bundled_script_manager
Updated script_manager for bundled scripts

Part of https://github.com/darktable-org/darktable/pull/19899
2026-04-19 14:38:24 -04:00
wpferguson
4d8d076d8e Merge pull request #667 from bkhl/master
Missing argument in group_persistence.lua
2026-04-18 15:29:10 -04:00
Björn Lindström
72f31d6208 Missing argument in group_persistence.lua
`add_to_group` was still failing as the leader was not provided
as second argument.
2026-04-18 20:59:58 +02:00
wpferguson
784a6ea85d Merge pull request #666 from bkhl/group_persistence-function-order
Order functions in dependency order in group_persistance.lua
2026-04-18 14:49:59 -04:00
Björn Lindström
bbfd9d12e3 Order functions in dependency order in group_persistance.lua
The call to `add_to_group` in `build_group` failed as `add_to_group`
was a local function defined later. This reorders the functions to avoid
that.
2026-04-18 20:38:52 +02:00
wpferguson
a55de740e5 Merge pull request #663 from deekayhd/issue_661
fix incomplete removal of datetime_taken in PR #653
2026-04-11 11:11:20 -04:00
deekayhd
dc31b221ef fix username as nil 2026-04-11 14:36:30 +02:00
deekayhd
34b88490c2 fix assignment of empty variables derived from exit_datetime_taken 2026-04-11 14:30:29 +02:00
deekayhd
d9b77bb3a2 fix incomplete removal of datetime_taken in PR #653 2026-04-10 19:56:00 +02:00
wpferguson
cc198d6ca1 Merge pull request #662 from wpferguson/fix_transfer_hierachy
contrib/transfer_hierarchy bug fix
2026-04-09 12:21:31 -04:00
Bill Ferguson
84582cbf29 contrib/transfer_hierarchy - fixed unused variable being used as
boolean function
2026-04-09 12:18:54 -04:00
wpferguson
9e7a18e290 Merge pull request #660 from wpferguson/readd_toggle_group_view
re-add contrib/toggle_group_view
2026-04-06 21:23:41 -04:00
Bill Ferguson
8ff81fcfa4 contrib/toggle_group_view - re-added toggle_group_view after getting
button sensitivity figured out.

This script adds a way to toggle a selected or hovered group to it's
own collection and back again
2026-04-06 21:20:32 -04:00
wpferguson
b649e7a840 Merge pull request #659 from wpferguson/fix_siv_typo
contrib/selected_image_visible - corrected typo
2026-04-05 15:32:32 -04:00
Bill Ferguson
efa06ee34c contrib/selected_image_visible - corrected typo 2026-04-05 15:31:38 -04:00
wpferguson
77959422e0 Merge pull request #658 from wpferguson/remove_toggle_group_view
contrib/toggle_group_view - removed because there are too many problems
2026-04-04 20:10:29 -04:00
Bill Ferguson
0bff346b05 contrib/toggle_group_view - removed because there are too many problems
needing fixed.
2026-04-04 20:08:14 -04:00
wpferguson
d05c494147 Merge pull request #657 from wpferguson/fix_event_destroy
contrib/toggle_group_view - fixed event destroy call
2026-04-04 19:53:27 -04:00
Bill Ferguson
9cbbae9621 contrib/toggle_group_view - fixed event destroy call 2026-04-04 19:52:13 -04:00
wpferguson
fb2aade198 Merge pull request #656 from wpferguson/fix_button_function_call
contrib/selected_image_visible - corrected action button function call
2026-04-04 19:48:07 -04:00
Bill Ferguson
7ffd8bdc27 contrib/selected_image_visible - corrected action button function call 2026-04-04 19:44:53 -04:00
wpferguson
b689dbc7ee Merge pull request #655 from wpferguson/update_toogle_group_view
contrib/toggle_group_view
2026-04-04 17:20:49 -04:00
Bill Ferguson
027135a257 contrib/toggle_group_view - updated to current version from old
version I mistakenly used.
2026-04-04 17:19:23 -04:00
wpferguson
643057deaf Merge pull request #652 from wpferguson/selected_image_visible
contrib/selected_image_visible
2026-04-04 15:56:12 -04:00
Bill Ferguson
a00f71f533 contrib/selected_image_visible - Added action button to make
selected non visible image visible
2026-04-04 15:54:00 -04:00
wpferguson
36d6db24e7 Merge pull request #654 from wpferguson/fix_s2c_destroy
official/selection2collection - fixed 2 bugs with destroy function
2026-04-04 15:23:26 -04:00
Bill Ferguson
f62a5a817f official/selection2collection - fixed 2 bugs with destroy function 2026-04-04 15:21:41 -04:00
wpferguson
44b40b305b Merge pull request #651 from wpferguson/select_raw_non_raw
select_raw_non_raw
2026-04-04 15:01:46 -04:00
wpferguson
97dade06cf Merge pull request #650 from wpferguson/group_persistence
official/group_persistence
2026-04-04 15:01:25 -04:00
wpferguson
30167631e7 Merge pull request #649 from wpferguson/toggle_group_view
toggle_group_view
2026-04-04 15:01:01 -04:00
wpferguson
8235de1f0a Merge pull request #648 from wpferguson/select_unaltered
select_unaltered
2026-04-04 15:00:26 -04:00
wpferguson
d67ada8300 Merge pull request #646 from wpferguson/recent_bookmarks
recent_bookmarks
2026-04-04 15:00:07 -04:00
wpferguson
c6788e4370 Merge pull request #645 from wpferguson/use_paired_jpg_as_mipmap
use_paired_jpg_as_mipmap
2026-04-04 14:59:46 -04:00
wpferguson
d238fcee61 Merge pull request #644 from wpferguson/selection2collection
selection2collection
2026-04-04 14:58:52 -04:00
wpferguson
28d71c989f Merge pull request #643 from wpferguson/extract_burst_roll_images
extract_burst_roll_images
2026-04-04 14:58:16 -04:00
wpferguson
74f020c809 Merge pull request #642 from wpferguson/auto_straighten
auto_straighten
2026-04-04 14:57:43 -04:00
wpferguson
80769a753a Merge pull request #641 from wpferguson/select_duplicates
select duplicates
2026-04-04 14:57:14 -04:00
wpferguson
427359769e Merge pull request #647 from wpferguson/scheduler
scheduler
2026-04-04 14:56:17 -04:00
Bill Ferguson
5e5b073cec official/use_paired_jpg_as_mipmap - fixed typo in namespace table 2026-03-30 21:12:51 -04:00
Bill Ferguson
c771c5fc1b official/use_paired_jpg_as_mipmap - changed isc message to a broadcast
so that any cache building script could catch it and then execute.
2026-03-30 20:51:37 -04:00
Bill Ferguson
7cdf1d4c7f official/use_paired_jpg_as_mipmap - Added more logging and changed
trigger message for cache building scripts
2026-03-30 13:19:41 -04:00
wpferguson
b6ea6211db Merge pull request #653 from deekayhd/no_exif_date_corr
fix wrong month in EXIF.MONTH.LONG and EXIF_MONTH.SHORT when no exif date available in image

Thanks
2026-03-30 13:00:31 -04:00
deekayhd
4d3924939b remove unnecessary variable datetime_taken 2026-03-29 23:45:40 +02:00
deekayhd
80ff1ef0aa add warning message 2026-03-29 23:37:22 +02:00
deekayhd
af362315e8 set all vars derived from datetime_taken to "" 2026-03-29 23:22:04 +02:00
deekayhd
28f3e9f6a2 fix wrong month in EXIF.MONTH.LONG and EXIF_MONTH.SHORT when no date available 2026-03-29 14:29:39 +02:00
Bill Ferguson
4f05672809 contrib/selected_image_visible - add script that creates a shortcut
to make the selected image visible in lighttable.
2026-03-26 23:34:50 -04:00
Bill Ferguson
8d8f295c77 official/select_raw_non_raw - updated help url 2026-03-26 23:17:25 -04:00
Bill Ferguson
a81ab437cf official/select_raw_non_raw - added script that creates two selection
buttons, one to select raw images and the other to select non raw
images
2026-03-26 22:43:14 -04:00
Bill Ferguson
a94a0c2c90 contrib/toggle_group_view - updated help link 2026-03-26 22:41:28 -04:00
Bill Ferguson
0eda1b6ac5 official/group_persistence - add script to store group information in
xmp files so that groups can be maintained across database instances
2026-03-26 22:06:33 -04:00
Bill Ferguson
58bc3a69df contrib/toggle_group_view - moved from official to contrib to handle
event conflict with official/group_persistence.  Moving to contrib forces
toggle_group_view to load before group_persistence and thus get events that
both need, first.  If the order is reversed then toggle_group_view can bog
down in a loop making darktable unresponsive.

Fixed bad call in destroy and added check for selection-changed to make sure
there are images before trying to set sensitivity.
2026-03-26 13:57:04 -04:00
Bill Ferguson
35398c55e5 official/toggle_group_view - fix no images selected so can't get a count of nil 2026-03-25 13:07:03 -04:00
Bill Ferguson
1762b372b1 official/toggle_group_view - made button sensitive to group members
selected
2026-03-25 12:33:21 -04:00
Bill Ferguson
1907f9a1f5 official/toggle_group_view - added a script to view a group as
a collection when toggled from lighttable
2026-03-24 22:57:45 -04:00
Bill Ferguson
b1dd3eca5a official/select_unaltered - add select button to select images that
haven't been altered in any way
2026-03-24 22:18:46 -04:00
Bill Ferguson
732d2788ca official/scheduler - script to provide a FIFO scheduler for cooperative
Lua multitasking.
2026-03-24 21:38:20 -04:00
Bill Ferguson
04908e3f0b official/recent_bookmarks - Update the recently-used.xbel file showing
recently used files for images edited in or exported from darktable.

Current this only works on Linux
2026-03-24 21:01:20 -04:00
Bill Ferguson
3ba3facad2 official/use_paired_jpg_as_mipmap - Canon R series cameras don't
included a full size embedded jpeg so generating mipmap cache for
anything larger than mipmap 3 requires the raw to be loaded and
processed.  This script copies the paired jpeg from the RAW+JPEG
pair to the full resolution mipmap.  This can then be used to generate
the remaining mipmap cache.  The jpeg image can be discarded after
being copied to the cache or retained based on a preference in Lua
options.
2026-03-24 20:45:44 -04:00
Bill Ferguson
cb209d1d56 official/selection2collection - create a temporary collection from
a selection.  Temporary collections are removed when darktable exits.
Temporary collections can be kept by setting a preference in the Lua options.
2026-03-24 20:29:35 -04:00
Bill Ferguson
36533b1004 official/extract_burst_roll_images - script to extract Canon burst
roll images from a burst roll image container
2026-03-24 20:07:48 -04:00
Bill Ferguson
cbbf0f413f official/auto_straighten - add script to straighten image based on
roll and pitch metadata
2026-03-24 19:56:13 -04:00
Bill Ferguson
181aea5bc2 official/select_duplicates - Added script to detect and select duplicates.
Script adds a button to the select images module.
2026-03-24 17:10:08 -04:00
wpferguson
94c6dff245 Merge pull request #636 from deekayhd/rename_images_reset
enable reset in rename_images.lua
2026-03-21 10:48:09 -04:00
Bill Ferguson
79ac6fc31e tools/script_manager - added check for darktable_gui_safe to avoid
startup race condition hang
2026-03-20 16:08:20 -04:00
deekayhd
7a61d77c27 add reset of preference 2026-03-19 11:39:36 +01:00
deekayhd
2635e261a0 changes after review 2026-03-19 11:38:45 +01:00
wpferguson
d197851478 Merge pull request #637 from wpferguson/image_time_filename_sanitize
contrib/image_time - sanitize filenames so that spaces don't cause problems
2026-03-18 10:50:28 -04:00
Bill Ferguson
6f519b3ce8 contrib/image_time - sanitize filenames so that spaces don't cause
problems
2026-03-18 10:46:12 -04:00
deekayhd
03eb79e83a enable reset in rename_images.lua 2026-03-13 21:55:14 +01:00
wpferguson
fb03b49a93 Merge pull request #633 from lefth/photils-cli-path
Fix the path to photils-cli on mac

Thanks
2026-02-15 13:17:46 -05:00
Dan Zwell
c0865c37bb Fix the path to photils-cli on mac 2026-02-15 17:15:32 +08:00
Bill Ferguson
6bbb619513 tools/script_manager - Changed to allow running scripts included
with the darktable distribution.  User installed
                       scripts will override the bundled scripts
2026-02-02 19:29:37 -05:00
wpferguson
182a2c96c3 Merge pull request #630 from wpferguson/select_non_existing_speedup
Speed up select_non_existing
2026-01-28 21:22:46 -05:00
Bill Ferguson
06522802d8 contrib/select_non_existing - changed the logic to get a list of
files in the filmroll and check the
                              "known" files against the file list.
                              This cuts the number of system calls
                              from 1 per image to 1 per film roll
                              resulting an incredible speed up on
                              windows and a good speedup on other
                              platforms.
2026-01-28 21:14:39 -05:00
wpferguson
ad263fed11 Merge pull request #626 from ipalopezhentsev/patch-1
reorder lines
2026-01-04 14:17:42 -05:00
Ilya Palopezhentsev
ced60016fb reorder lines
oops, committed line before the line it should go after
2026-01-04 20:12:35 +01:00
wpferguson
1d6d0695fe Merge pull request #625 from ipalopezhentsev/patch-2
Fix enfuseAdvanced command line building bugs
2026-01-04 13:27:22 -05:00
wpferguson
fbe504b47a Merge pull request #624 from ipalopezhentsev/patch-1
fix dtutils_system.external_command ignoring bad return code on Windows
2026-01-04 13:26:52 -05:00
Ilya Palopezhentsev
7da38b3665 Fix enfuseAdvanced command line building bugs
1) enfuse has argument --contrast-min-curvature taking string with a percent sign. When that runs on Windows, it fails due to the fact that % means env var substitution in Windows command lines. we have to escape it with double percent
2) when enfuse produces a jpg, it takes argument --compression with an integer value. But the script takes it from GUI and passes as a float which breaks enfuse. 
3) When errors like these happen, the script forgets to stop darktable job leaving dangling string in UI.
2026-01-04 12:17:35 +01:00
Ilya Palopezhentsev
08fe1ba410 fix dtutils_system.external_command ignoring bad return code on Windows
On Windows, external_command calls windows_command which prepares a bat file with the command, surrounded with code page saving/restoration. If the "real" command fails, its error return code does not get anywhere and 0 is always returned, because the last line is a chcp call which always succeeds.
This breaks error logic in scripts, e.g. enfuseAdvanced, where they think everything went fine when it didn't.
2026-01-04 10:20:03 +01:00
wpferguson
088c79e140 Merge pull request #622 from dterrahe/patch-1
Update author name in x-touch.lua metadata
2026-01-01 11:47:19 -05:00
dterrahe
f06eebe316 Update author name in x-touch.lua metadata 2025-12-26 11:29:35 +01:00
wpferguson
d6a1cec9fa Merge pull request #620 from wpferguson/revert_ext_editors_sanitize
reverted export2collection sanitize for new file name
2025-12-20 19:53:12 -05:00
Bill Ferguson
e194ad2bc7 reverted export2collection sanitizing new file name as it causes a
problem on windows.
2025-12-20 19:50:24 -05:00
wpferguson
22e3c59d69 Merge pull request #611 from scorpi11/website_gallery
Website gallery replacement
2025-12-18 12:18:44 -05:00
Tino Mettler
eb743e4b3b Make some further strings transtlatable 2025-12-18 18:11:04 +01:00
Tino Mettler
7a8f3afc40 Portability: use system specific path separator, sanitize filenames 2025-12-18 17:23:00 +01:00
Tino Mettler
2b3910c698 CSS style cleanup 2025-12-18 08:19:14 +01:00
Tino Mettler
c584ee05c9 Remove unused JavaScript code and outdated comments 2025-12-18 08:02:43 +01:00
Tino Mettler
bb3a74feac CSS cleanup, separate functional and visual CSS code
Added file style.css which is aimed to contain CSS code that affects
the look of the gallery.

This could be extended to provide different or user-modified styles
to give each gallery an individual look.
2025-12-18 07:55:53 +01:00
wpferguson
90d2764a5a Merge pull request #604 from MStraeten/patch-3
Refactor x-touch script, basic AgX support
2025-12-10 20:39:29 -05:00
Tino Mettler
b0373f7166 Use subdirectories in the generated gallery
JavaScript files are now in js/, CSS files in css/, thumbnail images in
thumbnails/.
2025-12-10 08:46:24 +01:00
Tino Mettler
c6002e7eb7 Make UI text elements translatable 2025-12-10 08:46:11 +01:00
Tino Mettler
a47de82afd Show progress during thumbnail export 2025-12-08 14:53:58 +01:00
wpferguson
7f09e55525 Merge pull request #616 from wpferguson/fix_multi_os
examples/multi_os - replaced ufraw-batch with exiftool
2025-12-07 14:18:29 -05:00
wpferguson
f83ac1366a Merge pull request #617 from wpferguson/ext_editor_sanitize
ext_editor sanitize file name
2025-12-07 14:18:11 -05:00
Bill Ferguson
6cda27172e contrib/ext_editor - sanitize filename before importing to collection 2025-12-06 19:11:12 -05:00
Bill Ferguson
df78522a79 examples/multi_os - replaced ufraw-batch with exiftool to extract
the embedded jpeg or preview image.
2025-12-06 18:40:37 -05:00
wpferguson
6e64d6db6f Merge pull request #613 from wpferguson/string_lib_fixes
String lib fixes
2025-12-04 15:13:56 -05:00
Tino Mettler
7f00cb8d6c Update TODO list for the website gallery export 2025-12-04 19:09:22 +01:00
Tino Mettler
dfeff73fcd Add callback to check if export format is supported
Currently JPG, TIFF, PNG and WebP are supported.
2025-12-04 19:06:26 +01:00
Bill Ferguson
d75f00daa0 lib/dtutils/string - fixed ROLL.NAME to support the original and
the ROLL.NAME[n] variant in the same variable
                     string
2025-12-03 20:54:47 -05:00
Bill Ferguson
3e430ee2be lib/dtutils/string - converted element from string to number for use in test 2025-12-03 18:26:13 -05:00
Bill Ferguson
3856ae3449 lib/dtutils/string - added format_exposure function to take an
exif_exposure and return it as an exposure
                     string (exif_exposure = 500, string 1/500)
2025-12-02 20:40:03 -05:00
Bill Ferguson
d54a9f8b7e lib/dtutils/string - implemented variable substitution for ROLL.NAME[n] 2025-12-02 19:54:10 -05:00
Bill Ferguson
8f6cc850fb lib/dtutils/string - Added Xmp.darktable.version and Xmp.dc.[title,
description,publisher,rights,creator]
2025-12-02 18:57:05 -05:00
Bill Ferguson
e9f18a3d36 lib/dtutils/string - add IMAGE.ID and IMAGE.ID.NEXT variables 2025-12-02 18:34:18 -05:00
wpferguson
f3a89a89bd Merge pull request #612 from wpferguson/fix_apply_camera_style
official/apply_camera_style - fixed pattern matching bugs
2025-12-02 16:30:56 -05:00
Bill Ferguson
d075fb7128 official/apply_camera_style - fixed pattern matching bug that allowed
camera styles to be applied twice

                              fixed pattern matching bug affecting
                              Canon EOS R, RP, and R5

                              Added more debugging statements to
                              pattern matching functions
2025-12-02 16:23:28 -05:00
Tino Mettler
7f4bae4234 Update TODO 2025-11-26 22:31:10 +01:00
Tino Mettler
071a3527c1 Add new website gallery module to README.md 2025-11-26 21:32:19 +01:00
Tino Mettler
22c9a7cd6e Comments, remove cruft 2025-11-26 21:32:10 +01:00
Tino Mettler
12ccefcc3b Add missing copyright/license information 2025-11-26 21:31:41 +01:00
Tino Mettler
fa6014fefa Export module for a website gallery with selected images
This export module should replace the PhotoSwipe copy in darktable.

See https://github.com/darktable-org/darktable/issues/16205 for more
details.
2025-11-26 20:46:11 +01:00
Martin Straeten
3c131c41d1 adaption to recent agx update
renamed action and reordered sliders
2025-11-11 22:00:25 +01:00
Martin Straeten
ffa125b134 further improved customizability
to add a new module, simply add an entry to MODULE_PROFILES below.
direct usage of action paths from darktable's shortcut interface.
2025-11-09 20:15:34 +01:00
Martin Straeten
056f616283 Refactor x-touch script, basic AgX support
Refactored x-touch script to improve structure and maintainability. Mainly by adding helper functions for handling different module types.
the added support for AgX module
(credit goes to claude.ai which helped a lot )
2025-11-06 19:19:46 +01:00
37 changed files with 6466 additions and 243 deletions

View File

@@ -79,6 +79,7 @@ passport_guide_germany|Yes|LMW|Add passport cropping guide for German passports
[slideshowMusic](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/slideshowMusic)|No|L|Play music during a slideshow [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 [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 [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 ### Example Scripts

View File

@@ -288,7 +288,13 @@ local function GetArgsFromPreference(prog_table, prefix) --for each arg in a pro
local temp = dt.preferences.read(mod, prefix..argument, arg_data.style) local temp = dt.preferences.read(mod, prefix..argument, arg_data.style)
if arg_data.style == 'bool' and temp then if arg_data.style == 'bool' and temp then
prog_table.arg_string = prog_table.arg_string..arg_data.text..' ' prog_table.arg_string = prog_table.arg_string..arg_data.text..' '
elseif arg_data.style == 'integer' or arg_data.style == 'string' then elseif arg_data.style == 'integer' then
prog_table.arg_string = prog_table.arg_string..arg_data.text..temp..' '
elseif arg_data.style == 'string' then
if dt.configuration.running_os == "windows" then
--escape % with %% as % means env var on windows and call will fail otherwise
temp = string.gsub(temp, "%%", "%%%%")
end
prog_table.arg_string = prog_table.arg_string..arg_data.text..temp..' ' prog_table.arg_string = prog_table.arg_string..arg_data.text..temp..' '
elseif arg_data.style == 'float' then elseif arg_data.style == 'float' then
temp = string.sub(tostring(temp),1,3) temp = string.sub(tostring(temp),1,3)
@@ -345,7 +351,7 @@ local function UpdateENFargs(image_table, prefix) --updates the Enfuse arguments
end end
ENF.arg_string = ENF.arg_string..' --depth='..GUI.Target.depth.value..' ' ENF.arg_string = ENF.arg_string..' --depth='..GUI.Target.depth.value..' '
if GUI.Target.format.value == 'tif' then ENF.arg_string = ENF.arg_string..'--compression='..GUI.Target.compression_level_tif.value..' ' if GUI.Target.format.value == 'tif' then ENF.arg_string = ENF.arg_string..'--compression='..GUI.Target.compression_level_tif.value..' '
elseif GUI.Target.format.value == 'jpg' then ENF.arg_string = ENF.arg_string..'--compression='..GUI.Target.compression_level_jpg.value..' ' elseif GUI.Target.format.value == 'jpg' then ENF.arg_string = ENF.arg_string..'--compression='..tostring(math.floor(GUI.Target.compression_level_jpg.value))..' '
end end
if not GUI.Target.source_location.value then out_path = GUI.Target.output_directory.value end if not GUI.Target.source_location.value then out_path = GUI.Target.output_directory.value end
out_name = smallest_name..'-'..largest_id out_name = smallest_name..'-'..largest_id
@@ -492,6 +498,7 @@ local function main(storage, image_table, extra_data)
remove_temp_files(images_to_remove) remove_temp_files(images_to_remove)
dt.print_error(ENF.name..' failed') dt.print_error(ENF.name..' failed')
dt.print(string.format(_("%s failed"), ENF.name)) dt.print(string.format(_("%s failed"), ENF.name))
job.valid = false
return return
end end

View File

@@ -138,8 +138,9 @@ script_data.show = nil -- only required for libs since the destroy_method only h
local PS = dt.configuration.runnin_os == "windows" and "\\" or "/" local PS <const> = dt.configuration.runnin_os == "windows" and "\\" or "/"
local ERROR = -1 local ERROR <const> = -1
local HAS_INTERNAL_METADATA = dt.metadata.exists("Exif.Image.DataTime")
-- function to convert from exif time to system time -- function to convert from exif time to system time
local function exiftime2systime(exiftime) local function exiftime2systime(exiftime)
@@ -211,7 +212,6 @@ local function year_month2months(year, month)
end end
local function months2year_month(months) local function months2year_month(months)
dt.print_log("months is " .. months)
local year = math.floor(months / 12) local year = math.floor(months / 12)
local month = months - (year * 12) local month = months - (year * 12)
@@ -224,7 +224,7 @@ local function get_image_taken_time(image)
local exiv2 = df.check_if_bin_exists("exiv2") local exiv2 = df.check_if_bin_exists("exiv2")
if exiv2 then if exiv2 then
p = io.popen(exiv2 .. " -K Exif.Image.DateTime " .. image.path .. PS .. image.filename) p = io.popen(exiv2 .. " -K Exif.Image.DateTime " .. df.sanitize_filename(image.path .. PS .. image.filename))
if p then if p then
for line in p:lines() do for line in p:lines() do
if string.match(line, "Exif.Image.DateTime") then if string.match(line, "Exif.Image.DateTime") then
@@ -242,28 +242,37 @@ end
local function _get_windows_image_file_creation_time(image) local function _get_windows_image_file_creation_time(image)
local datetime = nil local datetime = nil
local p = io.popen("dir " .. image.path .. PS .. image.filename) local datestring = nil
if p then
for line in p:lines() do if HAS_INTERNAL_METADATA then
if string.match(line, ds.sanitize_lua(image.filename)) then datestring = image.DateString
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 else
dt.print(string.format(_("unable to get information for %s"), image.filename)) local p = io.popen("dir " .. image.path .. PS .. image.filename)
datetime = ERROR if p then
for line in p:lines() do
if string.match(line, ds.sanitize_lua(image.filename)) then
datestring = line
end
end
p:close()
else
dt.print(string.format(_("unable to get information for %s"), image.filename))
datetime = ERROR
end
end
if datestring then
local mo, day, yr, hr, min, apm = string.match(datestring, "(%d+)/(%d+)/(%d+) (%d-):(%d+) (%S+)")
if apm == "PM" then
hr = hr + 12
end
datetime = vars2exiftime(yr, mo, day, hr, min, 0)
end end
return datetime return datetime
end end
local function _get_nix_image_file_creation_time(image) local function _get_nix_image_file_creation_time(image)
local datetime = nil local datetime = nil
local p = io.popen("ls -lL --time-style=full-iso " .. image.path .. PS .. image.filename) local p = io.popen("ls -lL --time-style=full-iso " .. df.sanitize_filename(image.path .. PS .. image.filename))
if p then if p then
for line in p:lines() do for line in p:lines() do
if string.match(line, ds.sanitize_lua(image.filename)) then if string.match(line, ds.sanitize_lua(image.filename)) then

View File

@@ -264,10 +264,6 @@ function PHOTILS.get_tags(image, with_export)
local out_arg = df.sanitize_filename(tmp_file) local out_arg = df.sanitize_filename(tmp_file)
local executable = photils_installed local executable = photils_installed
if dt.configuration.running_os == "macos" then
executable = executable .. "/Contents/MacOS/photils-cli"
end
if with_export then if with_export then
dt.print_log("use export to for prediction") dt.print_log("use export to for prediction")
local export_file = df.create_tmp_file() local export_file = df.create_tmp_file()

View File

@@ -185,9 +185,6 @@ local function do_rename(images)
end end
end end
local function reset_callback()
rename.widgets.pattern.text = ""
end
-- - - - - - - - - - - - - - - - - - - - - - - - -- - - - - - - - - - - - - - - - - - - - - - - -
-- W I D G E T S -- W I D G E T S
@@ -196,7 +193,11 @@ end
rename.widgets.pattern = dt.new_widget("entry"){ rename.widgets.pattern = dt.new_widget("entry"){
tooltip = ds.get_substitution_tooltip(), tooltip = ds.get_substitution_tooltip(),
placeholder = _("enter pattern") .. "$(FILE_FOLDER)/$(FILE_NAME)", placeholder = _("enter pattern") .. "$(FILE_FOLDER)/$(FILE_NAME)",
text = "" 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") local pattern_pref = dt.preferences.read(MODULE_NAME, "pattern", "string")

View File

@@ -20,14 +20,19 @@ Enable selection of non-existing images in the the currently worked on images, e
local dt = require "darktable" local dt = require "darktable"
local du = require "lib/dtutils" local du = require "lib/dtutils"
local df = require "lib/dtutils.file" local df = require "lib/dtutils.file"
local ds = require "lib/dtutils.string"
local log = require "lib/dtutils.log"
-- module name -- module name
local MODULE = "select_non_existing" local MODULE <const> = "select_non_existing"
du.check_min_api_version("9.1.0", MODULE) du.check_min_api_version("9.1.0", MODULE)
-- figure out the path separator -- figure out the path separator
local PS = dt.configuration.running_os == "windows" and "\\" or "/" 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 gettext = dt.gettext.gettext
@@ -39,26 +44,61 @@ local function stop_job(job)
job.valid = false job.valid = false
end end
local function select_nonexisting_images(event, images) local function get_file_list(dir)
local selection = {} log.log_level(DEFAULT_LOG_LEVEL)
local file_list = {}
local cmd = nil
log.msg(log.debug, "called get_file_list with " .. dir)
local job = dt.gui.create_job(_("select non existing images"), true, stop_job) if dt.configuration.running_os == "windows" then
for key,image in ipairs(images) do cmd = "forfiles /P " .. ds.sanitize(dir) .. " /M * /C \"cmd /c echo @file\""
if(job.valid) then else
job.percent = (key - 1)/#images cmd = "cd " .. ds.sanitize(dir) ..";ls -1"
local filepath = image.path..PS..image.filename end
local file_exists = df.test_file(filepath, "e") log.msg(log.info, "cmd is " .. cmd)
dt.print_log(filepath.." exists? => "..tostring(file_exists))
if (not file_exists) then local p = io.popen(cmd)
table.insert(selection, image) if p then
end for line in p:lines() do
else if line:len() > 4 then
break if not string.match(line, "xmp$") and not string.match(line, "XMP$") then
line = line:gsub("\"", "")
file_list[line] = true
end end
end
end end
stop_job(job) p:close()
end
return selection 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 end
local function destroy() local function destroy()

View 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

View 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

View File

@@ -270,7 +270,7 @@ local function doTransfer(transferFunc)
local sourceBase = sourceTextBox.text local sourceBase = sourceTextBox.text
local destBase = destinationTextBox.text local destBase = destinationTextBox.text
local destFilms = {} local destFilms = {}
for film, _ in pairs(films) do for film, i in pairs(films) do
films[film] = destBase .. string.sub(film.path, #sourceBase+1) films[film] = destBase .. string.sub(film.path, #sourceBase+1)
if not pathExists(films[film]) then if not pathExists(films[film]) then
if createDirectory(films[film]) == nil then if createDirectory(films[film]) == nil then

View File

@@ -0,0 +1,294 @@
--[[Export module to create a web gallery from selected images
copyright (c) 2025 Tino Mettler
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 this software. If not, see <http://www.gnu.org/licenses/>.
]]
--[[
TODO:
- Lua: remove images dir if already existent
- Lua: translations
]]
local dt = require "darktable"
local df = require "lib/dtutils.file"
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
local temp = dt.preferences.read('web_gallery', 'title', 'string')
if temp == nil then temp = 'Darktable gallery' end
local function _(msgid)
return dt.gettext.gettext(msgid)
end
local title_widget = dt.new_widget("entry")
{
text = temp
}
local temp = dt.preferences.read('web_gallery', 'destination_dir', 'string')
if temp == nil then temp = '' end
local dest_dir_widget = dt.new_widget("file_chooser_button")
{
title = _("select output folder"),
tooltip = _("select output folder"),
value = temp,
is_directory = true,
changed_callback = function(this) dt.preferences.write('web_gallery', 'destination_dir', 'string', this.value) end
}
local gallery_widget = dt.new_widget("box")
{
orientation=vertical,
dt.new_widget("label"){label = _("gallery title")},
title_widget,
dt.new_widget("label"){label = _("destination directory")},
dest_dir_widget
}
local function get_file_name(file)
return file:match("[^" .. PS .. "]*.$")
end
function escape_js_string(str)
local replacements = {
['\\'] = '\\\\',
['"'] = '\\"',
["'"] = "\\'",
['\n'] = '\\n',
['\r'] = '\\r',
['\t'] = '\\t',
['\b'] = '\\b',
['\f'] = '\\f',
['\v'] = '\\v'
}
return (str:gsub('[\\\"\n\r\t\b\f\v\']', replacements))
end
local function export_thumbnail(image, filename)
exporter = dt.new_format("jpeg")
exporter.quality = 90
exporter.max_height = 512
exporter.max_width = 512
exporter:write_image(image, filename, true)
end
local function write_image(image, dest_dir, filename)
df.file_move(filename, dest_dir.. PS .. "images" .. PS .. get_file_name(filename))
export_thumbnail(image, dest_dir .. PS .. "thumbnails" .. PS .. "thumb_" .. get_file_name(filename))
end
function exiftool_get_image_dimensions(filename)
local handle = io.popen("exiftool " .. filename)
local result = handle:read("*a")
handle:close()
for line in result:gmatch("[^\r\n]+") do
local w = line:match("^Image Width%s*:%s*(%d+)")
if w then
width = tonumber(w)
end
local h = line:match("^Image Height%s*:%s*(%d+)")
if h then
height = tonumber(h)
end
end
if width and height then
return width, height
else
return nil, nil
end
end
local function stop_job(job)
job.valid = false
end
local function fill_gallery_table(images_ordered, images_table, title, dest_dir, sizes, exiftool)
local gallery_data = { name = escape_js_string(title) }
local images = {}
local index = 1
local job = dt.gui.create_job(_("exporting thumbnail images"), true, stop_job)
for i, image in pairs(images_ordered) do
local filename = df.sanitize_filename(images_table[image])
dt.print(_("export thumbnail image ") .. index .. "/" .. #images_ordered)
write_image(image, dest_dir, filename)
if exiftool then
width, height = exiftool_get_image_dimensions(df.sanitize_filename(dest_dir .. PS .. "images" .. PS .. get_file_name(filename)))
else
width = sizes[index].width
height = sizes[index].height
end
local entry = { filename = "images" .. PS .. get_file_name(filename),
width = width, height = height }
images[index] = entry
job.percent = index / #images_ordered
index = index + 1
end
stop_job(job)
gallery_data.images = images
return gallery_data
end
local function generate_javascript_gallery_object(gallery)
local js = 'const gallery_data = {\n'
js = js .. ' name: "' .. gallery.name .. '",\n'
js = js .. ' images: [\n'
for i, img in ipairs(gallery.images) do
js = js .. string.format(' { filename: "%s",\n height: %d,\n width: %d }', img.filename, img.height, img.width)
if i < #gallery.images then
js = js .. ',\n'
else
js = js .. '\n'
end
end
js = js .. ' ]\n};\n'
return(js)
end
local function write_javascript_file(gallery_table, dest_dir)
dt.print(_("write JavaScript file"))
javascript_object = generate_javascript_gallery_object(gallery_table)
local fileOut, errr = io.open(dest_dir .. PS .. "js" .. PS .. "images.js", 'w+')
if fileOut then
fileOut:write(javascript_object)
else
log.msg(log.error, errr)
end
fileOut:close()
end
local function copy_static_files(dest_dir)
gfsrc = dt.configuration.config_dir .. PS .. "lua" .. PS .. "data" .. PS .. "website_gallery"
local gfiles = {
"index.html",
"css" .. PS .. "gallery.css",
"css" .. PS .. "modal.css",
"css" .. PS .. "style.css",
"js" .. PS .. "gallery.js",
"js" .. PS .. "modal.js",
"js" .. PS .. "fullscreen.js"
}
dt.print(_("copy static gallery files"))
for _, file in ipairs(gfiles) do
df.file_copy(gfsrc .. PS .. file, dest_dir .. PS .. file)
end
end
local function build_gallery(storage, images_table, extra_data)
local dest_dir = dest_dir_widget.value
df.mkdir(df.sanitize_filename(dest_dir))
df.mkdir(df.sanitize_filename(dest_dir .. PS .. "images"))
df.mkdir(df.sanitize_filename(dest_dir .. PS .. "thumbnails"))
df.mkdir(df.sanitize_filename(dest_dir .. PS .. "css"))
df.mkdir(df.sanitize_filename(dest_dir .. PS .. "js"))
local images_ordered = extra_data["images"] -- process images in the correct order
local sizes = extra_data["sizes"]
local title = _("Darktable export")
if title_widget.text ~= "" then
title = title_widget.text
end
local exiftool = df.check_if_bin_exists("exiftool");
gallerydata = fill_gallery_table(images_ordered, images_table, title, dest_dir, sizes, exiftool)
write_javascript_file(gallerydata, dest_dir)
copy_static_files(dest_dir)
end
local script_data = {}
script_data.metadata = {
name = _("website gallery (new)"),
purpose = _("create a web gallery from exported images"),
author = "Tino Mettler <tino+darktable@tikei.de>",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/TODO"
}
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 destroy()
dt.preferences.write('web_gallery', 'title', 'string', title_widget.text)
dt.destroy_storage("module_webgallery")
end
script_data.destroy = destroy
local function show_status(storage, image, format, filename,
number, total, high_quality, extra_data)
dt.print(string.format(_("export image ").."%i/%i", number, total))
aspect = image.aspect_ratio
-- calculate the size of the exported image and store it in extra_data
-- to make it available in the finalize function
if image.final_height == 0 then
if aspect < 1 then
dimensions = { width = image.height, height = image.width }
else
dimensions = { width = image.width, height = image.height }
end
else
dimensions = { width = image.final_width, height = image.final_height }
end
if format.max_height > 0 and dimensions.height > format.max_height then
scale = format.max_height / dimensions.height
dimensions.height = math.floor(dimensions.height * scale + 0.5)
dimensions.width = math.floor(dimensions.width * scale + 0.5)
end
if format.max_width > 0 and dimensions.width > format.max_width then
scale = format.max_width / dimensions.width
dimensions.height = math.floor(dimensions.height * scale + 0.5)
dimensions.width = math.floor(dimensions.width * scale + 0.5)
end
extra_data["sizes"][number] = dimensions
end
local function initialize(storage, img_format, images, high_quality, extra_data)
dt.preferences.write('web_gallery', 'title', 'string', title_widget.text)
extra_data["images"] = images -- needed, to preserve images order
extra_data["sizes"] = {};
end
local supported_formats = { "jpg", "tif", "png", "webp" }
local formats_lut = {}
for key,format in pairs(supported_formats) do
formats_lut[format] = true
end
function check_supported(storage, format)
extension = format.extension
if formats_lut[extension] == true then
return true
else
return false
end
end
dt.register_storage("module_webgallery", "website gallery (new)", show_status, build_gallery, check_supported, initialize, gallery_widget)
return script_data

View File

@@ -0,0 +1,56 @@
/*
copyright (c) 2025 Tino Mettler
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 this software. If not, see <http://www.gnu.org/licenses/>.
*/
body {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
}
.heading h1 {
text-align: center;
display: grid;
}
.gallery {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
}
.gallery img {
cursor: pointer;
object-fit: contain;
}
.thumb {
object-fit: contain;
flex-shrink: 0;
min-width: 100%;
min-height: 100%;
max-width: 100%;
max-height: 100%;
}
.thumb-box {
display: flex;
align-items: center;
object-fit: contain;
justify-content: center;
}

View File

@@ -0,0 +1,99 @@
/*
copyright (c) 2025 Tino Mettler
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 this software. If not, see <http://www.gnu.org/licenses/>.
*/
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.viewer {
position: relative;
width: 100vw;
height: 100vh;
touch-action: none;
overflow: hidden;
display: none;
}
.navgrid {
display: grid;
}
.nav {
height: var(--navigation-bar-height);
justify-content: center;
align-items: center;
display: flex;
z-index: 1001;
}
.slider {
height: calc(100vh - var(--navigation-bar-height));
position: relative;
width: 100vw;
touch-action: none;
overflow: hidden;
}
.slide-container {
position: absolute;
top: 0;
left: 0;
width: 300%;
height: 100%;
display: flex;
transform: translateX(-33.333%);
transition: transform 0.3s ease-out;
will-change: transform;
}
.slide {
width: 33.333%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: #000;
will-change: contents;
overflow: hidden;
position: relative;
touch-action: none;
object-fit: contain;
}
.slide img {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
display: block;
transform-origin: 0 0;
cursor: zoom-in;
object-fit: contain;
}
.slide.zoomed img {
cursor: zoom-out;
max-width: none;
max-height: none;
}
.slide img.zooming {
transition: transform 0.2s ease-out;
}

View File

@@ -0,0 +1,66 @@
body {
font-family: system-ui, Arial, Helvetica, sans-serif;
background-color: rgb(238, 240, 242);
}
.heading h1 {
background-color: #cdd;
color: black;
}
.heading h1:after {
content: " ";
border-bottom: 1px solid #888888;
height: 1px;
}
.gallery {
gap: 3vw;
margin: 3vw auto;
padding: 1.5vw;
}
.gallery img {
background: none;
border: 1px outset #737780;
}
.gallery img:hover {
transform: scale(1.08);
}
.thumb-box {
background: none;
box-shadow: 0 1vw 4vw 1vw rgba(0,0,0,0.6);
}
.viewer {
--navigation-bar-height: 5vh;
}
.nav {
background-color: rgba(60,60,60, 0.4);
color: #ccc;
}
.navgrid {
grid-template-columns: 4em 1fr 1.5em 1.5em 1fr 1.5em 1.5em;
font-size: 3.5vh;
grid-column-gap: 1vw;
background-color: black;
}
.button {
cursor: pointer;
}
.button:hover {
cursor: pointer;
color: white;
text-shadow: 0px 0px 10px #ccc;
}
.counter {
font-size: 2.5vh;
}

View File

@@ -0,0 +1,46 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<meta name="viewport" content="width=device-width, height=device-height, initial-scale=1.0">
<link rel="stylesheet" href="css/modal.css">
<link rel="stylesheet" href="css/gallery.css">
<link rel="stylesheet" href="css/style.css">
<title id="page-title">Image Gallery</title>
</head>
<body>
<div id="heading1" class="heading">
<h1 id="gallery-title">Image Gallery</h1>
</div>
<div id="gallery" class="gallery"></div>
<div class="viewer" id="viewer">
<div class="navgrid">
<div id="counter" class="nav"></div>
<div class="spacer"></div>
<div class="button nav nav-arrow" id="prevArrow">&#9664;</div>
<div class="button nav nav-arrow" id="nextArrow">&#9654;</div>
<div class="spacer"></div>
<div id="fullscreen" class="button nav">&#x26F6;</div>
<div id="close" class="nav button">&times;</div>
</div>
<div class="slider" id="slider1">
<div class="slide-container">
<div class="slide" id="prev"></div>
<div class="slide" id="current"></div>
<div class="slide" id="next"></div>
</div>
</div>
</div>
<script type='text/javascript' >
var currentIndex = 1;
</script>
<script src="js/images.js"></script>
<script src="js/fullscreen.js"></script>
<script src="js/modal.js"></script>
<script src="js/gallery.js"></script>
</body>
</html>

View File

@@ -0,0 +1,40 @@
var isFullscreen = false;
var toggleFullscreen = function (ele) {
return isFullscreen ? exitFullscreen(ele) : requestFullscreen(ele);
};
var requestFullscreen = function (ele) {
if(isFullscreen == true) return 0 ;
isFullscreen = true;
if (ele.requestFullscreen) {
ele.requestFullscreen();
} else if (ele.webkitRequestFullscreen) {
ele.webkitRequestFullscreen();
} else if (ele.mozRequestFullScreen) {
ele.mozRequestFullScreen();
} else if (ele.msRequestFullscreen) {
ele.msRequestFullscreen();
} else {
console.log('Fullscreen API is not supported.');
}
};
var exitFullscreen = function () {
if(isFullscreen == false) return 0;
isFullscreen = false;
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
} else {
console.log('Fullscreen API is not supported.');
}
};

View File

@@ -0,0 +1,112 @@
/*
copyright (c) 2025 Tino Mettler
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 this software. If not, see <http://www.gnu.org/licenses/>.
*/
var scrollPosX = 0;
var scrollPosY = 0;
document.addEventListener('DOMContentLoaded', function () {
var imageCount = 0;
const gallery = document.getElementById('gallery');
const viewer = document.getElementById('viewer');
function showModal(e) {
const thumbbox = e.target.parentElement;
const index = [...gallery.children].indexOf(thumbbox);
currentIndex = index;
scrollPosX = document.documentElement.scrollLeft;
scrollPosY = document.documentElement.scrollTop;
gallery.style.display = 'none';
document.getElementById('heading1').style.display = 'none';
viewer.style.display = 'grid';
loadSlides();
updateCounter(currentIndex);
updateNavigationState();
}
function closeModal() {
exitFullscreen(document.documentElement);
viewer.style.display = 'none';
document.getElementById('heading1').style.display = 'grid';
gallery.style.display = 'flex';
document.documentElement.scrollTo({
left: scrollPosX,
top: scrollPosY,
behavior: "instant",
});
};
function createThumbnailElement(imageObj) {
const frame = document.createElement('div');
frame.className = 'thumb-box';
const framesize = 18;
const width = parseInt(imageObj.width);
const height = parseInt(imageObj.height);
const sum = width + height;
const scalefactor = sum / (framesize * 2.0);
frame.style.width = (width / scalefactor) + 'vw';
frame.style.height = (height / scalefactor) + 'vw';
const img = document.createElement('img');
img.className = 'thumb';
img.src = imageObj.filename.replace(/images\/(.*)$/i, 'thumbnails/thumb_$1');
img.alt = imageObj.filename;
img.addEventListener('click', function (e) { e.stopPropagation(); showModal(e); });
frame.appendChild(img);
gallery.appendChild(frame);
}
const images = gallery_data.images;
const title = document.getElementById('gallery-title');
const pageTitle = document.getElementById('page-title');
if (gallery_data.name) {
title.textContent = gallery_data.name;
pageTitle.textContent = gallery_data.name;
}
document.getElementById('close').onclick = function (e) {
e.stopPropagation();
closeModal();
};
// Keyboard navigation using left/right arrow keys
document.onkeyup = function (e) {
e.stopPropagation();
switch(e.key) {
case "Escape":
closeModal();
break;
}
};
document.getElementById('fullscreen').onclick = function (e) {
e.stopPropagation();
toggleFullscreen(document.documentElement);
};
// Populate thumbnail gallery
images.forEach(function (imageObj) {
createThumbnailElement(imageObj);
});
});

View File

@@ -0,0 +1,355 @@
/*
copyright (c) 2025 Tino Mettler
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 this software. If not, see <http://www.gnu.org/licenses/>.
*/
const container = document.querySelector('.slide-container');
const slides = {
prev: document.getElementById('prev'),
current: document.getElementById('current'),
next: document.getElementById('next')
};
const prevArrow = document.getElementById('prevArrow');
const nextArrow = document.getElementById('nextArrow');
let startX = 0;
let startY = 0;
let isDragging = false; // For swipe navigation
let isZoomed = false;
let isPanning = false; // Panning in zoomed state
let hasPanned = false;
let translateX = 0;
let translateY = 0;
let currentScale = 1;
let img;
let baseWidth, baseHeight; // Image dimensions before zoom
let scaledWidth, scaledHeight;
const images = gallery_data.images;
function updateCounter(index) {
const counter = document.getElementById('counter');
counter.textContent = (index + 1) + ' / ' + images.length;
}
function updateBoundaries() {
containerRect = container.getBoundingClientRect();
// Calculate the actual scaled dimensions based on the base (pre-zoom) size
scaledWidth = baseWidth * currentScale;
scaledHeight = baseHeight * currentScale;
}
function limitPanning(proposedX, proposedY) {
const maxX = 0;
const minX = baseWidth - scaledWidth;
const maxY = 0;
const minY = baseHeight - scaledHeight;
let constrainedX = proposedX;
let constrainedY = proposedY;
// Apply constraints
if (scaledWidth > baseWidth) {
// Image wider than original - constrain panning
constrainedX = Math.max(minX, Math.min(maxX, proposedX));
} else {
// Image narrower than original - center it
constrainedX = (baseWidth - scaledWidth) / 2;
}
if (scaledHeight > baseHeight) {
// Image taller than original - constrain panning
constrainedY = Math.max(minY, Math.min(maxY, proposedY));
} else {
// Image shorter than original - center it
constrainedY = (baseHeight - scaledHeight) / 2;
}
return {
x: constrainedX,
y: constrainedY
};
}
function handleZoom(e) {
// If we were actually panning, don't zoom
if (hasPanned) {
isPanning = false;
hasPanned = false;
return;
}
// If we're zoomed and haven't panned, zoom out
if (isZoomed) {
img.classList.add('zooming');
translateX = 0;
translateY = 0;
currentScale = 1;
img.style.transform = 'none';
container.classList.remove('zoomed');
isZoomed = false;
updateNavigationState();
setTimeout(() => {
img.classList.remove('zooming');
}, 200);
return;
}
// Zoom in at clicked/tapped point
if (!isZoomed) {
const rect = img.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// Store the base (pre-zoom) dimensions and offset
baseWidth = rect.width;
baseHeight = rect.height;
// Calculate the scale for 1:1 pixel zoom
currentScale = img.naturalWidth / rect.width;
// Calculate the translation needed to keep clicked point under cursor
// After scaling, the point at (x, y) will be at (x * currentScale, y * currentScale)
// We want it to remain at (e.clientX - rect.left, e.clientY - rect.top)
translateX = e.clientX - rect.left - (x * currentScale);
translateY = e.clientY - rect.top - (y * currentScale);
updateBoundaries();
const limited = limitPanning(translateX, translateY);
translateX = limited.x;
translateY = limited.y;
img.classList.add('zooming');
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${currentScale})`;
container.classList.add('zoomed');
setTimeout(() => {
img.classList.remove('zooming');
}, 200);
}
isZoomed = true;
updateNavigationState();
}
function createImageElement(imageData) {
if (!imageData) return null;
const img = new Image();
img.src = imageData.filename;
img.width = imageData.width;
img.height = imageData.height;
img.addEventListener('dragstart', (e) => e.preventDefault());
return img;
}
function loadSlides() {
slides.prev.innerHTML = '';
slides.current.innerHTML = '';
slides.next.innerHTML = '';
if (currentIndex > 0) {
const prevImg = createImageElement(images[currentIndex - 1]);
if (prevImg) slides.prev.appendChild(prevImg);
}
const currentImg = createImageElement(images[currentIndex]);
if (currentImg) {
img = currentImg;
slides.current.appendChild(currentImg);
}
if (currentIndex < images.length - 1) {
const nextImg = createImageElement(images[currentIndex + 1]);
if (nextImg) slides.next.appendChild(nextImg);
}
updateCounter(currentIndex)
updateNavigationState();
}
function updateNavigationState() {
prevArrow.style.visibility = (currentIndex === 0 || isZoomed) ? 'hidden' : 'visible';
nextArrow.style.visibility = (currentIndex === images.length - 1 || isZoomed) ? 'hidden' : 'visible';
}
async function showPreviousImage() {
if (currentIndex > 0 && !isZoomed) {
currentIndex--;
container.style.transition = 'transform 0.3s ease-out';
container.style.transform = 'translateX(0%)';
await waitForTransition();
container.style.transition = 'none';
container.style.transform = 'translateX(-33.333%)';
loadSlides();
}
}
async function showNextImage() {
if (currentIndex < images.length - 1 && !isZoomed) {
currentIndex++;
container.style.transition = 'transform 0.3s ease-out';
container.style.transform = 'translateX(-66.666%)';
await waitForTransition();
container.style.transition = 'none';
container.style.transform = 'translateX(-33.333%)';
loadSlides();
}
}
function handleTouchStart(e) {
if (isZoomed) return;
startX = e.touches[0].clientX;
isDragging = true;
container.style.transition = 'none';
}
function handleTouchMove(e) {
if (!isDragging || isZoomed) return;
const currentX = e.touches[0].clientX;
const diff = currentX - startX;
const baseOffset = -33.333;
const percentMoved = (diff / window.innerWidth) * 33.333;
container.style.transform = `translateX(${baseOffset + percentMoved}%)`;
}
async function handleTouchEnd(e) {
if (!isDragging)
if(isZoomed) {
handleZoom(e);
return;
}
isDragging = false;
const endX = e.changedTouches[0].clientX;
const diff = endX - startX;
const threshold = window.innerWidth * 0.2;
container.style.transition = 'transform 0.3s ease-out';
if (diff > threshold && currentIndex > 0) {
await showPreviousImage();
} else if (diff < -threshold && currentIndex < images.length - 1) {
await showNextImage();
} else {
container.style.transform = 'translateX(-33.333%)';
}
}
function handleKeyDown(e) {
if (isZoomed) return;
if (e.code === 'Space' || e.code === 'ArrowRight') {
e.preventDefault();
showNextImage();
} else if (e.code === 'Backspace' || e.code === 'ArrowLeft') {
e.preventDefault();
showPreviousImage();
}
}
function waitForTransition() {
return new Promise(resolve => {
container.addEventListener('transitionend', resolve, { once: true });
});
}
// Initialize
loadSlides();
// Navigation event listeners
container.addEventListener('touchstart', handleTouchStart);
container.addEventListener('touchmove', handleTouchMove);
container.addEventListener('touchend', handleTouchEnd);
container.addEventListener('click', handleZoom);
document.addEventListener('keydown', handleKeyDown);
prevArrow.addEventListener('click', showPreviousImage);
nextArrow.addEventListener('click', showNextImage);
// Mouse panning event listeners
container.addEventListener('mousedown', function(e) {
if (isZoomed && e.target.tagName === 'IMG') {
isPanning = true;
hasPanned = false;
updateBoundaries();
startX = e.clientX - translateX;
startY = e.clientY - translateY;
e.preventDefault();
container.style.cursor = 'grabbing';
}
});
window.addEventListener('mousemove', function(e) {
if (isPanning && isZoomed) {
const proposedX = e.clientX - startX;
const proposedY = e.clientY - startY;
const limited = limitPanning(proposedX, proposedY);
translateX = limited.x;
translateY = limited.y;
const img = slides.current.querySelector('img');
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${currentScale})`;
hasPanned = true;
}
});
window.addEventListener('mouseup', function() {
if (isPanning) {
isPanning = false;
container.style.cursor = isZoomed ? 'zoom-out' : 'zoom-in';
}
});
// Touch panning event listeners
container.addEventListener('touchstart', function(e) {
if (isZoomed) {
isPanning = true;
hasPanned = false;
updateBoundaries();
const touch = e.touches[0];
startX = touch.clientX - translateX;
startY = touch.clientY - translateY;
e.preventDefault();
}
});
container.addEventListener('touchmove', function(e) {
if (isPanning && isZoomed) {
const touch = e.touches[0];
const proposedX = touch.clientX - startX;
const proposedY = touch.clientY - startY;
const limited = limitPanning(proposedX, proposedY);
translateX = limited.x;
translateY = limited.y;
const img = slides.current.querySelector('img');
img.style.transform = `translate(${translateX}px, ${translateY}px) scale(${currentScale})`;
hasPanned = true;
e.preventDefault();
}
});
container.addEventListener('touchend', function(e) {
if (isPanning) {
isPanning = false;
}
});

323
examples/ai_denoise.lua Normal file
View File

@@ -0,0 +1,323 @@
--[[
This file is part of darktable,
copyright (c) 2026 darktable developers.
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/>.
]]
--[[
ai_denoise - example script demonstrating the darktable.ai Lua
API by denoising the selected images with tile-based inference.
Adds an "AI denoise" panel to the lighttable right column with a
"denoise selected" button. Each selected image is loaded through
the develop pipeline (so denoise sees the output of all enabled
IOPs, not the original raw), run tile-by-tile through the active
denoise model, and saved as a 16-bit TIFF grouped with the source.
Demonstrates:
* model lookup via dt.ai.model_for_task
* tensor crop/paste for tile-based inference
* linear <-> sRGB conversion (NR models train on gamma input)
* edge-replicated padding to suppress border artifacts
* background job with progress bar and cancellation
LIMITATIONS
* Image must be at least TILE_SIZE x TILE_SIZE (768 x 768 for
denoise-nind / denoise-nafnet); smaller inputs return an error.
* If a save fails mid-batch the Lua error propagates and aborts
the whole job. Wrap denoise_one in pcall if you need
batch-robust behaviour.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
* a denoise model enabled in preferences -> AI (denoise-nind,
denoise-nafnet, ...)
USAGE
* require this script from your luarc
* select one or more images in lighttable
* click the "denoise selected" button in the AI denoise panel
BUGS, COMMENTS, SUGGESTIONS
* file an issue on https://github.com/darktable-org/darktable
CHANGES
]]
local dt = require "darktable"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE_NAME = "ai_denoise"
-- darktable's NR models use static-shape ONNX: each model has a fixed
-- input dimension baked in at training time. The Lua API doesn't
-- expose this, so keep TILE_SIZE in sync with the model's
-- `attributes.input_sizes` in its config.json.
local TILE_SIZE = 768 -- denoise-nind, denoise-nafnet
-- overlap between adjacent tiles. larger values produce smoother
-- seams at the cost of more compute per pixel
local OVERLAP = 64
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
-- Surround the input with `pad` pixels on every side, replicating the
-- outermost rows/columns ("edge clamp" padding). Boundary tiles then
-- see plausible context instead of a literal image edge, so the
-- network's internal zero-padding no longer leaks into the visible
-- output as colour artifacts along the image rim.
local function pad_replicate(input, pad)
local C = input:shape()[2]
local H = input:shape()[3]
local W = input:shape()[4]
local padded = dt.ai.create_tensor({1, C, H + 2 * pad, W + 2 * pad})
-- center: original image
padded:paste(input, pad, pad)
-- left and right strips: replicate first / last column of input
local left_col = input:crop(0, 0, H, 1)
local right_col = input:crop(0, W - 1, H, 1)
for i = 0, pad - 1 do
padded:paste(left_col, pad, i)
padded:paste(right_col, pad, pad + W + i)
end
-- top and bottom strips: replicate first / last row of the already
-- horizontally-padded buffer so the four corners get filled correctly
local top_row = padded:crop(pad, 0, 1, W + 2 * pad)
local bottom_row = padded:crop(pad + H - 1, 0, 1, W + 2 * pad)
for i = 0, pad - 1 do
padded:paste(top_row, i, 0)
padded:paste(bottom_row, pad + H + i, 0)
end
return padded
end
-- Tile a [1,C,H,W] tensor through the model with overlap-blended seams.
-- Each tile is tile_size x tile_size, successive tiles step by
-- (tile_size - 2*overlap), and only the central (non-overlap) region
-- of each tile is pasted into the output. Image-boundary edges keep
-- their full extent; only image-interior seams drop the overlap margin.
--
-- The trailing tile in each axis is pinned to the image boundary so
-- the residual right/bottom strip is never smaller than 2*overlap --
-- otherwise it would have to be skipped (the model has a fixed input
-- size) and those pixels would never be written.
local function run_tiled(ctx, input, tile_size, overlap,
job, base_pct, span_pct)
local C = input:shape()[2]
local H = input:shape()[3]
local W = input:shape()[4]
if H < tile_size or W < tile_size then
return nil, string.format("image too small (%dx%d) for tile %d",
W, H, tile_size)
end
local step = tile_size - 2 * overlap
-- enumerate tile origins along one axis, ending exactly at the boundary
local function origins(extent)
local out, p = {}, 0
while p + tile_size < extent do
out[#out + 1] = p
p = p + step
end
out[#out + 1] = math.max(0, extent - tile_size)
return out
end
local ys, xs = origins(H), origins(W)
local total = #xs * #ys
local output = dt.ai.create_tensor({1, C, H, W})
local done = 0
for _, ty in ipairs(ys) do
for _, tx in ipairs(xs) do
if job and not job.valid then return nil, "cancelled" end
local tile_in = input:crop(ty, tx, tile_size, tile_size)
local tile_out = ctx:run(tile_in)
-- central region to keep: drop overlap on every inner edge;
-- full extent at image-boundary edges
local at_top = (ty == 0)
local at_left = (tx == 0)
local at_bottom = (ty + tile_size >= H)
local at_right = (tx + tile_size >= W)
local cy = at_top and 0 or overlap
local cx = at_left and 0 or overlap
local ch = tile_size - cy - (at_bottom and 0 or overlap)
local cw = tile_size - cx - (at_right and 0 or overlap)
output:paste(tile_out:crop(cy, cx, ch, cw), ty + cy, tx + cx)
done = done + 1
if job then
job.percent = base_pct + span_pct * (done / total)
end
end
end
return output
end
-- Denoise one image: load through the develop pipeline, run tiled
-- inference, save as 16-bit TIFF, group with the source.
local function denoise_one(ctx, img, tile_size, job, base_pct, span_pct)
-- darktable returns the develop-pipeline output as scene-linear RGB;
-- the NR models were trained on sRGB-gamma input, so we re-encode
-- before inference and decode back to linear before saving
local input = dt.ai.load_image(img)
if not input then return false, "load_image failed" end
input:linear_to_srgb()
-- pad so the image's outer rim is no longer at a tile boundary;
-- the padded strip absorbs the network's edge artifacts and gets
-- cropped away after inference
local H = input:shape()[3]
local W = input:shape()[4]
local padded_in = pad_replicate(input, OVERLAP)
local padded_out, err = run_tiled(ctx, padded_in, tile_size, OVERLAP,
job, base_pct, span_pct)
if not padded_out then return false, err end
local output = padded_out:crop(OVERLAP, OVERLAP, H, W)
output:srgb_to_linear()
local base = img.filename:match("(.+)%..+$") or img.filename
local out_path = img.path .. "/" .. base .. "_denoised.tif"
output:save_tiff(out_path, 16, img)
-- import the result and group with the source so they live together
-- in the lighttable
local imported = dt.database.import(out_path)
if imported then imported:group_with(img) end
return true
end
-- Button handler: looks up the active denoise model, fans out the
-- selected images, and reports progress via a cancellable job.
local function process_denoise()
local model_id = dt.ai.model_for_task("denoise")
if not model_id then
dt.print("no denoise model enabled -- pick one in preferences -> AI")
return
end
local images = dt.gui.selection()
if #images == 0 then
dt.print("select at least one image")
return
end
-- progress + cancel job. the cancel callback flips job.valid; the
-- main loop polls it between tiles to stop cleanly
local job = dt.gui.create_job(
string.format("AI denoise (%d image%s)",
#images, #images == 1 and "" or "s"),
true,
function(j) j.valid = false end)
dt.print_log(string.format(
"[ai_denoise] starting: %d image%s with model %s",
#images, #images == 1 and "" or "s", model_id))
local t_start = os.time()
local ctx = dt.ai.load_model(model_id)
if not ctx then
dt.print("failed to load model: " .. model_id)
job.valid = false
return
end
local span = 1.0 / #images
local done = 0
for i, img in ipairs(images) do
if not job.valid then break end
dt.print_log(string.format("[ai_denoise] [%d/%d] %s",
i, #images, img.filename))
local ok, err = denoise_one(ctx, img, TILE_SIZE, job,
(i - 1) * span, span)
if not ok then
dt.print(string.format("[%d/%d] %s: %s",
i, #images, img.filename, tostring(err)))
dt.print_log(string.format("[ai_denoise] [%d/%d] %s FAILED: %s",
i, #images, img.filename, tostring(err)))
else
done = done + 1
end
end
ctx:close()
job.valid = false
local elapsed = os.time() - t_start
dt.print(string.format("denoise complete: %d image%s",
#images, #images == 1 and "" or "s"))
dt.print_log(string.format(
"[ai_denoise] finished: %d/%d in %ds",
done, #images, elapsed))
end
-- script_manager integration: called when the user disables the script.
-- darktable can't remove a registered lib from the UI at runtime, so we
-- only stop any in-flight work; the panel itself remains until restart.
local function destroy()
-- nothing persistent to clean up: each click runs its own job and
-- closes its own model context
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.register_lib(
MODULE_NAME, -- plugin id
"AI denoise", -- displayed name
true, -- expandable
false, -- no reset button
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 100}},
dt.new_widget("box") {
orientation = "vertical",
dt.new_widget("button") {
label = "denoise selected",
clicked_callback = process_denoise,
}
},
nil, nil
)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- 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 = "AI denoise",
purpose = "tile-based AI denoise using the darktable.ai Lua API",
author = "Andrii Ryzhkov",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/ai_denoise"
}
script_data.destroy = destroy
return script_data

481
examples/ai_raw_denoise.lua Normal file
View File

@@ -0,0 +1,481 @@
--[[
This file is part of darktable,
copyright (c) 2026 darktable developers.
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/>.
]]
--[[
ai_raw_denoise - example script demonstrating the darktable.ai Lua
API by denoising the selected raw images with tile-based inference
on the raw CFA.
Adds an "AI raw denoise" panel to the lighttable right column with
a "denoise selected" button. Each selected image's raw CFA is
loaded, Bayer-packed to 4 channels, run tile-by-tile through the
currently enabled rawdenoise model (which denoises AND demosaicks
in one pass), and saved as a LinearRaw DNG grouped with the
source image.
Demonstrates:
* model lookup via dt.ai.model_for_task
* loading model variant files via dt.ai.load_model file arg
* raw CFA loading via dt.ai.load_raw
* tensor bayer_pack for 4-channel CFA tiling
* tile loop with non-trivial input->output scale (here 1 -> 2)
* edge-replicated padding to suppress border artifacts
* LinearRaw DNG output via dt.ai.save_dng_linear
* background job with progress bar and cancellation
LIMITATIONS
* Bayer sensors only. The script loads model_bayer.onnx and uses
bayer_pack, which assumes a 2x2 Bayer layout. X-Trans and
Foveon sensors need model_linear.onnx with a different
preprocessing pipeline (no bayer_pack, demosaicked input).
* Image must be at least TILE_SIZE x TILE_SIZE in packed pixels
(1024 x 1024 of CFA for the default 512 packed tile size).
* If a save fails mid-batch the Lua error propagates and aborts
the whole job. Wrap denoise_one in pcall if you need
batch-robust behaviour.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
* a rawdenoise model enabled in preferences -> AI
(e.g. rawdenoise-nind)
USAGE
* require this script from your luarc
* select one or more raw images in lighttable
* click the "denoise selected" button in the AI raw denoise panel
BUGS, COMMENTS, SUGGESTIONS
* file an issue on https://github.com/darktable-org/darktable
CHANGES
]]
local dt = require "darktable"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE_NAME = "ai_raw_denoise"
-- The rawdenoise-nind bayer model is a denoise+demosaic combo:
-- input : [1, 4, 512, 512] packed CFA (4 phase planes)
-- output : [1, 3, 1024, 1024] linear RGB at 2x the input resolution
-- So each tile-size pixel of the input covers a 2x2 CFA block, and the
-- model emits two output pixels per input pixel in each axis.
-- TILE_SIZE is in packed pixels (model input); SCALE turns those into
-- output pixels. Keep in sync with `attributes.input_sizes` in the
-- model's config.json.
local TILE_SIZE = 512 -- rawdenoise-nind
local SCALE = 2 -- model output dim / input dim
local OUT_CH = 3 -- model emits linear RGB
-- overlap between adjacent tiles, in packed pixels (input space).
-- larger values produce smoother seams at the cost of more compute
local OVERLAP = 32
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
-- Bayer-only CFA channel lookup. Returns 0/1/2 (R/G/B) for the cell
-- at sensor position (r, c) given the 32-bit `filters` mask from
-- load_raw's meta table. Used to find the R-cell origin so bayer_pack's
-- plane 0 always holds R. X-Trans (filters == 9) uses meta.xtrans
-- instead and is not handled here.
local function fc(r, c, filters)
local shift = ((r % 2) * 4) + ((c % 2) * 2)
return math.floor(filters / (2 ^ shift)) % 4
end
-- daylight WB multipliers (R, G=1, B) derived from the camera's
-- XYZ->camRGB color matrix and the D65 white point. mirrors the C
-- path's _bayer_wb_daylight in restore_raw_bayer.c — the rawdenoise
-- bayer model is trained on daylight-WB'd input, so feeding it
-- un-WB'd camRGB produces wrong colors. Falls back to {1, 1, 1} if
-- the matrix is missing or degenerate.
local function daylight_wb(color_matrix)
local d65 = {0.95047, 1.0, 1.08883}
if not color_matrix then return 1.0, 1.0, 1.0 end
local resp = {0, 0, 0}
for c = 1, 3 do
resp[c] = color_matrix[c][1] * d65[1]
+ color_matrix[c][2] * d65[2]
+ color_matrix[c][3] * d65[3]
end
if resp[1] <= 0 or resp[2] <= 0 or resp[3] <= 0 then
return 1.0, 1.0, 1.0
end
return resp[2] / resp[1], 1.0, resp[2] / resp[3]
end
-- Surround the input with `pad` pixels on every side, replicating the
-- outermost rows/columns ("edge clamp" padding). Boundary tiles then
-- see plausible context instead of a literal image edge, so the
-- network's internal zero-padding no longer leaks into the visible
-- output as colour artifacts along the image rim.
local function pad_replicate(input, pad)
local C = input:shape()[2]
local H = input:shape()[3]
local W = input:shape()[4]
local padded = dt.ai.create_tensor({1, C, H + 2 * pad, W + 2 * pad})
-- center: original image
padded:paste(input, pad, pad)
-- left and right strips: replicate first / last column of input
local left_col = input:crop(0, 0, H, 1)
local right_col = input:crop(0, W - 1, H, 1)
for i = 0, pad - 1 do
padded:paste(left_col, pad, i)
padded:paste(right_col, pad, pad + W + i)
end
-- top and bottom strips: replicate first / last row of the already
-- horizontally-padded buffer so the four corners get filled correctly
local top_row = padded:crop(pad, 0, 1, W + 2 * pad)
local bottom_row = padded:crop(pad + H - 1, 0, 1, W + 2 * pad)
for i = 0, pad - 1 do
padded:paste(top_row, i, 0)
padded:paste(bottom_row, pad + H + i, 0)
end
return padded
end
-- Tile a [1,C_in,H,W] tensor through a model that emits [1,C_out,H*scale,
-- W*scale]. Each tile is tile_size x tile_size in input space; successive
-- tiles step by (tile_size - 2*overlap); only the central (non-overlap)
-- region of each tile's output is pasted into the result, with all
-- offsets multiplied by `scale`. Image-boundary edges keep their full
-- extent; only image-interior seams drop the overlap margin.
--
-- The trailing tile in each axis is pinned to the image boundary so
-- the residual right/bottom strip is never smaller than 2*overlap --
-- otherwise it would have to be skipped (the model has a fixed input
-- size) and those pixels would never be written.
local function run_tiled(ctx, input, tile_size, overlap, scale, c_out,
job, base_pct, span_pct)
local H = input:shape()[3]
local W = input:shape()[4]
if H < tile_size or W < tile_size then
return nil, string.format("image too small (%dx%d) for tile %d",
W, H, tile_size)
end
local step = tile_size - 2 * overlap
-- enumerate tile origins along one axis, ending exactly at the boundary
local function origins(extent)
local out, p = {}, 0
while p + tile_size < extent do
out[#out + 1] = p
p = p + step
end
out[#out + 1] = math.max(0, extent - tile_size)
return out
end
local ys, xs = origins(H), origins(W)
local total = #xs * #ys
-- output is scale-times larger spatially and may have a different
-- channel count than the input
local output = dt.ai.create_tensor({1, c_out, H * scale, W * scale})
local done = 0
for _, ty in ipairs(ys) do
for _, tx in ipairs(xs) do
if job and not job.valid then return nil, "cancelled" end
local tile_in = input:crop(ty, tx, tile_size, tile_size)
local tile_out = ctx:run(tile_in)
-- central region to keep: drop overlap on every inner edge;
-- full extent at image-boundary edges. all dimensions in OUTPUT
-- (post-scale) space, since that's where we paste
local at_top = (ty == 0)
local at_left = (tx == 0)
local at_bottom = (ty + tile_size >= H)
local at_right = (tx + tile_size >= W)
local ov = overlap * scale
local tos = tile_size * scale
local cy = at_top and 0 or ov
local cx = at_left and 0 or ov
local ch = tos - cy - (at_bottom and 0 or ov)
local cw = tos - cx - (at_right and 0 or ov)
output:paste(tile_out:crop(cy, cx, ch, cw),
ty * scale + cy, tx * scale + cx)
done = done + 1
if job then
job.percent = base_pct + span_pct * (done / total)
end
end
end
return output
end
-- Denoise one raw image. The bayer rawdenoise model denoises AND
-- demosaicks in one pass, so its output is linear RGB at the
-- original CFA resolution (not a CFA mosaic). Saved as a LinearRaw
-- DNG, which darktable can re-import as an already-debayered raw.
--
-- Pipeline:
-- load CFA [1,1,H,W] + image metadata
-- -> bayer_pack [1,4,H/2,W/2]
-- -> normalize to [0, 1] using black/white levels (the model is
-- trained on (val - black) / (white - black), not raw ADC)
-- -> pad to [1,4,H/2+2P,W/2+2P]
-- -> tile through model (scale=2, c_out=3) -> [1,3,(H+4P),(W+4P)]
-- -> crop padding back off -> [1,3,H,W]
-- -> save linear DNG -> import + group with source
local function denoise_one(ctx, img, tile_size, job, base_pct, span_pct)
-- load_raw returns (tensor, meta). tensor is [1,1,H,W] in raw ADC
-- units; meta carries black/white/wb/CFA info we need to normalise
-- the input into the [0, 1] range the model was trained on
local cfa, meta = dt.ai.load_raw(img)
if not cfa then return false, "load_raw failed" end
-- this script is Bayer-only: filters == 9 is X-Trans (handled by a
-- different preprocessing pipeline + model_linear.onnx); filters == 0
-- means monochrome / non-CFA, which bayer_pack can't handle either
if not meta.filters or meta.filters == 0 or meta.filters == 9 then
return false, "unsupported CFA pattern (Bayer-only script)"
end
-- Crop to the visible region and shift the origin onto the R cell
-- of the CFA pattern. bayer_pack splits by position only, so to make
-- plane 0 always hold R (which the model expects) the cropped CFA's
-- (0, 0) must sit at an R site. Mirrors the C path's _bayer_origin
-- + RGGB shift in restore_raw_bayer.c.
local cy = 0
local cx = 0
local cw = cfa:shape()[4]
local ch = cfa:shape()[3]
if meta.crop_x and meta.crop_y then
cy = meta.crop_y - (meta.crop_y % 2)
cx = meta.crop_x - (meta.crop_x % 2)
cw = (meta.visible_width or cw) + (meta.crop_x - cx)
ch = (meta.visible_height or ch) + (meta.crop_y - cy)
end
-- find R cell within the 2x2 starting at (cy, cx)
local sy, sx = 0, 0
for ty = 0, 1 do
for tx = 0, 1 do
if fc(cy + ty, cx + tx, meta.filters) == 0 then
sy, sx = ty, tx
end
end
end
cy = cy + sy
cx = cx + sx
ch = ch - sy
cw = cw - sx
-- ensure even dims for bayer_pack
ch = ch - (ch % 2)
cw = cw - (cw % 2)
if cy > 0 or cx > 0
or cw < cfa:shape()[4] or ch < cfa:shape()[3]
then
cfa = cfa:crop(cy, cx, ch, cw)
end
-- uniform-black approximation: most sensors report identical black
-- levels across the 4 CFA sites, in which case a single scalar
-- (val - black) / (white - black) normalisation is exact. cameras
-- with non-uniform per-site black levels (e.g. some PDAF sensors)
-- would need per-plane scaling via scale_add_planes -- left as an
-- exercise
local black = meta.black_level
local range = meta.white_level - black
if range <= 0 then range = 65535 end
-- pack the 1-channel CFA into 4 phase planes so each channel holds
-- one Bayer site type. After the RGGB shift above:
-- plane 0 = R, planes 1,2 = G, plane 3 = B
local packed = cfa:bayer_pack()
packed:scale_add(1.0 / range, -black / range)
-- apply daylight WB so the four planes sit in the same range the
-- model was trained on (R, G, G, B → R*wb_R, G, G, B*wb_B). without
-- this, the model sees unbalanced channels and produces wrong colors
local wb_R, wb_G, wb_B = daylight_wb(meta.color_matrix)
packed:scale_add_planes({wb_R, wb_G, wb_G, wb_B})
local pH = packed:shape()[3]
local pW = packed:shape()[4]
-- save the post-normalisation input mean for gain-matching the
-- model output: the bayer rawdenoise model emits values on its own
-- internal scale, not [0, 1], so we rescale so output mean matches
-- input mean (the per-tile equivalent of dt's C reference path)
local in_mean = packed:mean()
-- pad so the image rim is no longer at a tile boundary; the padded
-- strip absorbs the network's edge artifacts and is cropped away
-- after inference (in output space, where the pad covers 2*OVERLAP
-- pixels per side)
local padded_in = pad_replicate(packed, OVERLAP)
local padded_out, err = run_tiled(ctx, padded_in, tile_size, OVERLAP,
SCALE, OUT_CH,
job, base_pct, span_pct)
if not padded_out then return false, err end
-- crop the padded region (in output coords: pad * scale per side)
-- back to the original-CFA resolution
local out_rgb = padded_out:crop(OVERLAP * SCALE, OVERLAP * SCALE,
pH * SCALE, pW * SCALE)
-- gain-match: rescale the output so its mean matches the input
-- mean, bringing the model's internal-scale output back into the
-- [0, 1] range save_dng_linear expects
local out_mean = out_rgb:mean()
if out_mean > 1e-8 then
out_rgb:scale_add(in_mean / out_mean)
end
-- invert the daylight WB so the saved tensor is un-WB'd camRGB
-- (save_dng_linear's contract — the consumer applies AsShotNeutral
-- on import)
out_rgb:scale_add_planes({1.0 / wb_R, 1.0 / wb_G, 1.0 / wb_B})
local base = img.filename:match("(.+)%..+$") or img.filename
local out_path = img.path .. "/" .. base .. "_rawdenoised.dng"
-- LinearRaw DNG: tensor values must be normalised to [0, 1] camRGB;
-- the model handles black-subtract + scale internally, so we just
-- pass the output through
dt.ai.save_dng_linear(out_rgb, img, out_path)
-- import the result and group with the source so they live together
-- in the lighttable
local imported = dt.database.import(out_path)
if imported then imported:group_with(img) end
return true
end
-- Button handler: looks up the active rawdenoise model, fans out the
-- selected images, and reports progress via a cancellable job.
local function process_raw_denoise()
local model_id = dt.ai.model_for_task("rawdenoise")
if not model_id then
dt.print("no rawdenoise model enabled -- pick one in preferences -> AI")
return
end
local images = dt.gui.selection()
if #images == 0 then
dt.print("select at least one image")
return
end
-- progress + cancel job. the cancel callback flips job.valid; the
-- main loop polls it between tiles to stop cleanly
local job = dt.gui.create_job(
string.format("AI raw denoise (%d image%s)",
#images, #images == 1 and "" or "s"),
true,
function(j) j.valid = false end)
dt.print_log(string.format(
"[ai_raw_denoise] starting: %d image%s with model %s",
#images, #images == 1 and "" or "s", model_id))
local t_start = os.time()
-- rawdenoise packages ship multiple files (one per CFA type); pick
-- the Bayer variant. X-Trans / Foveon would use model_linear.onnx
-- with a different preprocessing pipeline (no bayer_pack)
local ctx = dt.ai.load_model(model_id, nil, "model_bayer.onnx")
if not ctx then
dt.print("failed to load model: " .. model_id)
job.valid = false
return
end
local span = 1.0 / #images
local done = 0
for i, img in ipairs(images) do
if not job.valid then break end
dt.print_log(string.format("[ai_raw_denoise] [%d/%d] %s",
i, #images, img.filename))
local ok, err = denoise_one(ctx, img, TILE_SIZE, job,
(i - 1) * span, span)
if not ok then
dt.print(string.format("[%d/%d] %s: %s",
i, #images, img.filename, tostring(err)))
dt.print_log(string.format("[ai_raw_denoise] [%d/%d] %s FAILED: %s",
i, #images, img.filename, tostring(err)))
else
done = done + 1
end
end
ctx:close()
job.valid = false
local elapsed = os.time() - t_start
dt.print(string.format("raw denoise complete: %d image%s",
#images, #images == 1 and "" or "s"))
dt.print_log(string.format(
"[ai_raw_denoise] finished: %d/%d in %ds",
done, #images, elapsed))
end
-- script_manager integration: called when the user disables the script.
-- darktable can't remove a registered lib from the UI at runtime, so we
-- only stop any in-flight work; the panel itself remains until restart.
local function destroy()
-- nothing persistent to clean up: each click runs its own job and
-- closes its own model context
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.register_lib(
MODULE_NAME, -- plugin id
"AI raw denoise", -- displayed name
true, -- expandable
false, -- no reset button
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 100}},
dt.new_widget("box") {
orientation = "vertical",
dt.new_widget("button") {
label = "denoise selected",
clicked_callback = process_raw_denoise,
}
},
nil, nil
)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- 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 = "AI raw denoise",
purpose = "tile-based AI raw denoise using the darktable.ai Lua API",
author = "Andrii Ryzhkov",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/ai_raw_denoise"
}
script_data.destroy = destroy
return script_data

View File

@@ -29,8 +29,7 @@
by hovering over the image and pressing the key combination. by hovering over the image and pressing the key combination.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
* ufraw-batch - https://ufraw.sourceforge.net * exiftool - https://exiftool.org
MacOS - install with homebrew
USAGE USAGE
* require this script from your main lua file * require this script from your main lua file
@@ -44,6 +43,7 @@
* Send to Bill Ferguson, wpferguson@gmail.com * Send to Bill Ferguson, wpferguson@gmail.com
CHANGES CHANGES
20251205 - replaced ufraw-batch with exiftool
]] ]]
--[[ --[[
@@ -139,6 +139,36 @@ local function copy_image_attributes(from, to, ...)
end end
end end
local function check_file_size(img_name)
local result = 0
if dt.configuration.running_os == "windows" then
local path = df.get_path(img_name)
local fname = df.get_filename(img_name)
local p = io.open("forfiles /P " .. path .. " /M " .. fname .. " /C \"cmd /c echo $fsize\"")
if p then
for line in p:lines() do
if line:length() > 5 then
result = tonumber(line)
end
end
p:close()
end
else
local p = io.popen("ls -s " .. img_name)
if p then
local line = p:read("*a")
result = tonumber(du.split(line, " ")[1])
p:close()
end
end
return result
end
--[[ --[[
The main function called from the button or the shortcut. It takes one or more raw files, passed The main function called from the button or the shortcut. It takes one or more raw files, passed
in a table and extracts the embedded jpeg images in a table and extracts the embedded jpeg images
@@ -152,11 +182,12 @@ local function extract_embedded_jpeg(images)
in the user's path. When it finds the executable, it returns the command to run it. in the user's path. When it finds the executable, it returns the command to run it.
]] ]]
local ufraw_executable = df.check_if_bin_exists("ufraw-batch") local exiftool_executable = df.check_if_bin_exists("exiftool")
if ufraw_executable then if exiftool_executable then
for _, image in ipairs(images) do for _, image in ipairs(images) do
if image.is_raw then if image.is_raw then
local img_file = du.join({image.path, image.filename}, "/") local img_file = tostring(image)
local jpg_img_file = string.gsub(img_file, "%....$", ".jpg")
--[[ --[[
dtsys.external_command() is operating system aware and formats the command as necessary. dtsys.external_command() is operating system aware and formats the command as necessary.
@@ -164,8 +195,12 @@ local function extract_embedded_jpeg(images)
operating system aware and uses the corresponding quotes. operating system aware and uses the corresponding quotes.
]] ]]
if dtsys.external_command(ufraw_executable .. " --silent --embedded-image " .. df.sanitize_filename(img_file)) then if dtsys.external_command(exiftool_executable .. " -b -JpgFromRaw " .. df.sanitize_filename(img_file) .. " > " .. df.sanitize_filename(jpg_img_file)) then
local jpg_img_file = df.chop_filetype(img_file) .. ".embedded.jpg" dt.print_log("created jpg from raw")
if check_file_size(df.sanitize_filename(jpg_img_file)) == 0 then
dt.print_log("using preview")
dtsys.external_command(exiftool_executable .. " -b -PreviewImage " .. df.sanitize_filename(img_file) .. " > " .. df.sanitize_filename(jpg_img_file))
end
dt.print_log("jpg_img_file set to ", jpg_img_file) -- print a debugging message dt.print_log("jpg_img_file set to ", jpg_img_file) -- print a debugging message
local myimage = dt.database.import(jpg_img_file) local myimage = dt.database.import(jpg_img_file)
myimage:group_with(image.group_leader) myimage:group_with(image.group_leader)
@@ -188,8 +223,8 @@ local function extract_embedded_jpeg(images)
end end
end end
else else
dt.print_error("ufraw-batch not found. Exiting...") -- print debugging error message dt.print_error("exiftool not found. Exiting...") -- print debugging error message
dt.print("ufraw-batch not found, exiting...") -- print the error to the screen dt.print("exiftool not found, exiting...") -- print the error to the screen
end end
end end

View File

@@ -51,6 +51,10 @@ midi:C0=iop/channelmixerrgb;focus
midi:C#0=iop/colorequal;focus midi:C#0=iop/colorequal;focus
midi:D0=iop/colorequal/page;previous midi:D0=iop/colorequal/page;previous
midi:D#0=iop/colorequal/page;next midi:D#0=iop/colorequal/page;next
USAGE:
To add a new module, simply add an entry to MODULE_PROFILES below.
Get action paths from darktable's shortcut interface.
]] ]]
local dt = require "darktable" local dt = require "darktable"
@@ -59,150 +63,283 @@ local du = require "lib/dtutils"
du.check_min_api_version("9.2.0", "x-touch") du.check_min_api_version("9.2.0", "x-touch")
local gettext = dt.gettext.gettext local gettext = dt.gettext.gettext
local function _(msgid) local function _(msgid)
return gettext(msgid) return gettext(msgid)
end end
-- return data structure for script_manager -- Constants
local NUM_KNOBS = 8
local NAN = 0/0
local darkroom_view = dt.gui.views.darkroom
local script_data = {} -- ============================================================================
-- USER CONFIGURATION
-- ============================================================================
-- Module Profiles: Define behavior when each module has focus
-- Structure: [module_name] = { knob1_action, knob2_action, ... }
local MODULE_PROFILES = {
-- Color Zones: Control individual color channels
colorzones = {
{"iop/colorzones/graph", "red"},
{"iop/colorzones/graph", "orange"},
{"iop/colorzones/graph", "yellow"},
{"iop/colorzones/graph", "green"},
{"iop/colorzones/graph", "aqua"},
{"iop/colorzones/graph", "blue"},
{"iop/colorzones/graph", "purple"},
{"iop/colorzones/graph", "magenta"}
},
-- Color Equalizer: Control color channels
colorequal = {
{"iop/colorequal/graph", "red"},
{"iop/colorequal/graph", "orange"},
{"iop/colorequal/graph", "yellow"},
{"iop/colorequal/graph", "green"},
{"iop/colorequal/graph", "cyan"},
{"iop/colorequal/graph", "blue"},
{"iop/colorequal/graph", "lavender"},
{"iop/colorequal/graph", "magenta"}
},
-- Sigmoid: Control primary color adjustments
sigmoid = {
{"iop/sigmoid/primaries/red attenuation"},
{"iop/sigmoid/primaries/red rotation"},
{"iop/sigmoid/primaries/green attenuation"},
{"iop/sigmoid/primaries/green rotation"},
{"iop/sigmoid/primaries/blue attenuation"},
{"iop/sigmoid/primaries/blue rotation"},
{"iop/sigmoid/primaries/recover purity"}
},
-- Color Calibration (Primaries)
primaries = {
{"iop/primaries/red hue"},
{"iop/primaries/red purity"},
{"iop/primaries/green hue"},
{"iop/primaries/green purity"},
{"iop/primaries/blue hue"},
{"iop/primaries/blue purity"},
{"iop/primaries/tint hue"},
{"iop/primaries/tint purity"}
},
-- AgX
agx = {
{"iop/agx/white relative exposure"},
{"iop/agx/black relative exposure"},
{"iop/agx/curve/pivot relative exposure"},
{"iop/agx/curve/pivot target output"},
{"iop/agx/curve/contrast"},
{"iop/agx/curve/shoulder power"},
{"iop/agx/curve/toe power"},
{"iop/agx/curve/curve y gamma"}
},
-- Tone Equalizer: Control EV bands
-- For 8 knobs, using offsets -8 to get bands
toneequal = {
{"iop/toneequal/simple/-8 EV"},
{"iop/toneequal/simple/-7 EV"},
{"iop/toneequal/simple/-6 EV"},
{"iop/toneequal/simple/-5 EV"},
{"iop/toneequal/simple/-4 EV"},
{"iop/toneequal/simple/-3 EV"},
{"iop/toneequal/simple/-2 EV"},
{"iop/toneequal/simple/-1 EV"}
},
-- Color Balance RGB: Selected controls
colorbalancergb = {
nil, -- Knob 1: unused --> default
{"iop/colorbalancergb/global vibrance"},
{"iop/colorbalancergb/contrast"},
{"iop/colorbalancergb/global chroma"},
{"iop/colorbalancergb/global saturation"},
{"iop/colorbalancergb/global brilliance"},
nil, -- Knob 7: --> default
nil -- Knob 8: --> default
},
-- Channel Mixer RGB (Color Calibration): Tab control and sliders
channelmixerrgb = {
nil, -- Knob 1-4: --> default
nil,
nil,
nil,
{"iop/channelmixerrgb/page", "CAT", "tab"}, -- Knob 5: Tab selector
{"iop/focus/sliders", "1st"}, -- Knob 6: First slider
{"iop/focus/sliders", "2nd"}, -- Knob 7: Second slider
{"iop/focus/sliders", "3rd"} -- Knob 8: Third slider
},
script_data.metadata = {
name = _("x-touch"),
purpose = _("example of how to control an x-touch midi device"),
author = "Diederik ter Rahe",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/x-touch"
} }
script_data.destroy = nil -- function to destory the script local DEFAULT_MAPPINGS = {
script_data.destroy_method = nil -- set to hide for libs since we can't destroy them commpletely yet {"iop/exposure/exposure"},
script_data.restart = nil -- how to restart the (lib) script after it's been hidden - i.e. make it visible again {"iop/filmicrgb/white relative exposure"},
script_data.show = nil -- only required for libs since the destroy_method only hides them {"iop/filmicrgb/black relative exposure"},
{"iop/filmicrgb/contrast"},
{"iop/crop/left"},
{"iop/crop/right"},
{"iop/crop/top"},
{"iop/crop/bottom"}
}
-- Mask properties: Always checked first, regardless of module focus
local MASK_PROPERTIES = {
{"lib/masks/properties/opacity"},
{"lib/masks/properties/size"},
{"lib/masks/properties/feather"},
{"lib/masks/properties/hardness"},
{"lib/masks/properties/rotation"},
{"lib/masks/properties/curvature"},
{"lib/masks/properties/compression"}
}
-- set up 8 mimic sliders with the same function -- Priority order for checking module focus (most commonly used first)
for k = 1,8 do local MODULE_CHECK_ORDER = {
dt.gui.mimic("slider", "knob ".. k, "toneequal",
function(action, element, effect, size) "colorequal",
-- take the number from the mimic name "colorbalancergb",
local k = tonumber(action:sub(-1)) "agx",
"sigmoid",
"primaries",
"channelmixerrgb",
"colorzones"
}
-- only operate in darkroom; return NAN otherwise -- ============================================================================
if dt.gui.current_view() ~= dt.gui.views.darkroom then -- CORE LOGIC - No need to edit below unless changing architecture
return 0/0 -- ============================================================================
end
local maskval = 0/0 -- Helper functions
if k < 8 then local function is_in_darkroom()
-- first try if the mask slider at that position is active return dt.gui.current_view() == darkroom_view
local s = { "opacity", end
"size",
"feather",
"hardness",
"rotation",
"curvature",
"compression" }
maskval = dt.gui.action("lib/masks/properties/" .. s[k],
element, effect, size)
end
-- if a value different from NAN is returned, the slider was active
if maskval == maskval then
return maskval
-- try if colorzones module is focused; if so select element of graph local function is_valid(value)
elseif dt.gui.action("iop/colorzones", "focus") ~= 0 then return value == value -- NaN check
which = "iop/colorzones/graph" end
local e = { "red",
"orange",
"yellow",
"green",
"aqua",
"blue",
"purple",
"magenta" }
element = e[k]
-- try if colorequalizer module is focused; if so select element of graph local function translate_tab_effect(effect)
elseif dt.gui.action("iop/colorequal", "focus") ~= 0 then if effect == "up" then
local e = { "red", return "next"
"orange", elseif effect == "down" then
"yellow", return "previous"
"green", else
"cyan", return "activate"
"blue", end
"lavender", end
"magenta" }
which = "iop/colorequal/graph"
element = e[k]
-- if the sigmoid rgb primaries is focused, -- Execute an action configuration
-- check sliders local function execute_action(action_config, element, effect, size)
elseif dt.gui.action("iop/sigmoid", "focus") ~= 0 and k <8 then if not action_config then
local e = { "red attenuation", "red rotation", "green attenuation", "green rotation", "blue attenuation", "blue rotation", "recover purity" } return nil
which = "iop/sigmoid/primaries/"..e[k] end
-- if the rgb primaries is focused, local path = action_config[1]
-- check sliders local elem = action_config[2] or element
elseif dt.gui.action("iop/primaries", "focus") ~= 0 and k >=1 then local action_type = action_config[3]
local e = { "red hue", "red purity", "green hue", "green purity", "blue hue", "blue purity", "tint hue", "tint purity" }
which = "iop/primaries/" ..e[k]
-- if the tone equalizer is focused, -- Handle special action types
-- select one of the sliders in the "simple" tab if action_type == "tab" then
elseif dt.gui.action("iop/toneequal", "focus") ~= 0 then effect = translate_tab_effect(effect)
which ="iop/toneequal/simple/"..(k-9).." EV" end
-- if color calibration is focused, return dt.gui.action(path, elem, effect, size)
-- the last 4 knobs are sent there end
elseif dt.gui.action("iop/channelmixerrgb", "focus") ~= 0
and k >= 5 then -- Try mask properties first
-- knob 5 selects the active tab; pressing it resets to CAT local function try_mask_properties(knob_idx, element, effect, size)
if k == 5 then if knob_idx >= NUM_KNOBS then
which = "iop/channelmixerrgb/page" return NAN
element = "CAT" end
-- since the tab action is not a slider,
-- the effects need to be translated local mask_action = MASK_PROPERTIES[knob_idx]
if effect == "up" then effect = "next" if not mask_action then
elseif effect == "down" then effect = "previous" return NAN
else effect = "activate" end
local value = execute_action(mask_action, element, effect, size)
return value
end
-- Main knob handler
local function handle_knob_action(knob_idx, element, effect, size)
-- Early exit for non-darkroom view
if not is_in_darkroom() then
return NAN
end
-- Try mask properties first (no focus check needed)
local mask_value = try_mask_properties(knob_idx, element, effect, size)
if is_valid(mask_value) then
return mask_value
end
-- Check focused modules in priority order
for _, module_name in ipairs(MODULE_CHECK_ORDER) do
local focus_status = dt.gui.action("iop/" .. module_name, "focus")
if focus_status ~= 0 then
-- Module has focus, get its profile
local profile = MODULE_PROFILES[module_name]
if profile then
local action_config = profile[knob_idx]
-- If profile has explicit mapping for this knob, use it
if action_config then
local result = execute_action(action_config, element, effect, size)
if result then
return result
end end
else else
-- knobs 6, 7 and 8 are for the three color sliders on each tab -- Profile exists but this knob is nil -> use default
which = "iop/focus/sliders" -- This avoids checking other modules when we know the focused one
local e = { "1st", local default_action = DEFAULT_MAPPINGS[knob_idx]
"2nd", return execute_action(default_action, element, effect, size) or NAN
"3rd" }
element = e[k - 5]
end end
-- the 4th knob is contrast;
-- either colorbalance if it is focused, or filmic
elseif dt.gui.action("iop/colorbalancergb", "focus") ~= 0
and k == 4 then
which = "iop/colorbalancergb/contrast"
-- in all other cases use a default selection
else
local s = { "iop/exposure/exposure",
"iop/filmicrgb/white relative exposure",
"iop/filmicrgb/black relative exposure",
"iop/filmicrgb/contrast",
"iop/crop/left",
"iop/crop/right",
"iop/crop/top",
"iop/crop/bottom" }
which = s[k]
end end
-- now pass the element/effect/size to the selected slider -- If no profile exists for this module, continue checking others
return dt.gui.action(which, element, effect, size) end
end) end
-- No module has focus or no profile matched, fall back to default
local default_action = DEFAULT_MAPPINGS[knob_idx]
return execute_action(default_action, element, effect, size) or NAN
end end
local function destroy() -- Setup mimic sliders
-- nothing to destroy local function setup_knobs()
for k = 1, NUM_KNOBS do
dt.gui.mimic("slider", "knob " .. k,
function(action, element, effect, size)
local knob_idx = tonumber(action:sub(-1))
return handle_knob_action(knob_idx, element, effect, size)
end)
end
end end
script_data.destroy = destroy -- Script metadata
local script_data = {
metadata = {
name = _("x-touch"),
purpose = _("data-driven x-touch midi controller configuration"),
author = "Martin Straeten",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/x-touch"
},
destroy = function() end,
destroy_method = nil,
restart = nil,
show = nil
}
-- Initialize
setup_knobs()
return script_data return script_data

View File

@@ -543,10 +543,13 @@ local PLACEHOLDERS = {"ROLL.NAME",
"FILE.NAME", "FILE.NAME",
"FILE.EXTENSION", "FILE.EXTENSION",
"ID", "ID",
"IMAGE.ID",
"IMAGE.ID.NEXT",
"VERSION", "VERSION",
"VERSION.IF.MULTI", "VERSION.IF.MULTI",
"VERSION.NAME", "VERSION.NAME",
"DARKTABLE.VERSION", "DARKTABLE.VERSION",
"Xmp.darktable.version",
"DARKTABLE.NAME", -- Not Implemented "DARKTABLE.NAME", -- Not Implemented
"SEQUENCE", "SEQUENCE",
"WIDTH.SENSOR", "WIDTH.SENSOR",
@@ -612,10 +615,15 @@ local PLACEHOLDERS = {"ROLL.NAME",
"LABELS", "LABELS",
"LABELS.ICONS", -- Not Implemented "LABELS.ICONS", -- Not Implemented
"TITLE", "TITLE",
"Xmp.dc.title",
"DESCRIPTION", "DESCRIPTION",
"Xmp.dc.description",
"CREATOR", "CREATOR",
"Xmp.dc.creator",
"PUBLISHER", "PUBLISHER",
"Xmp.dc.publisher",
"RIGHTS", "RIGHTS",
"Xmp.dc.rights",
"TAGS", -- Not Implemented "TAGS", -- Not Implemented
"SIDECAR.TXT", -- Not Implemented "SIDECAR.TXT", -- Not Implemented
"FOLDER.PICTURES", "FOLDER.PICTURES",
@@ -652,6 +660,37 @@ local function get_colorlabels(image)
return labels return labels
end end
-- handle the ROLL.NAME[n] case
local function build_rollname_substitution_list(image, variable_string)
local old_log_level = log.log_level()
log.log_level(dtutils_string.log_level)
for match in string.gmatch(variable_string, "%$%(.-%)?%)") do -- grab each complete variable
log.msg(log.info, "match is " .. match)
local var = string.match(match, "%$%((.-%)?)%)") -- strip of the leading $( and trailing )
log.msg(log.info, "var is " .. var)
local element = nil
if string.match(var, "ROLL.NAME%[") then
element = string.match(var, "%[(%d)%]")
local path = du.split(image.film.path, dt.configuration.running_os ~= "windows" and "/" or "\\")
element = tonumber(element) + 1 -- add one because c arrays are 0 based and lua are 1 based
if element > #path then
log.msg(log.warn, "ROLL.NAME element requested was more than number of elements in film roll")
substitutes[var] = ""
else
substitutes[var] = path[(#path + 1) - element]
end
end
end
end
-- find the $CATEGORYn and $CATEGORY[n,m] requests and add them to the substitute list -- find the $CATEGORYn and $CATEGORY[n,m] requests and add them to the substitute list
local function build_category_substitution_list(image, variable_string) local function build_category_substitution_list(image, variable_string)
@@ -734,33 +773,29 @@ function dtutils_string.build_substitute_list(image, sequence, variable_string,
local labels = get_colorlabels(image) local labels = get_colorlabels(image)
local datetime_taken = "" local emon_long, emon_short
local eyear, emon, eday, ehour, emin, esec, emsec
local use_millisecs = false local use_millisecs = false
if dt.preferences.read("darktable", "lighttable/ui/milliseconds", "bool") and is_api_9_1 then if dt.preferences.read("darktable", "lighttable/ui/milliseconds", "bool") and is_api_9_1 then
use_millisecs = true use_millisecs = true
end end
if image.exif_datetime_taken and image.exif_datetime_taken ~= "" then if image.exif_datetime_taken and image.exif_datetime_taken ~= "" then
datetime_taken = image.exif_datetime_taken emon_long = os.date("%B", exiftime2systime(image.exif_datetime_taken))
else emon_short = os.date("%b", exiftime2systime(image.exif_datetime_taken))
if use_millisecs then if use_millisecs then
datetime_taken = "0000:00:00 00:00:00.0" eyear, emon, eday, ehour, emin, esec, emsec =
string.match(image.exif_datetime_taken, "(%d+):(%d+):(%d+) (%d+):(%d+):(%d+)%.(%d+)$")
else else
datetime_taken = "0000:00:00 00:00:00" emsec = "0"
eyear, emon, eday, ehour, emin, esec =
string.match(image.exif_datetime_taken, "(%d+):(%d+):(%d+) (%d+):(%d+):(%d+)$")
end end
end
local eyear, emon, eday, ehour, emin, esec, emsec
if use_millisecs then
eyear, emon, eday, ehour, emin, esec, emsec =
string.match(datetime_taken, "(%d+):(%d+):(%d+) (%d+):(%d+):(%d+)%.(%d+)$")
else else
emsec = "0" log.msg(log.warn, "no capture date and time found in EXIF data")
eyear, emon, eday, ehour, emin, esec = emon_long, emon_short, eyear, emon, eday, ehour, emin, esec, emsec = "", "", "", "", "", "", "", "", ""
string.match(datetime_taken, "(%d+):(%d+):(%d+) (%d+):(%d+):(%d+)$")
end end
local version_multi = #image:get_group_members() > 1 and image.duplicate_index or "" local version_multi = #image:get_group_members() > 1 and image.duplicate_index or ""
local replacements = {dtutils_string.get_basename(image.film.path),-- ROLL.NAME local replacements = {dtutils_string.get_basename(image.film.path),-- ROLL.NAME
@@ -768,10 +803,13 @@ function dtutils_string.build_substitute_list(image, sequence, variable_string,
dtutils_string.get_basename(image.filename),-- FILE.NAME dtutils_string.get_basename(image.filename),-- FILE.NAME
dtutils_string.get_filetype(image.filename),-- FILE.EXTENSION dtutils_string.get_filetype(image.filename),-- FILE.EXTENSION
image.id, -- ID image.id, -- ID
image.id, -- IMAGE.ID
dt.database[#dt.database].id + 1, -- IMAGE.ID.NEXT
image.duplicate_index, -- VERSION image.duplicate_index, -- VERSION
version_multi, -- VERSION.IF_MULTI version_multi, -- VERSION.IF_MULTI
image.version_name and image.version_name or "", -- VERSION.NAME image.version_name and image.version_name or "", -- VERSION.NAME
dt.configuration.version, -- DARKTABLE.VERSION dt.configuration.version, -- DARKTABLE.VERSION
dt.configuration.version, -- Xmp.darktable.version
"", -- DARKTABLE.NAME "", -- DARKTABLE.NAME
string.format("%04d", sequence), -- SEQUENCE string.format("%04d", sequence), -- SEQUENCE
image.width, -- WIDTH.SENSOR image.width, -- WIDTH.SENSOR
@@ -796,10 +834,10 @@ function dtutils_string.build_substitute_list(image, sequence, variable_string,
string.format("%02d", datetime.sec), -- SECOND string.format("%02d", datetime.sec), -- SECOND
0, -- MSEC 0, -- MSEC
eyear, -- EXIF.YEAR eyear, -- EXIF.YEAR
string.sub(eyear, 3), -- EXIF.YEAR.SHORT (eyear ~= nil) and string.sub(eyear, 3) or "", -- EXIF.YEAR.SHORT
emon, -- EXIF.MONTH emon, -- EXIF.MONTH
os.date("%B", exiftime2systime(datetime_taken)), -- EXIF.MONTH.LONG emon_long, -- EXIF.MONTH.LONG
os.date("%b", exiftime2systime(datetime_taken)), -- EXIF.MONTH.SHORT emon_short, -- EXIF.MONTH.SHORT
eday, -- EXIF.DAY eday, -- EXIF.DAY
ehour, -- EXIF.HOUR ehour, -- EXIF.HOUR
"", -- EXIF.HOUR.AMPM "", -- EXIF.HOUR.AMPM
@@ -837,17 +875,22 @@ function dtutils_string.build_substitute_list(image, sequence, variable_string,
labels, -- LABELS labels, -- LABELS
"", -- LABELS.ICONS - wont be implemented "", -- LABELS.ICONS - wont be implemented
image.title and image.title or "", -- TITLE image.title and image.title or "", -- TITLE
image.title and image.title or "", -- Xmp.dc.title
image.description and image.description or "", -- DESCRIPTION image.description and image.description or "", -- DESCRIPTION
image.description and image.description or "", -- Xmp.dc.description
image.creator and image.creator or "", -- CREATOR image.creator and image.creator or "", -- CREATOR
image.creator and image.creator or "", -- Xmp.dc.creator
image.publisher and image.publisher or "", -- PUBLISHER image.publisher and image.publisher or "", -- PUBLISHER
image.publisher and image.publisher or "", -- Xmp.dc.publisher
image.rights and image.rights or "", -- RIGHTS image.rights and image.rights or "", -- RIGHTS
image.rights and image.rights or "", -- Xmp.dc.rights
"", -- TAGS - wont be implemented "", -- TAGS - wont be implemented
"", -- SIDECAR.TXT - wont be implemented "", -- SIDECAR.TXT - wont be implemented
pictures_folder, -- FOLDER.PICTURES pictures_folder, -- FOLDER.PICTURES
home_folder, -- FOLDER.HOME home_folder, -- FOLDER.HOME
desktop_folder, -- FOLDER.DESKTOP desktop_folder, -- FOLDER.DESKTOP
"", -- OPENCL.ACTIVATED - wont be implemented "", -- OPENCL.ACTIVATED - wont be implemented
user_name, -- USERNAME (user_name ~= nil) and user_name or "",-- USERNAME
"", -- NL - wont be implemented "", -- NL - wont be implemented
"" -- JOBCODE - wont be implemented "" -- JOBCODE - wont be implemented
} }
@@ -862,6 +905,7 @@ function dtutils_string.build_substitute_list(image, sequence, variable_string,
-- do category substitutions separately -- do category substitutions separately
build_category_substitution_list(image, variable_string) build_category_substitution_list(image, variable_string)
build_rollname_substitution_list(image, variable_string)
log.log_level(old_log_level) log.log_level(old_log_level)
end end
@@ -889,10 +933,13 @@ function dtutils_string.get_substitution_tooltip()
_("$(FILE.NAME) - basename of the input image"), _("$(FILE.NAME) - basename of the input image"),
_("$(FILE.EXTENSION) - extension of the input image"), _("$(FILE.EXTENSION) - extension of the input image"),
_("$(ID) - image ID"), _("$(ID) - image ID"),
_("$(IMAGE.ID) - image ID"),
_("$(IMAGE.ID.NEXT) - next image ID to be assigned on import"),
_("$(VERSION) - duplicate version"), _("$(VERSION) - duplicate version"),
_("$(VERSION.IF_MULTI) - same as $(VERSION) but null string if only one version exists"), _("$(VERSION.IF_MULTI) - same as $(VERSION) but null string if only one version exists"),
_("$(VERSION.NAME) - version name from metadata"), _("$(VERSION.NAME) - version name from metadata"),
_("$(DARKTABLE.VERSION) - current darktable version"), _("$(DARKTABLE.VERSION) - current darktable version"),
_("$(Xmp.darktable.version) - current darktable version"),
-- _("$(DARKTABLE.NAME) - darktable name"), -- not implemented -- _("$(DARKTABLE.NAME) - darktable name"), -- not implemented
_("$(SEQUENCE[n,m]) - sequence number, n: number of digits, m: start number"), _("$(SEQUENCE[n,m]) - sequence number, n: number of digits, m: start number"),
_("$(WIDTH.SENSOR) - image sensor width"), _("$(WIDTH.SENSOR) - image sensor width"),
@@ -957,10 +1004,15 @@ function dtutils_string.get_substitution_tooltip()
_("$(LABELS) - color labels as text"), _("$(LABELS) - color labels as text"),
-- _("$(LABELS.ICONS) - color labels as icons"),-- not implemented -- _("$(LABELS.ICONS) - color labels as icons"),-- not implemented
_("$(TITLE) - title from metadata"), _("$(TITLE) - title from metadata"),
_("$(Xmp.dc.title) - title from metadata"),
_("$(DESCRIPTION) - description from metadata"), _("$(DESCRIPTION) - description from metadata"),
_("$(Xmp.dc.description) - description from metadata"),
_("$(CREATOR) - creator from metadata"), _("$(CREATOR) - creator from metadata"),
_("$(Xmp.dc.creator) - creator from metadata"),
_("$(PUBLISHER) - publisher from metadata"), _("$(PUBLISHER) - publisher from metadata"),
_("$(Xmp.dc.publisher) - publisher from metadata"),
_("$(RIGHTS) - rights from metadata"), _("$(RIGHTS) - rights from metadata"),
_("$(Xmp.dc.rights) - rights from metadata"),
--_("$(TAGS) - tags as set in metadata settings"), --_("$(TAGS) - tags as set in metadata settings"),
_("$(CATEGORY[n,category]) - subtag of level n in hierarchical tags"), _("$(CATEGORY[n,category]) - subtag of level n in hierarchical tags"),
_("$(SIDECAR_TXT) - contents of .txt sidecar file, if present"), _("$(SIDECAR_TXT) - contents of .txt sidecar file, if present"),
@@ -1010,6 +1062,11 @@ local function treat(var_string)
if not ret_val then ret_val = "" end if not ret_val then ret_val = "" end
log.msg(log.info, "ret_val is " .. ret_val) log.msg(log.info, "ret_val is " .. ret_val)
elseif string.match(var_string, "ROLL.NAME%[") then
ret_val = substitutes[var_string]
if not ret_val then ret_val = "" end
log.msg(log.info, "ret_val is " .. ret_val)
elseif string.match(var_string, "SEQUENCE%[") then elseif string.match(var_string, "SEQUENCE%[") then
local width, start = string.match(var_string, "(%d+),(%d+)") local width, start = string.match(var_string, "(%d+),(%d+)")
local seq_val = tonumber(substitutes[var]) local seq_val = tonumber(substitutes[var])
@@ -1243,6 +1300,49 @@ function dtutils_string.substitute(image, sequence, variable_string, username, p
return str return str
end end
dtutils_string.libdoc.functions["format_exposure"] = {
Name = [[format_exposure]],
Synopsis = [[format exif_exposure from dt_lua_image_t to an exposure time]],
Usage = [[local ds = require "lib/dtutils.string"
ds.format_exposure(image.exif_exposure)
image.exif_exposure - dt_lua_image_t exif exposure field
Description = [[format_exposure takes the EXIF exposure field and formats it into an exposure string]],
Return_Value = [[result - string - the EXIF exposure formatted as an exposure string]],
Limitations = [[]],
Example = [[]],
See_Also = [[https://docs.darktable.org/usermanual/4.6/en/special-topics/variables/]],
Reference = [[]],
License = [[]],
Copyright = [[]],
}
local function _nearbyintf(v)
return math.floor(v + .5)
end
function dtutils_string.format_exposure(exif_exposure)
result = nil
if exif_exposure > 1.0 then
if _nearbyintf(exif_exposure) == exif_exposure then
result = string.format("%.0f″", exif_exposure)
else
result = string.format("%.1f″", exif_exposure)
end
-- catch everything below 0.3 seconds
elseif exif_exposure < 0.2 then
result = string.format("1/%.0f", 1.0 / exif_exposure)
-- catch 1/2, 1/3, ...
elseif _nearbyintf(1.0 / exif_exposure) == (1.0 / exif_exposure) then
result = string.format("1/%.0f", 1.0 / exif_exposure)
-- catch 1/1.3, 1/1.6, ...
elseif (10 * _nearbyintf(10.0 / exif_exposure)) == _nearbyintf(100.0 / exif_exposure) then
result = string.format("1/%.1f", 1.0 / exif_exposure)
else
result = string.format("%.1f″", exif_exposure)
end
return result
end
return dtutils_string return dtutils_string

View File

@@ -91,8 +91,10 @@ function dtutils_system.windows_command(command)
file:write("chcp 65001>nul\n") -- change the encoding of the terminal to handle non-english characters in path file:write("chcp 65001>nul\n") -- change the encoding of the terminal to handle non-english characters in path
file:write("\n") file:write("\n")
file:write(command .. "\n") file:write(command .. "\n")
file:write("set COMMAND_RC=%ERRORLEVEL%")
file:write("\n") file:write("\n")
file:write("chcp %cp%>nul\n") file:write("chcp %cp%>nul\n")
file:write("exit /b %COMMAND_RC%") -- otherwise, when the real command fails, script will still return "good" error code of chcp
file:close() file:close()
result = dt.control.execute(fname) result = dt.control.execute(fname)

View File

@@ -34,12 +34,17 @@
Bill Ferguson <wpferguson@gmail.com> Bill Ferguson <wpferguson@gmail.com>
CHANGES CHANGES
20251202 - fixed pattern matching bugs affecting Canon EOS R? series
styles and styles being applied twice if images are reimported
Added more debugging statements to pattern matching functions
]] ]]
local dt = require "darktable" local dt = require "darktable"
local du = require "lib/dtutils" local du = require "lib/dtutils"
-- local df = require "lib/dtutils.file" -- local df = require "lib/dtutils.file"
-- local ds = require "lib/dtutils.string" local ds = require "lib/dtutils.string"
-- local dtsys = require "lib/dtutils.system" -- local dtsys = require "lib/dtutils.system"
local log = require "lib/dtutils.log" local log = require "lib/dtutils.log"
-- local debug = require "darktable.debug" -- local debug = require "darktable.debug"
@@ -50,7 +55,7 @@ local log = require "lib/dtutils.log"
-- - - - - - - - - - - - - - - - - - - - - - - - -- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "apply_camera_style" local MODULE <const> = "apply_camera_style"
local DEFAULT_LOG_LEVEL <const> = log.info local DEFAULT_LOG_LEVEL <const> = log.warn
local TMP_DIR <const> = dt.configuration.tmp_dir local TMP_DIR <const> = dt.configuration.tmp_dir
local STYLE_PREFIX <const> = "_l10n_darktable|_l10n_camera styles|" local STYLE_PREFIX <const> = "_l10n_darktable|_l10n_camera styles|"
local MAKER = 3 local MAKER = 3
@@ -154,24 +159,32 @@ local function process_pattern(pattern)
local log_level = set_log_level(acs.log_level) local log_level = set_log_level(acs.log_level)
log.msg(log.debug, "input pattern is " .. pattern)
pattern = string.lower(pattern) pattern = string.lower(pattern)
log.msg(log.debug, "lower case pattern is " .. pattern)
-- strip off series -- strip off series
pattern = string.gsub(pattern, " series$", "?") pattern = string.gsub(pattern, " series$", "?")
log.msg(log.debug, "series stripped off pattern is " .. pattern)
-- match a character -- match a character
if string.match(pattern, "?$") then if string.match(pattern, "r%?%?$") then
-- handle EOS R case -- handle EOS R case
pattern = string.gsub(pattern, "?", ".?") pattern = string.gsub(pattern, "%?%?", ".?")
log.msg(log.debug, "matched EOS R pattern is " .. pattern)
else else
pattern = string.gsub(pattern, "?", ".") pattern = string.gsub(pattern, "?", ".")
log.msg(log.debug, "didn't match EOS R pattern is " .. pattern)
end end
pattern = string.gsub(pattern, " ", " ?")
-- escape dashes -- escape dashes
pattern = string.gsub(pattern, "%-", "%%-") pattern = string.gsub(pattern, "%-", "%%-")
log.msg(log.debug, "escape dashes conditional pattern is " .. pattern)
-- make spaces optional -- make spaces optional
pattern = string.gsub(pattern, " ", " ?") pattern = string.gsub(pattern, " ", " ?")
-- until we end up with a set, I'll defer set processing, i.e. [...] -- until we end up with a set, I'll defer set processing, i.e. [...]
--log.msg(log.debug, "make spaces conditional again? pattern is " .. pattern)
-- anchor the pattern to ensure we don't short match -- anchor the pattern to ensure we don't short match
pattern = "^" .. pattern .. "$" pattern = "^" .. pattern .. "$"
log.msg(log.debug, "anchored pattern is " .. pattern)
restore_log_level(log_level) restore_log_level(log_level)
@@ -326,9 +339,11 @@ local function has_style_tag(image, tag_name)
log.msg(log.debug, "looking for tag " .. tag_name) log.msg(log.debug, "looking for tag " .. tag_name)
for _, tag in ipairs(image:get_tags()) do for _, tag in ipairs(image:get_tags()) do
log.msg(log.debug, "checking against " .. tag.name) log.msg(log.debug, "got attached tag " .. tag.name)
if tag.name == tag_name then local cleaned_tag_name = string.gsub(tag_name, "_l10n_", "")
log.msg(log.debug, "matched tag " .. tag_name) log.msg(log.debug, "checking against " .. cleaned_tag_name)
if ds.sanitize_lua(tag.name) == cleaned_tag_name then
log.msg(log.debug, "matched tag " .. cleaned_tag_name)
result = true result = true
end end
end end
@@ -358,7 +373,7 @@ local function mangle_model(model)
model = string.gsub(model, "eos 1200d", "eos rebel t5") model = string.gsub(model, "eos 1200d", "eos rebel t5")
model = string.gsub(model, "eos 1300d", "eos rebel t6") model = string.gsub(model, "eos 1300d", "eos rebel t6")
model = string.gsub(model, "eos 2000d", "eos rebel t7") model = string.gsub(model, "eos 2000d", "eos rebel t7")
log.msg(log.debug, "mandle model returning " .. model) log.msg(log.debug, "mangle model returning " .. model)
end end
restore_log_level(log_level) restore_log_level(log_level)
@@ -389,14 +404,15 @@ local function apply_style_to_images(images)
if acs.styles[maker] then if acs.styles[maker] then
local no_match = true local no_match = true
for i, pattern in ipairs(acs.styles[maker].patterns) do for i, pattern in ipairs(acs.styles[maker].patterns) do
log.msg(log.debug, "pattern is " .. pattern)
if string.match(model, pattern) or if string.match(model, pattern) or
(i == #acs.styles[maker].patterns and string.match(pattern, "generic")) then (i == #acs.styles[maker].patterns and string.match(pattern, "generic")) then
local tag_name = "darktable|style|" .. acs.styles[maker].styles[i].name local tag_name = "darktable|style|" .. acs.styles[maker].styles[i].name
if not has_style_tag(image, tag_name) then if not has_style_tag(image, tag_name) then
image:apply_style(acs.styles[maker].styles[i]) image:apply_style(acs.styles[maker].styles[i])
no_match = false
log.msg(log.info, "applied style " .. acs.styles[maker].styles[i].name .. " to " .. image.filename) log.msg(log.info, "applied style " .. acs.styles[maker].styles[i].name .. " to " .. image.filename)
end end
no_match = false
log.log_level(loglevel) log.log_level(loglevel)
break break
end end

View File

@@ -0,0 +1,288 @@
--[[
auto_straighten.lua - straighten an image in darkroom using image metadata
Copyright (C) 2025 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/>.
]]
--[[
auto_straighten - straighten an image in darkroom using image metadata
auto_straighten reads the roll and pitch angle imformation captured when
an image is captured. A correction is computed and applied to correct the
roll to the nearest vertical or horizontal axis using the rotate and
perspective module.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
exiftool - https://exiftool.org
USAGE
* add it sowewhere in your scripts directory
* ensure exiftool is installed and accessible
* enable it using script manager
BUGS, COMMENTS, SUGGESTIONS
Bill Ferguson <wpferguson@gmail.com>
CHANGES
LIMITATIONS
* Roll and pitch angles are taken when an image is captured. If you shoot
in burst mode, the roll and pitch angles of the first image are used in
the rest of the burst (at least for Canon).
* If you are running multiple scripts that trigger on an image being opened
in darkroom then the auto crop may not work. There is a shortcut included
that can have a key sequence assigned so that the auto crop will be reapplied.
* Currently oply roll angle is corrected
LIEENSE
GPL V2
]]
local dt = require "darktable"
local du = require "lib/dtutils"
-- local da = require "lib/dtutils.action"
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"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "auto_straighten"
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 ";"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- 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_straighten"), -- visible name of script
purpose = _("straighten an image in darkroom using image metadata"), -- 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/official/auto_straighten/" -- 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_straighten = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
auto_straighten.pixelpipe_busy = false
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.preferences.register(MODULE, "crop_area", "enum",
_("automatic_cropping"),
_("what automatic cropping to perform"),
_("original format"),
_("off"), _("largest area"), _("original format"))
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local namespace = auto_straighten
local as = auto_straighten
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
local function determine_correction(roll_angle)
--[[
Canon camera roll angle is as foillows
* landscape orientation camera upright = 0
* camera rolled to the left (shutter button up) portrait = -90
* camera inverted landscape = +/- 180
* camera rolled to the right (shutter button down) portrait = 90
rotate and perspective corrections
* rotate the image to the right is a negative correction
* rotate the image left is a positive correction
]]
if roll_angle == 0 or -- landscape
roll_angle == -90 or -- roll left portrait
math.abs(roll_angle) == 180 or -- inverted landscape
roll_angle == 90 then -- roll right portrait
dt.print_log("on axis, no correction required")
return 0
elseif roll_angle > 0 and roll_angle <= 45 then
return (roll_angle * -1.0)
elseif roll_angle > 45 and roll_angle < 90 then
return (90 - roll_angle)
elseif roll_angle > 90 and roll_angle <= 135 then
return (roll_angle - 90) * -1.0
elseif roll_angle > 135 and roll_angle < 180 then
return (180 - roll_angle)
elseif roll_angle > -180 and roll_angle <= -135 then
return (-180 - roll_angle)
elseif roll_angle > -135 and roll_angle <= -90 then
return (math.abs(roll_angle) - 90)
elseif roll_angle > -90 and roll_angle <= -45 then
return (-90 - roll_angle)
else
return roll_angle * -1.0
end
end
local function toggle_auto_crop()
local crop = dt.preferences.read(MODULE, "crop_area", "enum")
dt.gui.action("iop/ashift/automatic cropping", "selection", "item:off", 1.000)
dt.control.sleep(100)
dt.gui.action("iop/ashift/automatic cropping", "selection", "item:" .. crop, 1.000)
end
local function straighten_image(image)
local roll = nil
local pitch = nil
-- Roll Angle 0 is landscape, -90 is rolled to the left in portrait, +- 180 is landscape inverted, 90 is portrait rolled to the right
local crop = dt.preferences.read(MODULE, "crop_area", "enum")
local pipe = io.popen(as.exiftool .. " -RollAngle -PitchAngle " .. image.path .. PS .. image.filename)
if pipe then
for line in pipe:lines() do
local pr, val = string.match(line, "(.).+: (.+)")
if pr == "R" then
local sign = 1.0
dt.print_log("roll angle is " .. val)
log.msg(log.debug, "roll angle is " .. val)
-- check for Nidon and change the sign
if string.match(string.upper(image.exif_maker), "NIKON") then
sign = -1.0
end
roll = determine_correction(tonumber(val) * sign)
dt.print_log("corrected roll angle is " .. roll)
log.msg(log.debug, "corrected roll angle is " .. roll)
elseif pr == "P" then
pitch = tonumber(val)
end
end
pipe:close()
end
if roll and dt.gui.action("iop/ashift", "enable", "", "") == 0.0 then
dt.print_log("straightening image")
dt.gui.action("iop/ashift/rotation", "value", "set", roll)
-- dt.gui.action("iop/ashift/automatic cropping", "selection", "item:" .. crop, 1.000)
-- da.wait_until_pixelpipe_complete()
toggle_auto_crop()
end
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N P R O G R A M
-- - - - - - - - - - - - - - - - - - - - - - - -
as.exiftool = df.check_if_bin_exists("exiftool")
if not as.exiftool then
log.msg(log.error, "exiftool not found, exiting...")
log.msg(log.screen, _("exiftool not found, exiting..."))
return
end
-- da.register_events(MODULE, straighten_image)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- U S E R I N T E R F A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- 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)
if clean then
straighten_image(image)
end
end
)
--]]
dt.register_event(MODULE, "shortcut",
function(event, shortcut)
toggle_auto_crop()
end, "toggle auto crop off and on"
)
return script_data

View File

@@ -0,0 +1,568 @@
--[[
extract_burst_roll_images.lua - extract burst images from a burst roll file
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/>.
]]
--[[
extract_burst_roll_images - extract burst images from a burst roll file
Canon cameras are capable of shooting in burst mode. When the shutter
button is half pressed the camera starts "recording". When the shutter is
fully pressed the pervious 1/2 second of imformation is recorded as individual
images stored in one file as well as images captured at 30 frames/sec until
the buffer fills or the shutter button is released. So a Canon R7 burst file
could contain ~90 images.
Extracting the images from this file required use of Canon proprietary software
until recently when dnglab got the capability to extract the embedded images as
DNG raws.
This script can be set to run on import, scanning for burst roll containers,
extracting the embedded images and grouping them with the burst roll container.
The script can alsu be utilized via a short cut and applied to selected images.
When set to on import the script scans each image after import using exiv2 or exiftool
to check the image EXIF information to see if the file is a busrt roll container.
Once the import images are scanned, the detected burst roll containers are processed,
the embedded images extracted as DNSs, and grouped with the container image.
In shortcut mode the user selects the burst roll containers manually and uses the shortcut
to extract the embedded images as DNGs
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
- exiv2 - https://exiv2.org
- exiftool - https://exiftool.org
- dnglab - https://github.com/dnglab/dnglab
USAGE
- add the script to your lua scripts
- enable in script manager
- set the preferences in perferences->Lua options
LIMITATIONS
extract_burst_roll_images makes heavy use of external programs to identify burst roll
containters and to extract the images. Windows users may want to use it in shortcut mode
to lessen the number of windows popping up.
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 = _("extract burst roll images"), -- visible name of script
purpose = _("extract burst images from a burst roll file"), -- 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/official/extract_burst_roll_images/" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "extract_burst_roll_images"
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 ";"
local EXIFTOOL <const> = df.check_if_bin_exists("exiftool")
local EXIV2 <const> = df.check_if_bin_exists("exiv2")
local DNGLAB <const> = df.check_if_bin_exists("dnglab")
local EXIV2TAG <const> = "Exif.Canon.RawBurstModeRoll"
local HAS_INTERNAL_METADATA <const> = dt.metadata.exists(EXIV2TAG)
local DNGLAB_ARGS <const> = " convert --image-index all --embed-raw false "
local EXIV2_ARGS <const> = " -K Exif.Canon.RawBurstModeRoll -pt "
local EXIFTOOL_ARGS <const> = " -T -RawBurstImageCount "
-- - - - - - - - - - - - - - - - - - - - - - - -
-- L O G L E V E L
-- - - - - - - - - - - - - - - - - - - - - - - -
log.log_level(DEFAULT_LOG_LEVEL)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- N A M E S P A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
local extract_burst_roll_images = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
extract_burst_roll_images.preferred_tag_reader = nil
extract_burst_roll_images.on_import = true
extract_burst_roll_images.select_button = true
extract_burst_roll_images.imported_images = {}
extract_burst_roll_images.log_level = DEFAULT_LOG_LEVEL
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
-- exiv2 or exiftool
dt.preferences.register(MODULE, -- script: This is a string used to avoid name collision in preferences (i.e namespace). Set it to something unique, usually the name of the script handling the preference.
"tag_reader", -- name
"enum", -- type
_("preferred exif tag reader"), -- label
_("use exiftool or exiv2 to read exif tags"), -- tooltip
"exiftool", -- default
"exiftool", "exiv2") -- values
-- run on import
dt.preferences.register(MODULE, -- script: This is a string used to avoid name collision in preferences (i.e namespace). Set it to something unique, usually the name of the script handling the preference.
"on_import", -- name
"bool", -- type
_("extract burst roll images on import"), -- label
_("extract burst roll images on import"), -- tooltip
false) -- default
-- display selection button
dt.preferences.register(MODULE, -- script: This is a string used to avoid name collision in preferences (i.e namespace). Set it to something unique, usually the name of the script handling the preference.
"select_button", -- name
"bool", -- type
_("display select burst roll images button"), -- label
_("add a button to the select module to select burst roll images"), -- tooltip
true) -- default
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local namespace = extract_burst_roll_images
local ebri = extract_burst_roll_images
local sleep = dt.control.sleep
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
-------------------
-- helper functions
-------------------
local function set_log_level(level)
if not level then
level = namespace.log_level
end
local old_log_level = log.log_level()
log.log_level(level)
return old_log_level
end
local function restore_log_level(level)
if not level then
level = namespace.log_level
end
log.log_level(level)
end
local function reset_log_level()
log.log_level(DEFAULT_LOG_LEVEL)
namespace.log_level = DEFAULT_LOG_LEVEL
end
local function pref_read(name, pref_type)
local old_log_level = set_log_level(ebri.log_level)
log.msg(log.debug, "name is " .. name .. " and type is " .. pref_type)
local val = dt.preferences.read(MODULE, name, pref_type)
log.msg(log.debug, "read value " .. tostring(val))
restore_log_level(old_log_level)
return val
end
local function pref_write(name, pref_type, value)
local old_log_level = set_log_level(ebri.log_level)
log.msg(log.debug, "writing value " .. tostring(value) .. " for name " .. name)
dt.preferences.write(MODULE, name, pref_type, value)
restore_log_level(old_log_level)
end
local function get_last_value(valuestr)
local old_log_level = set_log_level()
local parts = du.split(valuestr, " +")
if parts then
log.msg(log.debug, "last value of parts is " .. tostring(parts[#parts]))
return parts[#parts]
else
log.msg(log.debug, "last part not detected, returning nil")
return nil
end
restore_log_level(old_log_level)
end
local function is_burst_roll_image(image)
local old_log_level = set_log_level()
local result = false
if HAS_INTERNAL_METADATA then
if image.RawBurstModeRoll then
local val = get_last_value(image.RawBurstModeRoll)
val = tonumber(val)
if type(val) == "number" then
if val > 0 then
result = true
end
end
end
else
local cmd = EXIFTOOL .. EXIFTOOL_ARGS
if ebri.preferred_tag_reader == "exiv2" then
cmd = EXIV2 .. EXIV2_ARGS
end
local p = io.popen(cmd .. ds.sanitize(image.path) .. PS .. ds.sanitize(image.filename))
if p then
for line in p:lines() do
if ebri.preferred_tag_reader == "exiv2" then
line = get_last_value(line)
end
line = tonumber(line)
if type(line) == "number" then
if line > 0 then
log.msg(log.info, image.filename .. " is a burst roll container image")
result = true
end
end
end
p:close()
end
end
restore_log_level(old_log_level)
return result
end
local function group_extracted_dngs(image)
local old_log_level = set_log_level()
local count = 0
local cmd = "ls -1 " .. ds.sanitize(image.path) .. PS .. ds.sanitize(ds.get_basename(image.filename)) .. "*.dng"
if dt.configuration.running_os == "windows" then
cmd = "forfiles /P " .. ds.sanitize(image.path) .. " /M " .. ds.sanitize(ds.get_basename(image.filename)) .. "*.dng" .. " /C \"cmd /c echo @path\\@fname\""
end
local p = io.popen(cmd)
if p then
for line in p:lines() do
if line:len() > 5 then -- skip blank lines created by forfiles
local dng_image = dt.database.import(line)
dng_image:group_with(image)
count = count + 1
end
end
p:close()
log.msg(log.screen, string.format(_("extracted %d dng files from %s"), count, image.filename))
end
restore_log_level(old_log_level)
end
local function extract_burst(image)
local old_log_level = set_log_level()
local cmd = DNGLAB .. DNGLAB_ARGS .. ds.sanitize(image.path) .. PS .. ds.sanitize(image.filename) .. " " .. ds.sanitize(image.path)
local result = dtsys.external_command(cmd)
if result == 0 then
group_extracted_dngs(image)
end
restore_log_level(old_log_level)
end
local function extract_burst_images(images)
for _, image in ipairs(images) do
if is_burst_roll_image(image) then
extract_burst(image)
end
end
end
local function process_image(image)
local old_log_level = set_log_level()
if is_burst_roll_image(image) then
extract_burst(image)
end
restore_log_level(old_log_level)
end
local function stop_job(job)
job.valid = false
end
local function process_images(images)
local old_log_level = set_log_level()
local job = dt.gui.create_job(_("scanning for burst roll images"), true, stop_job)
for i, image in ipairs(images) do
if ebri.job.valid then
extract_burst(image)
ebri.job.percent = i / #images
sleep(10)
end
end
stop_job(job)
restore_log_level(old_log_level)
end
local function process_import(images)
local old_log_level = set_log_level()
local burst_roll_images = {}
local job = dt.gui.create_job(_("scanning for burst roll images"), true, stop_job)
for i, image in ipairs(images) do
if job.valid then
if is_burst_roll_image(image) then
table.insert(burst_roll_images, image)
end
if i % 10 == 0 then
job.percent = i / #images
end
sleep(10)
end
end
stop_job(job)
if #burst_roll_images > 0 then
job = dt.gui.create_job(_("extracting burst roll images"), true, stop_job)
for j, image in ipairs(burst_roll_images) do
if job.valid then
extract_burst(image)
job.percent = j / #burst_roll_images
sleep(10)
end
end
stop_job(job)
end
restore_log_level(old_log_level)
end
local function select_burst_roll_images(event, images)
local old_log_level = set_log_level()
local selected = {}
for _, image in ipairs(images) do
if is_burst_roll_image(image) then
table.insert(selected, image)
end
end
restore_log_level(old_log_level)
return selected
end
local function set_select_sensitive()
if #dt.collection > 0 then
dt.gui.libs.select.set_sensitive(MODULE, true)
else
dt.gui.libs.select.set_sensitive(MODULE, false)
end
end
local function set_action_sensitive()
local sensitive = false
if #dt.gui.action_images > 0 then
if is_burst_roll_image(dt.gui.action_images[1]) then
sensitive = true
end
end
dt.gui.libs.image.set_sensitive(MODULE, sensitive)
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N P R O G R A M
-- - - - - - - - - - - - - - - - - - - - - - - -
if not DNGLAB then
log.msg(log.error, MODULE .. _("dnglab executable not found"))
log.msg(log.screen, script_data.metadata.name .. _(": dnglab executable not found, exiting..."))
return
end
if HAS_INTERNAL_METADATA then
log.msg(log.info, "Exif.Canon.RawBurstModeRoll detected, using internal metadata")
else
if not EXIFTOOL and not EXIV2 then
log.msg(log.error, MODULE .. _("no exif tag reader available"))
log.msg(log.screen, script_data.metadata.name .. _(" no exif tag reader available, exiting..."))
return
end
ebri.preferred_tag_reader = pref_read("tag_reader", "enum")
if not string.match(EXIFTOOL, ebri.preferred_tag_reader) and not string.match(EXIV2, ebri.preferred_tag_reader) then
log.msg(log.error, MODULE .. " selected tag " .. ebri.preferred_tag_reader .." not available")
log.msg(log.screen, script_data.metadata.name .. string.format(_(" selected tag reader %s not available"), ebri.preferred_tag_reader))
return
end
end
ebri.on_import = pref_read("on_import", "bool")
ebri.select_button = pref_read("select_button", "bool")
-- - - - - - - - - - - - - - - - - - - - - - - -
-- U S E R I N T E R F A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
if ebri.select_button then
dt.gui.libs.select.register_selection(MODULE, _("select burst roll images"), select_burst_roll_images,
_("select burst roll image containers"))
end
dt.gui.libs.image.register_action(
MODULE,
_("extract burst roll images"),
function(event, images)
extract_burst_images(images)
end,
_("extract and group burst roll images with container")
)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- 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, "post-import-film")
dt.destroy_event(MODULE, "post-import-image")
if ebri.select_button then
dt.gui.libs.select.destroy_selection(MODULE)
end
dt.gui.libs.image.action.destroy_action(MODULE)
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.register_event(MODULE, "post-import-film",
function(event, film)
process_import(ebri.imported_images)
ebri.imported_images = {}
end
)
dt.register_event(MODULE, "post-import-image",
function(event, image)
if ebri.on_import then
table.insert(ebri.imported_images, image)
end
end
)
dt.register_event(MODULE, "selection-changed",
function(event)
set_action_sensitive()
end
)
if dt.preferences.read("darktable", "plugins/lighttable/act_on", "bool") then
dt.print_log("adding mouse-over-image-changed event")
dt.register_event(MODULE, "mouse-over-image-changed",
function(event, image)
set_action_sensitive()
end
)
end
dt.register_event(MODULE, "collection-changed",
function(event)
set_select_sensitive()
end
)
if not dt.query_event(MODULE, "shortcut") then
dt.register_event(MODULE, "shortcut",
function(event, shortcut)
if #dt.gui.action_images > 1 then
process_images(dt.gui.action_images)
else
process_image(dt.gui.action_images[1])
end
end, "extract roll burst images"
)
end
return script_data

View File

@@ -0,0 +1,694 @@
--[[
group_persistence.lua - preserve groups between darktable instances
Copyright (C) 2024-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/>.
]]
--[[
group_persistence - preserve groups between darktable instances
group_persistence uses "functional" tagging to maintain image groups
across database instances, allowing image grouping to be restored when
the database is rebuilt from the XMP sidecar files.
Once the script is started it "listens" for changes in image grouping and
applies or removes the necessary tags automatically.
A shortcut can be assigned to read a collection and apply the necessary
grouping tags so that existing collections can be maintained.
If the database is lost and needs to be rebuilt then start the script before
importing any images. As images are imported the tags will be read and the
grouping applied in the database.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
None
USAGE
* Enable the script using script manager
* Optionally, create a keystroke combination for the shortcut
LIMITATIONS
In normal use you wont notice that the script is running.
However if you do something like import 4000+ images, then
run the autogrouper script to create groups whenever there is
a 5 second difference between 2 images, your system will be busy
for awhile. If for some reason you need to do something like this
turn off group_persistence, run the autogrouper, turn on group_persistence
and use the shortcut to read all the grouping information and apply
the necessary tags. It will still be an impact, but less than trying
to do both at once. Learned from experience :-)
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"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "group_persistence"
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("9.4.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(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 = _("group persistence"), -- name of script
purpose = _("preserve groups between darktable instances"), -- 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/official/group_persistence" -- 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 group_persistence = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local namespace = group_persistence
local gp = group_persistence
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
-- table of imported images that need grouping rebuilt
gp.imported_images = {}
-- uuid to group leader mapping
gp.uuids = {}
-- group leader to uuid mapping
gp.leaders = {}
-- uuid to group tag mapping
gp.group_tags = {}
-- uuid to group leader tag mapping
gp.leader_tags = {}
-- when the groups are rebuilt after import they
-- cause the group change events to fire but the
-- events are queued until after rebuilding completes
-- so we need to build a table of images to ignore when
-- the queued events catch up. As the event fires for an
-- image, the image is removed.
gp.rebuilt_images = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
-- utilities for building/debugging
local function dump_image_tags(image)
log.msg(log.debug, "")
for _, tag in ipairs(image:get_tags()) do
log.msg(log.debug, "tag for image " .. image.id .. " is " .. tag.name)
end
log.msg(log.debug, "group_leader is " .. image.group_leader.id .. " and group has " .. #image:get_group_members() .. " members")
log.msg(log.debug, "")
end
local function dump_group_tags(image)
local leader = image.group_leader
for _, tag in ipairs(image:get_tags()) do
if string.match(tag.name, "darktable|group") then
log.msg(log.debug, "group leader image " .. image.id .. " has tag " .. tag.name)
end
end
for _, img in ipairs(image:get_group_members()) do
for _, tag in ipairs(img:get_tags()) do
if string.match(tag.name, "darktable|group") then
log.msg(log.debug, "group member image " .. img.id .. " has tag " .. tag.name)
end
end
end
end
-- rebuild grouping on import
local function save_group_leader(image, uuid)
log.log_level(DEFAULT_LOG_LEVEL)
log.msg(log.debug, "input image is " .. image.id .. " and uuid is " .. uuid)
gp.uuids[image] = uuid
gp.leaders[uuid] = image
log.msg(log.debug, "after saving group leader uuid is " .. gp.leaders[uuid].id .. " and image is " .. gp.uuids[image])
end
local function get_group_and_uuid(tags)
log.log_level(DEFAULT_LOG_LEVEL)
local group_type = nil
local uuid = nil
for _, tag in ipairs(tags) do
if string.match(tag.name, "darktable|group") then
log.msg(log.debug, "found tag darktable|group")
local parts = du.split(tag.name, "|")
group_type = parts[2]
uuid = parts[3]
log.msg(log.info, "found type " .. group_type .. " with uuid " .. uuid)
end
end
return group_type, uuid
end
local function get_uuid_from_tag(tag)
local parts = du.split(tag.name, "|")
return parts[#parts]
end
local function find_group_leader(uuid)
local group_leader = nil
local tag_name = "darktable|group_leader|" .. uuid
local found_tag = dt.tags.find(tag_name)
if found_tag then
group_leader = dt.tags.get_tagged_images(found_tag)[1]
end
return group_leader
end
local function rebuild_groups()
log.log_level(DEFAULT_LOG_LEVEL)
for _, image in ipairs(gp.imported_images) do
log.msg(log.info, "processing image " .. image.id)
local tags = image:get_tags()
local group_type, uuid = get_group_and_uuid(tags)
if group_type =="group_leader" then
table.insert(gp.rebuilt_images, image)
log.msg(log.debug, "took group_leader branch")
save_group_leader(image, uuid)
elseif group_type == "group" then
table.insert(gp.rebuilt_images, image)
log.msg(log.debug, "took group branch")
local leader = gp.uuids[uuid]
if leader then
image:group_with(leader)
else
local group_leader = find_group_leader(uuid)
if group_leader then
save_group_leader(group_leader, uuid)
image:group_with(group_leader)
end
end
end
end
gp.imported_images = {}
end
-- rebuild grouping on command (shortcut)
local function is_group_leader(image)
result = false
if image.group_leader == image and #image:get_group_members() > 1 then
result = true
end
return result
end
local function has_group_tag(image)
local result = false
for _, tag in ipairs(image:get_tags()) do
if string.match(tag.name, "darktable|group|") then
result = tag
end
end
return result
end
local function has_group_leader_tag(image)
local result = false
for _, tag in ipairs(image:get_tags()) do
if string.match(tag.name, "darktable|group_leader") then
result = tag
end
end
return result
end
-- handle grouping changes in the UI
local function group_exists(group_leader)
local exists = false
local tags = group_leader:get_tags()
for _, tag in ipairs(tags) do
if string.match(tag.name, "group_leader") then
exists = true
break
end
end
return exists
end
local function add_group_leader(image, group)
-- tag image as group leader
-- create the uuid and leader/group tags if necessary
log.log_level(DEFAULT_LOG_LEVEL)
local uuid = du.gen_uuid()
if uuid then
log.msg(log.debug, "in add_group_leader and uuid is " .. uuid)
else
log.msg(log.debug, "uuid not created")
end
local tag = dt.tags.create("darktable|group_leader|" .. uuid)
local group_tag = dt.tags.create("darktable|group|" .. uuid)
log.msg(log.info, "created group leader tag " .. tag.name)
log.msg(log.info, "created group tag " .. group_tag.name)
gp.group_tags[uuid] = group_tag
gp.leader_tags[uuid] = tag
gp.uuids[uuid] = image
gp.leaders[image] = uuid
image:attach_tag(tag)
log.msg(log.info, "attached tag " .. tag.name .. " to image " .. image.id)
end
local function add_to_group(image, group_leader)
-- add an image to a group
-- create the group if necessary
log.log_level(DEFAULT_LOG_LEVEL)
if image == group_leader then
add_group_leader(image, group_leader)
else
if not group_exists(group_leader) then
add_group_leader(group_leader, group_leader)
end
local group_tag = nil
log.msg(log.debug, "image is " .. image.id .. " and group_leader is " .. image.group_leader.id)
local group, uuid = get_group_and_uuid(image.group_leader:get_tags())
group_tag = gp.group_tags[uuid]
if not group_tag then
log.msg(log.info, "creating group tag darktable|group|" .. uuid)
group_tag = dt.tags.create("darktable|group|" .. uuid)
gp.group_tags[uuid] = group_tag
end
image:attach_tag(group_tag)
log.msg(log.info, "group tag attached to image " .. image.id)
end
end
local function build_group(leader)
if not has_group_leader_tag(leader) then
add_group_leader(leader)
end
for _, image in ipairs(leader:get_group_members()) do
if not has_group_tag(image) then
add_to_group(image, leader)
end
end
end
local function process(image_set)
local images
if image_set == "collection" then
images = dt.collection
else
images = dt.gui.selection()
if #images == 0 then
images = dt.gui.action_images
end
end
for _, image in ipairs(images) do
if is_group_leader(image) then
build_group(image)
end
end
end
local function check_and_remove_empty_group_data(group_tag)
if not group_tag then
return -- handle duplicate getting deleted
end
local group_leader_tag = nil
group_leader_tag = dt.tags.find(string.gsub(group_tag.name, "|group|", "|group_leader|"))
local group_leader_count = 0
local group_count = #dt.tags.get_tagged_images(group_tag)
if group_leader_tag then
group_leader_count = #dt.tags.get_tagged_images(group_leader_tag)
end
if group_leader_count + group_count < 2 then -- there's either 1 or 0, so no group
local uuid = get_uuid_from_tag(group_tag)
if gp.group_tags[uuid] then
gp.group_tags[uuid] = nil
end
if gp.uuids[image] then
gp.uuids[image] = nil
end
if gp.leaders[uuid] then
gp.leaders[uuid] = nil
end
group_tag:delete()
if group_leader_tag then
group_leader_tag:delete()
end
end
end
local function remove_group_leader(image, new_group_leader)
-- remove image group leader tag
-- if it's the last image, then remove the leader/group tags
log.log_level(DEFAULT_LOG_LEVEL)
dump_image_tags(image)
local group, uuid = get_group_and_uuid(image:get_tags())
local leader_tag = dt.tags.find("darktable|group_leader|" .. uuid)
local group_tag = dt.tags.find("darktable|group|" .. uuid)
image:detach_tag(leader_tag)
gp.leaders[image] = nil
log.msg(log.info, "removed image " .. image.id .. " as group leader for uuid " .. uuid)
new_group_leader:detach_tag(group_tag)
new_group_leader:attach_tag(leader_tag)
log.msg(log.info, "added image " .. new_group_leader.id .. " as group leader for uuid " .. uuid)
gp.leaders[new_group_leader] = uuid
gp.uuids[uuid] = new_group_leader
check_and_remove_empty_group_data(group_tag)
end
local function remove_from_group(image, group_leader)
-- remove an image from a group
-- if it's the last image then remove the tags
-- image.id > group_leder.id - simple remove
-- image.id < group_leader.id - deleting old group_leader (image) with new group_leader (group_leader)
log.log_level(DEFAULT_LOG_LEVEL)
if image.id < group_leader.id then
remove_group_leader(image, group_leader)
else
local group_tag = has_group_tag(image)
if group_tag then
image:detach_tag(group_tag)
log.msg(log.info, "image " .. image.filename .. " with id " .. image.id .. " removed from group " .. group_tag.name)
end
check_and_remove_empty_group_data(group_tag)
end
end
local function change_group_leader(image)
-- remove the group leader tag from the old leader
-- replace the old group leader tag with a group tag
-- add the group leader tag to the new image
log.log_level(DEFAULT_LOG_LEVEL)
dump_image_tags(image)
local group, uuid = get_group_and_uuid(image:get_tags())
local leader_tag = dt.tags.find("darktable|group_leader|" .. uuid)
local group_tag = dt.tags.find("darktable|group|" .. uuid)
old_leader = dt.tags.get_tagged_images(leader_tag)[1]
old_leader:detach_tag(leader_tag)
log.msg(log.info, "remove leader tag from image " .. old_leader.id)
old_leader:attach_tag(group_tag)
log.msg(log.info, "attached group tag to image " .. old_leader.id)
image:detach_tag(group_tag)
log.msg(log.info, "removed group tag from new leader image " .. image.id)
image:attach_tag(leader_tag)
log.msg(log.info, "attached leader tag to new leader image " .. image.id)
gp.leaders[uuid] = image
gp.uuids[image] = uuid
end
-- event handling
local function add_group_event()
dt.register_event(MODULE, "image-group-information-changed",
function(event, reason, image, other_image)
log.msg(log.debug, "")
log.msg(log.debug, "caught event with reason " .. reason .. " and image " .. image.id .. " and other image " .. other_image.id)
log.msg(log.debug, "")
for pos, rimage in ipairs(gp.rebuilt_images) do
if rimage == image then
table.remove(gp.rebuilt_images, pos)
return
end
end
if reason == "add" then
log.msg(log.debug, "in add, image is " .. image.id .. " and group is " .. other_image.id)
if image == other_image then
add_group_leader(image, other_image)
else
add_to_group(image, other_image)
end
elseif reason == "remove" then
log.msg(log.debug, "in remove with image " .. image.id .. " and group_id " .. other_image.id)
remove_from_group(image, other_image)
elseif reason == "remove-leader" then
remove_group_leader(image, other_image)
log.msg(log.debug, "in remove-leader with image " .. image.id .. " and new group id " .. other_image.id)
else
if image ~= other_image then
log.msg(log.debug, "in change-leader with image " .. image.id .. " and other_id " .. other_image.id)
change_group_leader(image)
log.msg(log.debug, "in change-leader with image " .. image.id .. " and other_id " .. other_image.id)
else
log.msg(log.debug, "in change-leader with image " .. image.id .. " and other_id " .. other_image.id)
log.msg(log.debug, "calling add_group_leader")
add_group_leader(image, other_image)
end
end
end
)
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N P R O G R A M
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.util.message(MODULE, "autogrouper", "running")
-- - - - - - - - - - - - - - - - - - - - - - - -
-- 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, "post-import-image")
dt.destroy_event(MODULE, "post-import-film")
dt.destroy_event(MODULE, "image-group-information-changed")
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.register_event(MODULE, "post-import-image",
function(event, image)
table.insert(gp.imported_images, image)
end
)
dt.register_event(MODULE, "post-import-film",
function(event, filmroll)
rebuild_groups()
end
)
add_group_event()
dt.register_event(MODULE, "shortcut",
function(event, string)
dump_image_tags(dt.gui.action_images[1])
end, "dump image tags"
)
dt.register_event(MODULE, "shortcut",
function(event, string)
dump_group_tags(dt.gui.action_images[1])
end, "dump group_tags"
)
dt.register_event(MODULE, "shortcut",
function(event, string)
process("collection")
end, "group collection"
)
return script_data

View File

@@ -0,0 +1,343 @@
--[[
recent_bookmarks.lua - add recently edited and exported images to system recent bookmarks
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/>.
]]
--[[
recent_bookmarks - add recently edited and exported images to system recently used files
recent_bookmarks adds images edited in darktable and the directory of images exported from
darktable to the reccently-used.xbel file so that they show up in the recently used system
shortcuts.
Currently recent_bookmarks only works on Linux
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
file - linux system file utility to get mime-type
USAGE
eanble from 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("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 = _("recent_bookmarks"), -- visible name of script
purpose = _("add recently edited and exported images to system recent bookmarks"), -- purpose of script
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
help = "" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "recent_bookmarks"
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 ";"
-- format strings for writing the bookmarks
local BOOKMARK_FORMAT <const> = " <bookmark href=\"file://%s\" added=\"%s\" modified=\"%s\" visited=\"%s\">\n"
local MIME_FORMAT <const> = " <mime:mime-type type=\"%s\"/>\n"
local EXPORT_FORMAT <const> = " <bookmark:application name=\"xdg-open\" exec=\"&apos;xdg-open %%u&apos;\" modified=\"%s\" count=\"1\"/>\n"
local EDIT_FORMAT <const> = " <bookmark:application name=\"darktable\" exec=\"&apos;darktable %%u&apos;\" modified=\"%s\" count=\"1\"/>\n"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- L O G L E V E L
-- - - - - - - - - - - - - - - - - - - - - - - -
log.log_level(DEFAULT_LOG_LEVEL)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- N A M E S P A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
local recent_bookmarks = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
recent_bookmarks.log_level = DEFAULT_LOG_LEVEL
recent_bookmarks.bookmark_file = nil
recent_bookmarks.current_view = nil
recent_bookmarks.in_darkroom = false
recent_bookmarks.current_image = nil
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local namespace = recent_bookmarks
local rb = recent_bookmarks
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
-------------------
-- helper functions
-------------------
local function set_log_level(level)
if not level then
level = namespace.log_level
end
local old_log_level = log.log_level()
log.log_level(level)
return old_log_level
end
local function restore_log_level(level)
if not level then
level = namespace.log_level
end
log.log_level(level)
end
local function reset_log_level()
log.log_level(DEFAULT_LOG_LEVEL)
namespace.log_level = DEFAULT_LOG_LEVEL
end
local function pref_read(name, pref_type)
local old_log_level = set_log_level(namespace.log_level)
log.msg(log.debug, "name is " .. name .. " and type is " .. pref_type)
local val = dt.preferences.read(MODULE, name, pref_type)
log.msg(log.debug, "read value " .. tostring(val))
restore_log_level(old_log_level)
return val
end
local function pref_write(name, pref_type, value)
local old_log_level = set_log_level(namespace.log_level)
log.msg(log.debug, "writing value " .. tostring(value) .. " for name " .. name)
dt.preferences.write(MODULE, name, pref_type, value)
restore_log_level(old_log_level)
end
--------------------
-- program functions
--------------------
local function zulu_timestamp()
return os.date("!%Y-%m-%dT%H:%M:%SZ")
end
local function get_mimetype(filepath)
local mimetype = "application/octet-stream"
local p = io.popen("file -L --mime-type " .. df.sanitize_filename(filepath))
if p then
for line in p:lines() do
local result = string.match(line, ".-: (.+)$")
if result then
mimetype = result
end
end
end
return mimetype
end
local function find_bookmark_file()
local file = nil
local home = os.getenv("HOME")
if home then
if df.test_file(home .. PS ..".local/share/recently-used.xbel", "e") then
file = home .. PS ..".local/share/recently-used.xbel"
end
end
return file
end
local function write_bookmark_file(bookmark, mimetype, application)
local file = io.open(rb.bookmark_file, "r+")
if file then
local pos = file:seek("end", -7)
if pos > 0 then
file:write(bookmark)
file:write(" <info>\n <metadata owner=\"http://freedesktop.org\">\n")
file:write(mimetype)
file:write(" <bookmark:applications>\n")
file:write(application)
file:write(" </bookmark:applications>\n </metadata>\n </info>\n </bookmark>\n</xbel>")
file:close()
else
log.msg(log.screen, _("failed to open bookmark file"))
end
else
log.msg(log.screen, _("failed to open bookmark file"))
end
end
local function create_bookmark_string(filename, zulu)
return string.format(BOOKMARK_FORMAT, filename, zulu, zulu, zulu)
end
local function create_mimetype_string(mimetype)
return string.format(MIME_FORMAT, mimetype)
end
local function process_bookmark(image, filename)
local zulu = zulu_timestamp()
local filepath = filename and filename or (image.path .. PS .. image.filename)
local format = filename and EXPORT_FORMAT or EDIT_FORMAT
local mimetype = get_mimetype(filepath)
local bookmark_string = create_bookmark_string(filepath, zulu)
local mime_string = create_mimetype_string(mimetype)
local app_string = string.format(format, zulu)
write_bookmark_file(bookmark_string, mime_string, app_string)
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N P R O G R A M
-- - - - - - - - - - - - - - - - - - - - - - - -
if dt.configuration.running_os ~= "linux" then
log.msg(log.screen, script_data.metadata.name .. _(" only runs on Linux"))
return
end
rb.bookmark_file = find_bookmark_file()
if not rb.bookmark_file then
log.msg(log.screen, _("recent bookmarks file not found"))
return
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- U S E R I N T E R F A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- 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, "intermediate-export-image")
dt.destroy_event(MODULE, "darkroom-image-loaded")
dt.destroy_event(MODULE, "darkroom-image-history-changed")
dt.destroy_event(MODULE, "view-changed")
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.register_event(MODULE, "intermediate-export-image",
function(event, image, filename, format, storage)
process_bookmark(image, filename)
end
)
dt.register_event(MODULE, "darkroom-image-loaded",
function(event, clean, image)
if clean then
rb.in_darkroom = true
rb.current_view = "darkroom"
rb.current_image = image
end
end
)
dt.register_event(MODULE, "darkroom-image-history-changed",
function(event, image)
if image == rb.current_image then
process_bookmark(image)
end
end
)
dt.register_event(MODULE, "view-changed",
function(event, old_view, new_view)
rb.current_view = new_view.id
if rb.current_view == "darkroom" then
rb.in_darkroom = true
else
if rb.in_darkroom then
rb.in_darkroom = false
end
end
end
)
return script_data

View File

@@ -76,10 +76,10 @@ script_data.restart = nil -- how to restart the (lib) script after it'
script_data.show = nil -- only required for libs since the destroy_method only hides them script_data.show = nil -- only required for libs since the destroy_method only hides them
script_data.metadata = { script_data.metadata = {
name = _("regenerate_thumbnails"), -- visible name of script name = _("regenerate thumbnails"), -- visible name of script
purpose = _("regenerate mipmap cache for selected images"), -- purpose of script purpose = _("regenerate mipmap cache for selected images"), -- purpose of script
author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address author = "Bill Ferguson <wpferguson@gmail.com>", -- your name and optionally e-mail address
help = "https://docs.darktable.org/lua/development/lua.scripts.manual/scripts/official/regenerate_thumbnails" -- URL to help/documentation help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/regenerate_thumbnails" -- URL to help/documentation
} }
@@ -173,7 +173,7 @@ local function generate_thumbnails(images)
local has_job = false local has_job = false
if #images > 50 then if #images > 50 then
rt.job = dt.gui.create_job("regenerating thumbnails", true, stop_job) rt.job = dt.gui.create_job(_("regenerating thumbnails"), true, stop_job)
has_job = true has_job = true
end end
@@ -243,7 +243,7 @@ dt.register_event(MODULE, "shortcut",
if images then if images then
generate_thumbnails(images) generate_thumbnails(images)
end end
end, "regenerate thumbnails" end, _("regenerate thumbnails")
) )
dt.register_event(MODULE, "selection-changed", dt.register_event(MODULE, "selection-changed",

225
official/scheduler.lua Normal file
View File

@@ -0,0 +1,225 @@
--[[
scheduler.lua - provide scheduler services for multitasking
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/>.
]]
--[[
scheduler - provide scheduler services for multitasking
scheduler implements a sinple first in first out (FIFO) scheduler
necessary for lua script multitasking.
Scripts voluntarily yield to the scheduler and they are placed at
the end of the run queue. The first item in the run queue is told
to resume.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
None
USAGE
start script from 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"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "scheduler"
local DEFAULT_LOG_LEVEL <const> = log.info
local TMP_DIR <const> = dt.configuration.tmp_dir
local BROADCAST_MSG <const> = "broadcast"
-- 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("9.4.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(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 = _("scheduler"), -- name of script
purpose = _("provide scheduler services for multitasking"), -- 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/official/scheduler/" -- 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 scheduler = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
scheduler.run_queue = {}
scheduler.message_queue = {}
scheduler.log_level = log.debug
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.preferences.register(MODULE, "target_time_slice_seconds", "float",
_("scheduler preferred time slice"), _("target time slice before interrupt"), 1.0, 0.1, 1.0, 0.1)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local sch = scheduler
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
local function remove_from_run_queue()
log.log_level(sch.log_level)
if #sch.run_queue > 0 then
local module = sch.run_queue[1]
local message = sch.message_queue[1]
table.remove(sch.run_queue, 1)
table.remove(sch.message_queue, 1)
log.msg(log.info, "run queue length is " .. #sch.run_queue)
log.msg(log.debug, "sending " .. message .. " to " .. module)
dt.util.message(MODULE, module, message)
else
log.msg(log.warn, "run queue empty, nothing to resume")
end
end
local function add_to_run_queue(module, message)
log.log_level(sch.log_level)
table.insert(sch.run_queue, module)
table.insert(sch.message_queue, message)
log.msg(log.info, "run queue length is " .. #sch.run_queue)
dt.control.sleep(500) -- allow time for waiting scripts to start
remove_from_run_queue()
end
local function send_status(sender)
dt.util.message(MODULE, sender, "running, queue length is " .. #sch.run_queue)
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N P R O G R A M
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.util.message(MODULE, BROADCAST_MSG, "starting")
-- - - - - - - - - - - - - - - - - - - - - - - -
-- U S E R I N T E R F A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- 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.util.message(MODULE, BROADCAST_MSG, "stopping")
dt.destroy_event(MODULE, "inter-script-communication")
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.register_event(MODULE, "inter-script-communication",
function(event, sender,receiver, message)
log.log_level(sch.log_level)
if receiver == "scheduler" then
log.msg(log.debug, "got message from " .. sender .. " to " .. receiver .. " with message " .. message)
if string.match(message, "^yield") then
log.msg(log.debug, "got message " .. message .. " from " .. sender)
add_to_run_queue(sender, string.gsub(message, "yield", "resume"))
elseif message == "status" then
send_status(sender)
elseif messages == "done" then
-- do nothing
else
log.msg(log.warn, "unrecognized message " .. message)
end
end
end
)
return script_data

View File

@@ -0,0 +1,266 @@
--[[
select_duplicates.lua - select duplicate images from a collection
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/>.
]]
--[[
select_duplicates - select duplicate images from a collection
select_duplicates adds a button to the select module to select
duplicate images from a collection. If the selection2collection
script is also active an option is available to have the selected
duplicates appear as a temporary collection.
A shortcut is available to select the duplicates from the collection
but it is somewhat slower because it doesn't have access to the current
image cache and therfore has to open each image separately.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
None
USAGE
* enable in script manager
* Press the button
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("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 = _("select duplicates"), -- visible name of script
purpose = _("select duplicate images from a collection"), -- 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/official/select_duplicates/" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "select_duplicates"
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 select_duplicates = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
select_duplicates.log_level = DEFAULT_LOG_LEVEL
select_duplicates.create_collection = false
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.preferences.register(MODULE, "create_collection", "bool",
script_data.metadata.name .. _(" create collection from selection"),
_("create collection of selected duplicates"), false)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local namespace = select_duplicates
local sd = select_duplicates
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
-------------------
-- helper functions
-------------------
local function set_log_level(level)
if not level then
level = namespace.log_level
end
local old_log_level = log.log_level()
log.log_level(level)
return old_log_level
end
local function restore_log_level(level)
if not level then
level = namespace.log_level
end
log.log_level(level)
end
local function reset_log_level()
log.log_level(DEFAULT_LOG_LEVEL)
namespace.log_level = DEFAULT_LOG_LEVEL
end
local function pref_read(name, pref_type)
local old_log_level = set_log_level(namespace.log_level)
log.msg(log.debug, "name is " .. name .. " and type is " .. pref_type)
local val = dt.preferences.read(MODULE, name, pref_type)
log.msg(log.debug, "read value " .. tostring(val))
restore_log_level(old_log_level)
return val
end
local function pref_write(name, pref_type, value)
local old_log_level = set_log_level(namespace.log_level)
log.msg(log.debug, "writing value " .. tostring(value) .. " for name " .. name)
dt.preferences.write(MODULE, name, pref_type, value)
restore_log_level(old_log_level)
end
local function select_duplicate_files(event, images)
local duplicates = {}
local sorted_files = {}
sd.create_collection = pref_read("create_collection", "bool")
for _, img in ipairs(images) do
local rawname = img.path .. PS .. img.filename
if not sorted_files[rawname] then
sorted_files[rawname] = {}
end
table.insert(sorted_files[rawname], img)
end
for k,v in pairs(sorted_files) do
if #v > 1 then
local old_image = nil
for _,i in ipairs(v) do
if not old_image then
old_image = i
else
if i.duplicate_index > old_image.duplicate_index then
table.insert(duplicates, i)
else
table.insert(duplicates, old_image)
old_image = i
end
end
end
end
end
if sd.create_collection then
dt.util.message(MODULE, "selection2collection", "create")
end
return duplicates
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.select.register_selection(MODULE, _("select duplicates"), select_duplicate_files,
_("select duplicated images"))
-- - - - - - - - - - - - - - - - - - - - - - - -
-- 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.select.destroy_selection(MODULE)
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
if not dt.query_event(MODULE, "shortcut") then -- skip if shortcut already registered
dt.register_event(MODULE, "shortcut",
function(event, shortcut)
local duplicates = select_duplicate_files(event, dt.collection)
if duplicates then
dt.gui.selection(duplicates)
end
end, _("select duplicates")
)
end
return script_data

View File

@@ -0,0 +1,180 @@
--[[
select_raw_non_raw.lua - select raw or non raw files from a collection
Copyright (C) 2025 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/>.
]]
--[[
select_raw_non_raw - select raw or non raw files from a collection
select_raw_non_raw adds two selection buttons, one to select raw
images and one to select non raw images.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
None
USAGE
* enable the script with 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"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "select_raw_non_raw"
local DEFAULT_LOG_LEVEL <const> = log.warn
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
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 = _("select raw/non-raw"), -- name of script
purpose = _("select raw or non raw files from collection"), -- 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/official/select_raw_non_raw" -- 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 select_group_followers = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local namespace = select_group_followers
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
local function select_raw_files(event, images)
local raws = {}
for _, img in ipairs(images) do
if img.is_raw then
table.insert(raws, img)
end
end
return raws
end
local function select_non_raw_files(event, images)
local non_raws = {}
for _, img in ipairs(images) do
if not img.is_raw then
table.insert(non_raws, img)
end
end
return non_raws
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.select.register_selection(MODULE .. "_raws", _("select raw"), select_raw_files,
_("select raw files"))
dt.gui.libs.select.register_selection(MODULE .. "_non_raws", _("select non-raw"), select_non_raw_files,
_("select non-raw files"))
-- - - - - - - - - - - - - - - - - - - - - - - -
-- 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.gui.libs.select.destroy_selection(MODULE .. "_raws")
dt.gui.libs.select.destroy_selection(MODULE .. "_non_raws")
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
return script_data

View File

@@ -0,0 +1,168 @@
--[[
select_unaltered.lua - select images that have not been altered in any way
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/>.
]]
--[[
select_unaltered - select images that have not been altered in any way
select_unaltered selects images from the current collection that have not
been altered in any way. In other words just imported into darktable, not
loaded into darkroom nor having any style applied.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
None
USAGE
* enable with 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("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 = _("select unaltered"), -- visible name of script
purpose = _("select images that have not been altered in any way"), -- 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/official/select_unaltered/" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "select_unaltered"
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 select_unaltered = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
select_unaltered.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 = select_unaltered
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
local function select_unaltered_files(event, images)
local unaltered = {}
for _, img in ipairs(images) do
if not img.is_altered then
table.insert(unaltered, img)
end
end
return unaltered
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.select.register_selection(MODULE, _("select unaltered"), select_unaltered_files,
_("select unaltered files"))
-- - - - - - - - - - - - - - - - - - - - - - - -
-- 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.select.destroy_selection(MODULE)
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
return script_data

View File

@@ -0,0 +1,354 @@
--[[
selection2collection.lua - create a temporary collection from a selection
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/>.
]]
--[[
selection2collection - create a temporary collection from a selection
selection2collection creates a temporary collection, using functional
tagging, from a selection. The collection lasts until darktable exits
and then the collections are removed, but not the images.
To create the temporary collection, a function tag (darktable|functional|s2c)
tag with a date and a time is applied to the selected images. The collection
module filters on the tag to display the connection. More than one temporary
collection can be created and collections can be changed by selecting the
appropriate tag.
The tags are autmatically destroyed when darktable exits, thus removing the
collections. There is a preference in the Lua options to keep the collections
across darktable runs.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
None
USAGE
* start from script_manager
* make a selection
* click the create temporary collection button the the actions on selected images
module
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 = _("selection to collection"), -- visible name of script
purpose = _("create a temporary collection from a selection"), -- 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/official/selection2collection/" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "selection2collection"
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 ";"
-- script specific
local TAG_HEADER <const> = "darktable|functional|s2c|"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- L O G L E V E L
-- - - - - - - - - - - - - - - - - - - - - - - -
log.log_level(DEFAULT_LOG_LEVEL)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- N A M E S P A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
local selection2collection = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
selection2collection.log_level = DEFAULT_LOG_LEVEL
selection2collection.keep_collections = false
selection2collection.collection_rules = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.preferences.register(MODULE, "keep_collections", "bool",
script_data.metadata.name .. _(" keep collections created from selection"),
_("keep collections created from selections"), false)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local namespace = selection2collection
local s2c = selection2collection
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
-------------------
-- helper functions
-------------------
local function set_log_level(level)
if not level then
level = namespace.log_level
end
local old_log_level = log.log_level()
log.log_level(level)
return old_log_level
end
local function restore_log_level(level)
if not level then
level = namespace.log_level
end
log.log_level(level)
end
local function reset_log_level()
log.log_level(DEFAULT_LOG_LEVEL)
namespace.log_level = DEFAULT_LOG_LEVEL
end
local function pref_read(name, pref_type)
local old_log_level = set_log_level(namespace.log_level)
log.msg(log.debug, "name is " .. name .. " and type is " .. pref_type)
local val = dt.preferences.read(MODULE, name, pref_type)
log.msg(log.debug, "read value " .. tostring(val))
restore_log_level(old_log_level)
return val
end
local function pref_write(name, pref_type, value)
local old_log_level = set_log_level(namespace.log_level)
log.msg(log.debug, "writing value " .. tostring(value) .. " for name " .. name)
dt.preferences.write(MODULE, name, pref_type, value)
restore_log_level(old_log_level)
end
-------------------
-- program functions
-------------------
local function clear_collections()
s2c.keep_collections = pref_read("keep_collections", "bool")
if not s2c.keep_collections then
for _, tag in ipairs(dt.tags) do
if string.match(tag.name, TAG_HEADER) then
tag:delete()
end
end
end
end
local function create_collection_tag()
local t = os.date("*t")
local ymd = string.format("%4d:%02d:%02d", t.year, t.month, t.day)
local hms = string.format("%02d:%02d:%02d", t.hour, t.min, t.sec)
local tag_string = TAG_HEADER .. ymd .. "|" .. hms
dt.print_log("tag_string is " .. tag_string)
local tag = dt.tags.create(tag_string)
return tag
end
local function display_collection(tag_name)
local collection_rules = dt.gui.libs.collect.filter()
local rule = dt.gui.libs.collect.new_rule()
rule.mode = "DT_LIB_COLLECT_MODE_AND"
rule.data = tostring(tag_name)
rule.item = "DT_COLLECTION_PROP_TAG"
table.insert(collection_rules, rule)
-- dt.gui.libs.collect.filter returns the previous rule set when you install another
s2c.collection_rules = dt.gui.libs.collect.filter(collection_rules)
end
local function create_collection()
local images = dt.gui.selection()
if #images > 1 then
local tag = create_collection_tag()
if tag then
for _, image in ipairs(images) do
image:attach_tag(tag)
end
end
display_collection(tag.name)
else
log.msg(log.screen, _("not enough images to create a collection"))
end
end
local function communications_handler(sender, receiver, message)
if receiver == MODULE then
if message == "create" then
create_collection()
end
end
end
local function set_button_sensitive(images)
if #images > 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
-- - - - - - - - - - - - - - - - - - - - - - - -
s2c.keep_collections = pref_read("keep_collections", "bool")
-- - - - - - - - - - - - - - - - - - - - - - - -
-- U S E R I N T E R F A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.gui.libs.image.register_action(
MODULE, _("create temporary collection"),
function(event, images)
create_collection()
end,
_("convert selection to collection")
)
set_button_sensitive(dt.gui.selection())
-- - - - - - - - - - - - - - - - - - - - - - - -
-- 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, "inter-script-communication")
dt.destroy_event(MODULE, "exit")
dt.destroy_event(MODULE, "selection-changed")
dt.gui.libs.image.destroy_action(MODULE, "create_collection")
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.register_event(MODULE, "inter-script-communication",
function(event, sender, receiver, message)
communications_handler(sender, receiver, message)
end
)
if not dt.query_event(MODULE, "shortcut") then -- skip if shortcut already registered
dt.register_event(MODULE, "shortcut",
function(event, shortcut)
create_collection()
end, _("convert selection to collection")
)
end
dt.register_event(MODULE, "exit",
function(event)
if not s2c.keep_collections then
dt.gui.libs.collect.filter(s2c.collection_rules)
clear_collections()
end
end
)
dt.register_event(MODULE, "selection-changed",
function(event)
set_button_sensitive(dt.gui.selection())
end
)
return script_data

View File

@@ -54,7 +54,7 @@ local script_data = {}
script_data.metadata = { script_data.metadata = {
name = _("selection to PDF"), name = _("selection to PDF"),
purpose = _("generate a pdf file of selected images"), purpose = _("generate a PDF file of selected images"),
author = "Jérémy Rosen & Pascal Obry", author = "Jérémy Rosen & Pascal Obry",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/selection_to_pdf" help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/official/selection_to_pdf"
} }
@@ -66,8 +66,8 @@ script_data.show = nil -- only required for libs since the destroy_method only h
dt.preferences.register dt.preferences.register
("selection_to_pdf","Open with","string", ("selection_to_pdf","Open with","string",
_("a pdf viewer"), _("a PDF viewer"),
_("can be an absolute pathname or the tool may be in the PATH\nif not specified the system default will be used to open the pdf"), _("can be an absolute pathname or the tool may be in the PATH\nif not specified the system default will be used to open the PDF"),
"") "")
local title_widget = dt.new_widget("entry") { local title_widget = dt.new_widget("entry") {
@@ -134,7 +134,7 @@ local function destroy()
dt.print_log("done destroying") dt.print_log("done destroying")
end end
dt.register_storage("export_pdf", _("export thumbnails to pdf"), dt.register_storage("export_pdf", _("export thumbnails to PDF"),
nil, nil,
function(storage,image_table) function(storage,image_table)
local my_title = title_widget.text local my_title = title_widget.text
@@ -200,7 +200,7 @@ dt.register_storage("export_pdf", _("export thumbnails to pdf"),
command = command .. " " .. pdffile command = command .. " " .. pdffile
local result = dt.control.execute(command) local result = dt.control.execute(command)
if result ~= 0 then if result ~= 0 then
dt.print(_("problem running pdf viewer")) -- this one is probably usefull to the user dt.print(_("problem running PDF viewer")) -- this one is probably usefull to the user
error("Problem running "..command) error("Problem running "..command)
end end

View File

@@ -0,0 +1,337 @@
--[[
use_paired_jpg_as_mipmap.lua - Use the JPG from the RAW JPG pair as the full size mipmap
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/>.
]]
--[[
use_paired_jpg_as_mipmap - Use the JPG from the RAW JPG pair as the full size mipmap
use_paired_jpg_as_mipmap looks for RAW+JPEG image pairs as images are imported. After
import the JPEG image is copied to the mipmap cache as the full resolution mipmap. Requests
for smaller mipmap sizes can be satisfied by down sampling the full resolution mipmap. User's can
decide whether or not to keep the paired JPEG files. The default is to keep them. If the
user doesn't want them, they are deleted after being copied to the mipmap cache.
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
None
USAGE
- Add the script to the lua-scripts
- Enable the script
- Turn off the keep_jpgs preference if the JPEGS are not wanted
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("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 = _("use paired jpg as mipmap"), -- visible name of script
purpose = _("Use the JPG from the RAW JPG pair as the full size mipmap"), -- 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/official/use_paired_jpg_as_mipmap/" -- URL to help/documentation
}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- C O N S T A N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
local MODULE <const> = "use_paired_jpg_as_mipmap"
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 ";"
-- max mipmap size - 5.4.x = 8, after is 10
local MAX_MIPMAP_SIZE <const> = (dt.configuration.version > "5.4.1") and "10" or "8"
-- - - - - - - - - - - - - - - - - - - - - - - -
-- L O G L E V E L
-- - - - - - - - - - - - - - - - - - - - - - - -
log.log_level(DEFAULT_LOG_LEVEL)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- N A M E S P A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
local use_paired_jpg_as_mipmap = {}
-- - - - - - - - - - - - - - - - - - - - - - - -
-- G L O B A L V A R I A B L E S
-- - - - - - - - - - - - - - - - - - - - - - - -
use_paired_jpg_as_mipmap.log_level = DEFAULT_LOG_LEVEL
use_paired_jpg_as_mipmap.imported_images = {}
use_paired_jpg_as_mipmap.image_count = 0
use_paired_jpg_as_mipmap.mipmap_dir = nil
use_paired_jpg_as_mipmap.keep_jpgs = true
-- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.preferences.register(MODULE, "keep_jpgs", "bool", script_data.metadata.name .. " - " .. _("keep JPEG files"),
_("don't delete JPEGs after copying them to mipmap folder"), true)
-- - - - - - - - - - - - - - - - - - - - - - - -
-- A L I A S E S
-- - - - - - - - - - - - - - - - - - - - - - - -
local namespace = use_paired_jpg_as_mipmap
local pmj = use_paired_jpg_as_mipmap
-- - - - - - - - - - - - - - - - - - - - - - - -
-- F U N C T I O N S
-- - - - - - - - - - - - - - - - - - - - - - - -
-------------------
-- helper functions
-------------------
local function set_log_level(level)
if not level then
level = namespace.log_level
end
local old_log_level = log.log_level()
log.log_level(level)
return old_log_level
end
local function restore_log_level(level)
if not level then
level = namespace.log_level
end
log.log_level(level)
end
local function reset_log_level()
log.log_level(DEFAULT_LOG_LEVEL)
namespace.log_level = DEFAULT_LOG_LEVEL
end
local function pref_read(name, pref_type)
local old_log_level = set_log_level(namespace.log_level)
log.msg(log.debug, MODULE .. ": name is " .. name .. " and type is " .. pref_type)
local val = dt.preferences.read(MODULE, name, pref_type)
log.msg(log.debug, MODULE .. ": read value " .. tostring(val))
restore_log_level(old_log_level)
return val
end
local function pref_write(name, pref_type, value)
local old_log_level = set_log_level(namespace.log_level)
log.msg(log.debug, MODULE .. ": writing value " .. tostring(value) .. " for name " .. name)
dt.preferences.write(MODULE, name, pref_type, value)
restore_log_level(old_log_level)
end
local function refresh_collection()
local rules = dt.gui.libs.collect.filter()
dt.gui.libs.collect.filter(rules)
end
local function get_mipmap_dir()
local old_log_level = set_log_level(pmj.log_level)
local mipmap_dir = nil
local cachedir = dt.configuration.cache_dir
local cmd = "cd " .. cachedir .. "; ls -d mip*"
if dt.configuration.running_os == "windows" then
cmd = "forfiles /P \"" .. cachedir .. "\" /M mip* /C \"cmd /c echo @file\""
end
local p = io.popen(cmd)
if p then
for line in p:lines() do
if line:len() > 4 then
mipmap_dir = cachedir .. PS .. line
log.msg(log.info, MODULE .. ": mipmap_dir is " .. mipmap_dir)
end
end
p:close()
end
restore_log_level(old_log_level)
return mipmap_dir
end
local function stop_job(job)
job.valid = false
end
local function process_images(images, count)
local old_log_level = set_log_level(pmj.log_level)
-- if dt.collection is equal to count there are no
-- RAW+JPEG pairs
if count == #dt.collection then
log.msg(log.screen, _("No RAW+JPEG pairs to process"))
return
end
local job = dt.gui.create_job(_("Generating cache from jpgs"), true, stop_job)
local processed_count = 0
local mipmap_dir = get_mipmap_dir() .. PS .. MAX_MIPMAP_SIZE .. PS
local rc = df.mkdir(mipmap_dir)
log.msg(log.debug, MODULE .. ": rc from mkdir is " .. rc)
for k,v in pairs(images) do
if job.valid then
if v["raw"] and v["jpg"] then
local raw_image_id = v["raw"].id
local fname = v["jpg"].path .. PS .. v["jpg"].filename
df.file_copy(fname, mipmap_dir .. raw_image_id .. ".jpg")
if not pmj.keep_jpgs then
log.msg(log.debug, MODULE .. ": removing jpg")
v["jpg"]:delete()
local success, msg = os.remove(v["jpg"].path .. PS .. v["jpg"].filename)
if not success then
log.msg(log.warn, MODULE .. ": unable to remove jpg - reason: " .. msg)
end
success, msg = os.remove(v["jpg"].path .. PS .. v["jpg"].filename .. ".xmp")
if not success then
log.msg(log.warn, MODULE .. ": unable to remove jpg xmp - reason: " .. msg)
end
end
-- v["raw"]:generate_cache(true, 6, 6)
processed_count = processed_count + 1
end
job.percent = processed_count / count
end
dt.control.sleep(5)
end
stop_job(job)
if not pmj.keep_jpgs then
refresh_collection()
end
dt.util.message(MODULE, "broadcast", "finished")
end
-- - - - - - - - - - - - - - - - - - - - - - - -
-- M A I N P R O G R A M
-- - - - - - - - - - - - - - - - - - - - - - - -
use_paired_jpg_as_mipmap.keep_jpgs = pref_read("keep_jpgs", "bool")
-- - - - - - - - - - - - - - - - - - - - - - - -
-- U S E R I N T E R F A C E
-- - - - - - - - - - - - - - - - - - - - - - - -
-- - - - - - - - - - - - - - - - - - - - - - - -
-- 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, "post-import-film")
dt.destroy_event(MODULE, "post-import-image")
end
script_data.destroy = destroy
-- - - - - - - - - - - - - - - - - - - - - - - -
-- E V E N T S
-- - - - - - - - - - - - - - - - - - - - - - - -
dt.register_event(MODULE, "post-import-film",
function(event, film)
process_images(pmj.imported_images, pmj.image_count)
pmj.imported_images = {}
pmj.image_count = 0
end
)
dt.register_event(MODULE, "post-import-image",
function(event, image)
local basename = df.get_basename(image.filename)
local extension = string.lower(df.get_filetype(image.filename))
if not string.match(extension, "jpg") then
extension = "raw"
end
if not pmj.imported_images[basename] then
pmj.imported_images[basename] = {}
end
pmj.imported_images[basename][extension] = image
if not string.match(extension, "jpg") then
pmj.image_count = pmj.image_count + 1
end
end
)
return script_data

View File

@@ -54,7 +54,7 @@ local df = require "lib/dtutils.file"
local ds = require "lib/dtutils.string" local ds = require "lib/dtutils.string"
local dtsys = require "lib/dtutils.system" local dtsys = require "lib/dtutils.system"
local log = require "lib/dtutils.log" local log = require "lib/dtutils.log"
local debug = require "darktable.debug" local dtdebug = require "darktable.debug"
local gettext = dt.gettext.gettext local gettext = dt.gettext.gettext
@@ -62,6 +62,19 @@ local function _(msgid)
return gettext(msgid) return gettext(msgid)
end end
-- figure out where we are running from
local info = debug.getinfo(1, "S")
local scripts_path = info.source:sub(2)
-- assume user based
local system_based = false
if string.match(scripts_path, ds.sanitize_lua(dt.configuration.data_dir)) then
log.msg(log.info, "script_manager using system based scripts")
system_based = true
end
-- api check -- api check
-- du.check_min_api_version("9.3.0", "script_manager") -- du.check_min_api_version("9.3.0", "script_manager")
@@ -71,7 +84,7 @@ end
-- - - - - - - - - - - - - - - - - - - - - - - - -- - - - - - - - - - - - - - - - - - - - - - - -
-- script_manager required API version -- script_manager required API version
local SM_API_VER_REQD <const> = "9.3.0" local SM_API_VER_REQD <const> = "9.7.0"
-- path separator -- path separator
local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/" local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/"
@@ -85,21 +98,44 @@ local MIN_BUTTONS_PER_PAGE <const> = 5
local MAX_BUTTONS_PER_PAGE <const> = 20 local MAX_BUTTONS_PER_PAGE <const> = 20
local DEFAULT_BUTTONS_PER_PAGE <const> = 10 local DEFAULT_BUTTONS_PER_PAGE <const> = 10
local DEFAULT_LOG_LEVEL <const> = log.warn local DEFAULT_LOG_LEVEL <const> = log.info
local USER_LUA_DIR <const> = dt.configuration.config_dir .. PS .. "lua"
local SYSTEM_LUA_DIR <const> = dt.configuration.data_dir .. PS .. "lua-scripts"
local LUA_DIR <const> = dt.configuration.config_dir .. PS .. "lua"
local LUA_SCRIPT_REPO <const> = "https://github.com/darktable-org/lua-scripts.git" local LUA_SCRIPT_REPO <const> = "https://github.com/darktable-org/lua-scripts.git"
local LUA_API_VER <const> = "API-" .. dt.configuration.api_version_string local LUA_API_VER <const> = "API-" .. dt.configuration.api_version_string
-- local POWER_ICON = dt.configuration.config_dir .. "/lua/data/data/icons/power.png" -- local POWER_ICON = dt.configuration.config_dir .. "/lua/data/data/icons/power.png"
local POWER_ICON <const> = dt.configuration.config_dir .. "/lua/data/icons/path20.png" local USER_POWER_ICON <const> = dt.configuration.config_dir .. "/lua/data/icons/path20.png"
local BLANK_ICON <const> = dt.configuration.config_dir .. "/lua/data/icons/blank20.png" local USER_BLANK_ICON <const> = dt.configuration.config_dir .. "/lua/data/icons/blank20.png"
local SYSTEM_POWER_ICON <const> = dt.configuration.data_dir .. "/lua-scripts/data/icons/path20.png"
local SYSTEM_BLANK_ICON <const> = dt.configuration.data_dir .. "/lua-scripts/data/icons/blank20.png"
local LUA_DIR = USER_LUA_DIR
local POWER_ICON = USER_POWER_ICON
local BLANK_ICON = USER_BLANK_ICON
if system_based then
LUA_DIR = SYSTEM_LUA_DIR
POWER_ICON = SYSTEM_POWER_ICON
BLANK_ICON = SYSTEM_BLANK_ICON
end
-- - - - - - - - - - - - - - - - - - - - - - - - -- - - - - - - - - - - - - - - - - - - - - - - -
-- P R E F E R E N C E S -- P R E F E R E N C E S
-- - - - - - - - - - - - - - - - - - - - - - - - -- - - - - - - - - - - - - - - - - - - - - - - -
dt.preferences.register(MODULE, "disable_scripts", "bool",
_("disable Lua scripts"),
_("do not run the Lua scripts"),
false)
if dt.preferences.read(MODULE, "disable_scripts", "bool") then
return
end
dt.preferences.register(MODULE, "check_update", "bool", dt.preferences.register(MODULE, "check_update", "bool",
_("check for updated scripts on start up"), _("check for updated scripts on start up"),
_("automatically update scripts to correct version"), _("automatically update scripts to correct version"),
@@ -712,7 +748,7 @@ local function scan_scripts(script_dir)
local find_cmd = "find -L " .. script_dir .. " -name \\*.lua -print | sort" local find_cmd = "find -L " .. script_dir .. " -name \\*.lua -print | sort"
if dt.configuration.running_os == "windows" then if dt.configuration.running_os == "windows" then
find_cmd = "dir /b/s \"" .. script_dir .. "\\*.lua\" | sort" find_cmd = "dir /b/s \"" .. script_dir .. "\\*.lua\" 2>nul | sort"
end end
log.msg(log.debug, "find command is " .. find_cmd) log.msg(log.debug, "find command is " .. find_cmd)
@@ -721,7 +757,12 @@ local function scan_scripts(script_dir)
local output = io.popen(find_cmd) local output = io.popen(find_cmd)
for line in output:lines() do for line in output:lines() do
log.msg(log.debug, "line is " .. line) log.msg(log.debug, "line is " .. line)
local l = string.gsub(line, ds.sanitize_lua(LUA_DIR) .. PS, "") -- strip the lua dir off local script_dir = SYSTEM_LUA_DIR
if string.match(line, ds.sanitize_lua(USER_LUA_DIR)) then
script_dir = USER_LUA_DIR
end
-- add a string.match to figure out which lua dir to use
local l = string.gsub(line, ds.sanitize_lua(script_dir) .. PS, "") -- strip the lua dir off
local script_file = l:sub(1,-5) -- strip off .lua\n local script_file = l:sub(1,-5) -- strip off .lua\n
if not string.match(script_file, "script_manager") then -- let's not include ourself if not string.match(script_file, "script_manager") then -- let's not include ourself
if not string.match(script_file, "plugins") then -- skip plugins if not string.match(script_file, "plugins") then -- skip plugins
@@ -781,7 +822,9 @@ local function update_script_update_choices()
for i, repo in ipairs(sm.installed_repositories) do for i, repo in ipairs(sm.installed_repositories) do
table.insert(installs, repo.name) table.insert(installs, repo.name)
pref_string = pref_string .. i .. "," .. repo.name .. "," .. repo.directory .. "," if not string.match(pref_string, ds.sanitize_lua(repo.name)) then
pref_string = pref_string .. i .. "," .. repo.name .. "," .. repo.directory .. ","
end
end end
update_combobox_choices(sm.widgets.update_script_choices, installs, 1) update_combobox_choices(sm.widgets.update_script_choices, installs, 1)
@@ -799,7 +842,7 @@ local function scan_repositories()
local find_cmd = "find -L " .. LUA_DIR .. " -name \\*.git -print | sort" local find_cmd = "find -L " .. LUA_DIR .. " -name \\*.git -print | sort"
if dt.configuration.running_os == "windows" then if dt.configuration.running_os == "windows" then
find_cmd = "dir /b/s /a:d " .. LUA_DIR .. PS .. "*.git | sort" find_cmd = "dir /b/s /a:d " .. LUA_DIR .. PS .. "*.git 2>nul | sort"
end end
log.msg(log.debug, "find command is " .. find_cmd) log.msg(log.debug, "find command is " .. find_cmd)
@@ -851,8 +894,17 @@ local function install_scripts()
local url = sm.widgets.script_url.text local url = sm.widgets.script_url.text
local folder = sm.widgets.new_folder.text local folder = sm.widgets.new_folder.text
if not df.test_file(USER_LUA_DIR, "d") then
df.mkdir(USER_LUA_DIR)
if not df.test_file(USER_LUA_DIR, "d") then
log.msg(log.error, "unable to create user lua directory")
log.msg(log.screen, _("unable to create user lua directory, installing additional scripts failed"))
return
end
end
if string.match(du.join(sm.folders, " "), ds.sanitize_lua(folder)) then if string.match(du.join(sm.folders, " "), ds.sanitize_lua(folder)) then
log.msg(log.screen, _(string.format("folder %s is already in use. Please specify a different folder name.", folder))) log.msg(log.screen, string.format(_("folder %s is already in use. Please specify a different folder name."), folder))
log.msg(log.error, "folder " .. folder .. " already exists, returning...") log.msg(log.error, "folder " .. folder .. " already exists, returning...")
restore_log_level(old_log_level) restore_log_level(old_log_level)
return return
@@ -868,7 +920,7 @@ local function install_scripts()
return return
end end
local git_command = "cd " .. LUA_DIR .. " " .. CS .. " " .. git .. " clone " .. url .. " " .. folder local git_command = "cd " .. USER_LUA_DIR .. " " .. CS .. " " .. git .. " clone " .. url .. " " .. folder
log.msg(log.debug, "update git command is " .. git_command) log.msg(log.debug, "update git command is " .. git_command)
if dt.configuration.running_os == "windows" then if dt.configuration.running_os == "windows" then
@@ -880,12 +932,12 @@ local function install_scripts()
log.msg(log.info, "result from import is " .. result) log.msg(log.info, "result from import is " .. result)
if result == 0 then if result == 0 then
local count = scan_scripts(LUA_DIR .. PS .. folder) local count = scan_scripts(USER_LUA_DIR)
if count > 0 then if count > 0 then
update_combobox_choices(sm.widgets.folder_selector, sm.folders, sm.widgets.folder_selector.selected) update_combobox_choices(sm.widgets.folder_selector, sm.folders, sm.widgets.folder_selector.selected)
dt.print(_(string.format("scripts successfully installed into folder %s"), folder)) dt.print(string.format(_("scripts successfully installed into folder %s"), folder))
table.insert(sm.installed_repositories, {name = folder, directory = LUA_DIR .. PS .. folder}) table.insert(sm.installed_repositories, {name = folder, directory = USER_LUA_DIR .. PS .. folder})
update_script_update_choices() update_script_update_choices()
for i = 1, #sm.widgets.folder_selector do for i = 1, #sm.widgets.folder_selector do
@@ -991,6 +1043,7 @@ local function populate_buttons(folder, first, last)
button_num = button_num + 1 button_num = button_num + 1
end end
log.msg(log.debug, "button num is " .. button_num .. " and num_buttons is " .. sm.page_status.num_buttons)
if button_num <= sm.page_status.num_buttons then if button_num <= sm.page_status.num_buttons then
for i = button_num, sm.page_status.num_buttons do for i = button_num, sm.page_status.num_buttons do
clear_button(i) clear_button(i)
@@ -1243,7 +1296,7 @@ end
script_manager_running_script = "script_manager" script_manager_running_script = "script_manager"
if check_for_updates or SM_API_VER_REQD > dt.configuration.api_version_string then if (check_for_updates or SM_API_VER_REQD > dt.configuration.api_version_string) and not system_based then
local repo_data = get_repo_status(LUA_DIR) local repo_data = get_repo_status(LUA_DIR)
local current_branch = get_current_repo_branch(LUA_DIR) local current_branch = get_current_repo_branch(LUA_DIR)
local clean = is_repo_clean(repo_data) local clean = is_repo_clean(repo_data)
@@ -1323,7 +1376,26 @@ if check_for_updates or SM_API_VER_REQD > dt.configuration.api_version_string th
end end
scan_scripts(LUA_DIR) scan_scripts(LUA_DIR)
log.msg(log.debug, "finished processing scripts") log.msg(log.info, "finished processing scripts")
if system_based then
-- exec user luarc file if it exists
local luarc = loadfile(USER_LUA_DIR .. "rc")
if luarc then
luarc()
end
if df.test_file(USER_LUA_DIR, "d") then
LUA_DIR = USER_LUA_DIR
scan_scripts(LUA_DIR)
LUA_DIR = SYSTEM_LUA_DIR
end
dt.configuration["lua_dir"] = LUA_DIR
dt.print_log("lua dir is " .. dt.configuration.lua_dir)
-- scan_script(USER_LUA_DIR)
end
-- - - - - - - - - - - - - - - - - - - - - - - - -- - - - - - - - - - - - - - - - - - - - - - - -
@@ -1564,7 +1636,7 @@ script_manager_running_script = nil
-- D A R K T A B L E I N T E G R A T I O N -- 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 if (dt.gui.current_view().id == "lighttable") and darktable_gui_safe then
install_module() install_module()
else else
if not sm.event_registered then if not sm.event_registered then