1 Commits

36 changed files with 220 additions and 6171 deletions

View File

@@ -79,7 +79,6 @@ 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
[transfer_hierarchy](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/transfer_hierarchy)|Yes|LMW|Image move/copy preserving directory hierarchy
[video_ffmpeg](https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/contrib/video_ffmpeg)|No|LMW|Export video from darktable
website_gallery_export|No|LMW|Export a website gallery for selected images
### Example Scripts

View File

@@ -288,13 +288,7 @@ local function GetArgsFromPreference(prog_table, prefix) --for each arg in a pro
local temp = dt.preferences.read(mod, prefix..argument, arg_data.style)
if arg_data.style == 'bool' and temp then
prog_table.arg_string = prog_table.arg_string..arg_data.text..' '
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
elseif arg_data.style == 'integer' or arg_data.style == 'string' then
prog_table.arg_string = prog_table.arg_string..arg_data.text..temp..' '
elseif arg_data.style == 'float' then
temp = string.sub(tostring(temp),1,3)
@@ -351,7 +345,7 @@ local function UpdateENFargs(image_table, prefix) --updates the Enfuse arguments
end
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..' '
elseif GUI.Target.format.value == 'jpg' then ENF.arg_string = ENF.arg_string..'--compression='..tostring(math.floor(GUI.Target.compression_level_jpg.value))..' '
elseif GUI.Target.format.value == 'jpg' then ENF.arg_string = ENF.arg_string..'--compression='..GUI.Target.compression_level_jpg.value..' '
end
if not GUI.Target.source_location.value then out_path = GUI.Target.output_directory.value end
out_name = smallest_name..'-'..largest_id
@@ -498,7 +492,6 @@ local function main(storage, image_table, extra_data)
remove_temp_files(images_to_remove)
dt.print_error(ENF.name..' failed')
dt.print(string.format(_("%s failed"), ENF.name))
job.valid = false
return
end
@@ -1172,4 +1165,4 @@ end
script_data.destroy = destroy
return script_data
return script_data

View File

@@ -328,7 +328,7 @@ local function export2collection(storage, image_table, extra_data)
-- create unique filename
new_name = image.path..PS..df.get_filename(temp_name)
new_name = df.create_unique_filename(new_name)
new_name = df.sanitize_filename(df.create_unique_filename(new_name))
-- move image to collection folder, check result, return if error
move_success = df.file_move(temp_name, new_name)

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,199 +0,0 @@
--[[
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

@@ -1,313 +0,0 @@
--[[
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 destBase = destinationTextBox.text
local destFilms = {}
for film, i in pairs(films) do
for film, _ in pairs(films) do
films[film] = destBase .. string.sub(film.path, #sourceBase+1)
if not pathExists(films[film]) then
if createDirectory(films[film]) == nil then

View File

@@ -1,294 +0,0 @@
--[[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

@@ -1,56 +0,0 @@
/*
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

@@ -1,99 +0,0 @@
/*
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

@@ -1,66 +0,0 @@
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

@@ -1,46 +0,0 @@
<!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

@@ -1,40 +0,0 @@
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

@@ -1,112 +0,0 @@
/*
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

@@ -1,355 +0,0 @@
/*
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;
}
});

View File

@@ -1,323 +0,0 @@
--[[
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 <andrii.ryzhkov@pm.me>",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/ai_denoise"
}
script_data.destroy = destroy
return script_data

View File

@@ -1,412 +0,0 @@
--[[
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
-- - - - - - - - - - - - - - - - - - - - - - - -
-- 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
-- crop off optical-black / masked pixels (Canon and others ship
-- the full sensor buffer including non-light-sensing strips on
-- the top/left). visible_* and crop_* come from the image
-- metadata; we snap crop offsets down to an even pixel so the
-- 2x2 CFA phase at the visible-region origin matches the buffer
-- (otherwise bayer_pack would group the wrong colours)
if meta.crop_x and meta.crop_y
and (meta.crop_x > 0 or meta.crop_y > 0)
then
local cx = meta.crop_x - (meta.crop_x % 2)
local cy = meta.crop_y - (meta.crop_y % 2)
local cw = meta.visible_width + (meta.crop_x - cx)
local ch = meta.visible_height + (meta.crop_y - cy)
-- ensure even dims for bayer_pack
cw = cw - (cw % 2)
ch = ch - (ch % 2)
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 -- 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; the model expects [1,4,H/2,W/2] input
local packed = cfa:bayer_pack()
packed:scale_add(1.0 / range, -black / range)
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
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 <andrii.ryzhkov@pm.me>",
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/ai_raw_denoise"
}
script_data.destroy = destroy
return script_data

View File

@@ -51,10 +51,6 @@ midi:C0=iop/channelmixerrgb;focus
midi:C#0=iop/colorequal;focus
midi:D0=iop/colorequal/page;previous
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"
@@ -63,283 +59,150 @@ local du = require "lib/dtutils"
du.check_min_api_version("9.2.0", "x-touch")
local gettext = dt.gettext.gettext
local function _(msgid)
return gettext(msgid)
local function _(msgid)
return gettext(msgid)
end
-- Constants
local NUM_KNOBS = 8
local NAN = 0/0
local darkroom_view = dt.gui.views.darkroom
-- return data structure for script_manager
-- ============================================================================
-- USER CONFIGURATION
-- ============================================================================
local script_data = {}
-- 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"
}
local DEFAULT_MAPPINGS = {
{"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"}
}
-- 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"}
}
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
-- Priority order for checking module focus (most commonly used first)
local MODULE_CHECK_ORDER = {
"toneequal",
"colorequal",
"colorbalancergb",
"agx",
"sigmoid",
"primaries",
"channelmixerrgb",
"colorzones"
}
-- set up 8 mimic sliders with the same function
for k = 1,8 do
dt.gui.mimic("slider", "knob ".. k,
function(action, element, effect, size)
-- take the number from the mimic name
local k = tonumber(action:sub(-1))
-- ============================================================================
-- CORE LOGIC - No need to edit below unless changing architecture
-- ============================================================================
-- only operate in darkroom; return NAN otherwise
if dt.gui.current_view() ~= dt.gui.views.darkroom then
return 0/0
end
-- Helper functions
local function is_in_darkroom()
return dt.gui.current_view() == darkroom_view
end
local maskval = 0/0
if k < 8 then
-- first try if the mask slider at that position is active
local s = { "opacity",
"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
local function is_valid(value)
return value == value -- NaN check
end
-- try if colorzones module is focused; if so select element of graph
elseif dt.gui.action("iop/colorzones", "focus") ~= 0 then
which = "iop/colorzones/graph"
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
elseif dt.gui.action("iop/colorequal", "focus") ~= 0 then
local e = { "red",
"orange",
"yellow",
"green",
"cyan",
"blue",
"lavender",
"magenta" }
which = "iop/colorequal/graph"
element = e[k]
-- if the sigmoid rgb primaries is focused,
-- check sliders
elseif dt.gui.action("iop/sigmoid", "focus") ~= 0 and k <8 then
local e = { "red attenuation", "red rotation", "green attenuation", "green rotation", "blue attenuation", "blue rotation", "recover purity" }
which = "iop/sigmoid/primaries/"..e[k]
local function translate_tab_effect(effect)
if effect == "up" then
return "next"
elseif effect == "down" then
return "previous"
else
return "activate"
end
end
-- Execute an action configuration
local function execute_action(action_config, element, effect, size)
if not action_config then
return nil
end
local path = action_config[1]
local elem = action_config[2] or element
local action_type = action_config[3]
-- Handle special action types
if action_type == "tab" then
effect = translate_tab_effect(effect)
end
return dt.gui.action(path, elem, effect, size)
end
-- Try mask properties first
local function try_mask_properties(knob_idx, element, effect, size)
if knob_idx >= NUM_KNOBS then
return NAN
end
local mask_action = MASK_PROPERTIES[knob_idx]
if not mask_action then
return NAN
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 the rgb primaries is focused,
-- check sliders
elseif dt.gui.action("iop/primaries", "focus") ~= 0 and k >=1 then
local e = { "red hue", "red purity", "green hue", "green purity", "blue hue", "blue purity", "tint hue", "tint purity" }
which = "iop/primaries/" ..e[k]
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
-- if the tone equalizer is focused,
-- select one of the sliders in the "simple" tab
elseif dt.gui.action("iop/toneequal", "focus") ~= 0 then
which ="iop/toneequal/simple/"..(k-9).." EV"
-- if color calibration is focused,
-- the last 4 knobs are sent there
elseif dt.gui.action("iop/channelmixerrgb", "focus") ~= 0
and k >= 5 then
-- knob 5 selects the active tab; pressing it resets to CAT
if k == 5 then
which = "iop/channelmixerrgb/page"
element = "CAT"
-- since the tab action is not a slider,
-- the effects need to be translated
if effect == "up" then effect = "next"
elseif effect == "down" then effect = "previous"
else effect = "activate"
end
else
-- Profile exists but this knob is nil -> use default
-- This avoids checking other modules when we know the focused one
local default_action = DEFAULT_MAPPINGS[knob_idx]
return execute_action(default_action, element, effect, size) or NAN
-- knobs 6, 7 and 8 are for the three color sliders on each tab
which = "iop/focus/sliders"
local e = { "1st",
"2nd",
"3rd" }
element = e[k - 5]
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
-- If no profile exists for this module, continue checking others
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
-- now pass the element/effect/size to the selected slider
return dt.gui.action(which, element, effect, size)
end)
end
-- Setup mimic sliders
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
local function destroy()
-- nothing to destroy
end
-- 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()
script_data.destroy = destroy
return script_data

View File

@@ -773,29 +773,33 @@ function dtutils_string.build_substitute_list(image, sequence, variable_string,
local labels = get_colorlabels(image)
local emon_long, emon_short
local eyear, emon, eday, ehour, emin, esec, emsec
local datetime_taken = ""
local use_millisecs = false
if dt.preferences.read("darktable", "lighttable/ui/milliseconds", "bool") and is_api_9_1 then
use_millisecs = true
end
if image.exif_datetime_taken and image.exif_datetime_taken ~= "" then
emon_long = os.date("%B", exiftime2systime(image.exif_datetime_taken))
emon_short = os.date("%b", exiftime2systime(image.exif_datetime_taken))
if use_millisecs then
eyear, emon, eday, ehour, emin, esec, emsec =
string.match(image.exif_datetime_taken, "(%d+):(%d+):(%d+) (%d+):(%d+):(%d+)%.(%d+)$")
else
emsec = "0"
eyear, emon, eday, ehour, emin, esec =
string.match(image.exif_datetime_taken, "(%d+):(%d+):(%d+) (%d+):(%d+):(%d+)$")
end
datetime_taken = image.exif_datetime_taken
else
log.msg(log.warn, "no capture date and time found in EXIF data")
emon_long, emon_short, eyear, emon, eday, ehour, emin, esec, emsec = "", "", "", "", "", "", "", "", ""
if use_millisecs then
datetime_taken = "0000:00:00 00:00:00.0"
else
datetime_taken = "0000:00:00 00:00:00"
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
emsec = "0"
eyear, emon, eday, ehour, emin, esec =
string.match(datetime_taken, "(%d+):(%d+):(%d+) (%d+):(%d+):(%d+)$")
end
local version_multi = #image:get_group_members() > 1 and image.duplicate_index or ""
local replacements = {dtutils_string.get_basename(image.film.path),-- ROLL.NAME
@@ -834,10 +838,10 @@ function dtutils_string.build_substitute_list(image, sequence, variable_string,
string.format("%02d", datetime.sec), -- SECOND
0, -- MSEC
eyear, -- EXIF.YEAR
(eyear ~= nil) and string.sub(eyear, 3) or "", -- EXIF.YEAR.SHORT
string.sub(eyear, 3), -- EXIF.YEAR.SHORT
emon, -- EXIF.MONTH
emon_long, -- EXIF.MONTH.LONG
emon_short, -- EXIF.MONTH.SHORT
os.date("%B", exiftime2systime(datetime_taken)), -- EXIF.MONTH.LONG
os.date("%b", exiftime2systime(datetime_taken)), -- EXIF.MONTH.SHORT
eday, -- EXIF.DAY
ehour, -- EXIF.HOUR
"", -- EXIF.HOUR.AMPM
@@ -890,7 +894,7 @@ function dtutils_string.build_substitute_list(image, sequence, variable_string,
home_folder, -- FOLDER.HOME
desktop_folder, -- FOLDER.DESKTOP
"", -- OPENCL.ACTIVATED - wont be implemented
(user_name ~= nil) and user_name or "",-- USERNAME
user_name, -- USERNAME
"", -- NL - wont be implemented
"" -- JOBCODE - wont be implemented
}

View File

@@ -91,10 +91,8 @@ 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("\n")
file:write(command .. "\n")
file:write("set COMMAND_RC=%ERRORLEVEL%")
file:write("\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()
result = dt.control.execute(fname)

View File

@@ -1,288 +0,0 @@
--[[
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

@@ -1,568 +0,0 @@
--[[
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

@@ -1,694 +0,0 @@
--[[
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

@@ -1,343 +0,0 @@
--[[
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.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
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/regenerate_thumbnails" -- URL to help/documentation
help = "https://docs.darktable.org/lua/development/lua.scripts.manual/scripts/official/regenerate_thumbnails" -- URL to help/documentation
}
@@ -173,7 +173,7 @@ local function generate_thumbnails(images)
local has_job = false
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
end
@@ -243,7 +243,7 @@ dt.register_event(MODULE, "shortcut",
if images then
generate_thumbnails(images)
end
end, _("regenerate thumbnails")
end, "regenerate thumbnails"
)
dt.register_event(MODULE, "selection-changed",

View File

@@ -1,225 +0,0 @@
--[[
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

@@ -1,266 +0,0 @@
--[[
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

@@ -1,180 +0,0 @@
--[[
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

@@ -1,168 +0,0 @@
--[[
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

@@ -1,354 +0,0 @@
--[[
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 = {
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",
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
("selection_to_pdf","Open with","string",
_("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"),
_("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"),
"")
local title_widget = dt.new_widget("entry") {
@@ -134,7 +134,7 @@ local function destroy()
dt.print_log("done destroying")
end
dt.register_storage("export_pdf", _("export thumbnails to PDF"),
dt.register_storage("export_pdf", _("export thumbnails to pdf"),
nil,
function(storage,image_table)
local my_title = title_widget.text
@@ -200,7 +200,7 @@ dt.register_storage("export_pdf", _("export thumbnails to PDF"),
command = command .. " " .. pdffile
local result = dt.control.execute(command)
if result ~= 0 then
dt.print(_("problem running PDF viewer")) -- this one is probably usefull to the user
dt.print(_("problem running pdf viewer")) -- this one is probably usefull to the user
error("Problem running "..command)
end

View File

@@ -1,337 +0,0 @@
--[[
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

@@ -67,11 +67,13 @@ end
local info = debug.getinfo(1, "S")
local scripts_path = info.source:sub(2)
dt.print_log("scripts path is " .. scripts_path)
-- 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")
if string.match(scripts_path, dt.configuration.data_dir) then
dt.print_log("script_manager using system based scripts")
system_based = true
end
@@ -98,7 +100,7 @@ local MIN_BUTTONS_PER_PAGE <const> = 5
local MAX_BUTTONS_PER_PAGE <const> = 20
local DEFAULT_BUTTONS_PER_PAGE <const> = 10
local DEFAULT_LOG_LEVEL <const> = log.info
local DEFAULT_LOG_LEVEL <const> = log.warn
local USER_LUA_DIR <const> = dt.configuration.config_dir .. PS .. "lua"
local SYSTEM_LUA_DIR <const> = dt.configuration.data_dir .. PS .. "lua-scripts"
@@ -127,15 +129,6 @@ end
-- 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",
_("check for updated scripts on start up"),
_("automatically update scripts to correct version"),
@@ -757,12 +750,7 @@ local function scan_scripts(script_dir)
local output = io.popen(find_cmd)
for line in output:lines() do
log.msg(log.debug, "line is " .. line)
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 l = string.gsub(line, ds.sanitize_lua(LUA_DIR) .. PS, "") -- strip the lua dir off
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, "plugins") then -- skip plugins
@@ -822,9 +810,7 @@ local function update_script_update_choices()
for i, repo in ipairs(sm.installed_repositories) do
table.insert(installs, repo.name)
if not string.match(pref_string, ds.sanitize_lua(repo.name)) then
pref_string = pref_string .. i .. "," .. repo.name .. "," .. repo.directory .. ","
end
pref_string = pref_string .. i .. "," .. repo.name .. "," .. repo.directory .. ","
end
update_combobox_choices(sm.widgets.update_script_choices, installs, 1)
@@ -894,17 +880,8 @@ local function install_scripts()
local url = sm.widgets.script_url.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
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...")
restore_log_level(old_log_level)
return
@@ -920,7 +897,7 @@ local function install_scripts()
return
end
local git_command = "cd " .. USER_LUA_DIR .. " " .. CS .. " " .. git .. " clone " .. url .. " " .. folder
local git_command = "cd " .. LUA_DIR .. " " .. CS .. " " .. git .. " clone " .. url .. " " .. folder
log.msg(log.debug, "update git command is " .. git_command)
if dt.configuration.running_os == "windows" then
@@ -932,12 +909,12 @@ local function install_scripts()
log.msg(log.info, "result from import is " .. result)
if result == 0 then
local count = scan_scripts(USER_LUA_DIR)
local count = scan_scripts(LUA_DIR .. PS .. folder)
if count > 0 then
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))
table.insert(sm.installed_repositories, {name = folder, directory = USER_LUA_DIR .. PS .. folder})
dt.print(_(string.format("scripts successfully installed into folder %s"), folder))
table.insert(sm.installed_repositories, {name = folder, directory = LUA_DIR .. PS .. folder})
update_script_update_choices()
for i = 1, #sm.widgets.folder_selector do
@@ -1043,7 +1020,6 @@ local function populate_buttons(folder, first, last)
button_num = button_num + 1
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
for i = button_num, sm.page_status.num_buttons do
clear_button(i)
@@ -1376,7 +1352,7 @@ if (check_for_updates or SM_API_VER_REQD > dt.configuration.api_version_string)
end
scan_scripts(LUA_DIR)
log.msg(log.info, "finished processing scripts")
log.msg(log.debug, "finished processing scripts")
if system_based then
-- exec user luarc file if it exists
@@ -1636,7 +1612,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
-- - - - - - - - - - - - - - - - - - - - - - - -
if (dt.gui.current_view().id == "lighttable") and darktable_gui_safe then
if dt.gui.current_view().id == "lighttable" then
install_module()
else
if not sm.event_registered then