Not sure if an issue is the right place for this, but submitting here as I'm not familiar with the PR process.
Im welcome to any input on changes / additions needed.
Currently the included external editors script does not work for my workflow as 1. I use Affinity on Linux and the wine wrapper doesn't open the image, and 2. It requires the editor to be closed to update back in Darktable.
With this you can open the image in any program, and can update live in Darktable while editing externally.
Its made my life easier so far, hopefully it helps someone else.
All four actions are also bindable as keyboard shortcuts via Preferences > Shortcuts > Lua.
--[[
export_edit_reload.lua - external edit workflow panel for darktable
Copyright (c) 2026 tomomoto
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/>.
]]
--[[
export_edit_reload.lua
A panel that streamlines the workflow for editing images in an external
application and reloading the changes back into darktable.
Four actions are provided:
Copy and Bake
Exports a 16-bit TIFF with all Darktable edits baked into the pixels,
saves it alongside the original as <filename>_edit.tif, imports it into
the library grouped with the original, and auto-selects it ready for
the next step.
Copy Original
Byte-copies the original file and its XMP sidecar alongside the original,
imports the copy into the library grouped with the original, and
auto-selects it. Darktable edits are not baked in — they remain on top
of any external edits made to the copy.
Open In Browser
Opens the folder of the selected image in the system file manager,
highlighting the file for drag and drop into an external editor. Checks
for an XMP sidecar first — if none exists, prompts the user to enable
XMP writing in Preferences > Storage or run Reload Image to generate
one. XMP is required for all images so that any DT edits made after
import survive the reimport step.
Reload Image
Deletes and reimports the selected image(s), preserving ratings, colour
labels and Darktable edits, then regenerates thumbnails. The reimport
also generates an XMP sidecar if one does not already exist.
TYPICAL WORKFLOW
Copy and Bake (or Copy Original) [copy is auto-selected]
-> Open In Browser -> drag into external editor -> save
-> select image in library -> Reload Image
USAGE
* enable via Script Manager or add to luarc:
require "contrib/export_edit_reload"
* available in both lighttable (right panel) and darkroom (left panel)
* select an image and use the panel buttons or the keyboard shortcuts
* all four actions are bindable via Preferences > Shortcuts > Lua
NOTES
* XMP sidecar writing must be enabled (Preferences > Storage) or Reload
Image must be run at least once before Open In Browser will work. This
ensures any Darktable edits made after import are not lost on reimport.
* The file manager is opened via the D-Bus org.freedesktop.FileManager1
interface where available (highlights the file), with per-app fallbacks
for Nautilus and Dolphin, and xdg-open on the folder as a last resort.
]]
local dt = require "darktable"
local du = require "lib/dtutils"
du.check_min_api_version("7.0.0", "export_edit_reload")
local MODULE = "export_edit_reload"
local PS = dt.configuration.running_os == "windows" and "\\" or "/"
local script_data = {}
script_data.metadata = {
name = "export edit reload",
purpose = "open images for external editing and reimport changes",
author = "tomomoto",
help = ""
}
local module_installed = false
local event_registered = false
local function apply_tag(image, tag_name)
dt.tags.attach(dt.tags.create(tag_name .. " | " .. os.date("%Y-%m-%d %H:%M")), image)
end
local function shell_ok(cmd)
local r = os.execute(cmd)
return r == 0 or r == true
end
local function open_and_select(folder, filepath)
-- D-Bus ShowItems is the universal way to open a file manager and highlight
-- a specific file — supported by Thunar, Nautilus, Dolphin, Nemo, Caja
if shell_ok("which dbus-send > /dev/null 2>&1") then
local ok = shell_ok(
"dbus-send --session --print-reply --dest=org.freedesktop.FileManager1 " ..
"/org/freedesktop/FileManager1 org.freedesktop.FileManager1.ShowItems " ..
"array:string:\"file://" .. filepath .. "\" string:\"\" > /dev/null 2>&1"
)
if ok then return end
end
-- per-app fallbacks with --select support
if shell_ok("which nautilus > /dev/null 2>&1") then
os.execute("nautilus --select \"" .. filepath .. "\" &")
elseif shell_ok("which dolphin > /dev/null 2>&1") then
os.execute("dolphin --select \"" .. filepath .. "\" &")
else
os.execute("xdg-open \"" .. folder .. "\" &")
end
end
local function open_in_browser()
local images = dt.gui.action_images
if #images == 0 then dt.print("No images selected"); return end
local image = images[1]
-- XMP must exist for any image before external editing: without it, any DT
-- edits made after import live only in the database and will be lost on reimport.
-- Reload Image generates the XMP as a side effect.
local xmp_path = image.path .. PS .. image.filename .. ".xmp"
local f = io.open(xmp_path, "r")
if not f then
dt.print("No XMP found — enable in Preferences > Storage, or press Reload Image to create one for this image")
return
end
io.close(f)
open_and_select(image.path, image.path .. PS .. image.filename)
end
local function copy_for_edit()
-- exports a 16-bit TIFF with DT edits baked in
local images = dt.gui.action_images
if #images == 0 then dt.print("No images selected"); return end
local image = images[1]
local basename = image.filename:match("(.+)%..+$") or image.filename
local out_path = image.path .. PS .. basename .. "_edit.tif"
local counter = 1
while io.open(out_path, "r") do
io.close(io.open(out_path, "r"))
out_path = image.path .. PS .. basename .. "_edit_" .. counter .. ".tif"
counter = counter + 1
end
local exporter = dt.new_format("tiff")
exporter.bpp = 16
local ok = exporter:write_image(image, out_path)
if ok then
local new_image = dt.database.import(out_path)
if new_image then
new_image:group_with(image.group_leader)
apply_tag(new_image, "Copy and Baked")
dt.gui.selection({new_image})
end
dt.print("Baked copy created and selected — press Open In Browser to locate it")
else
dt.print("Export failed")
end
end
local function copy_original()
-- byte-copies the original file; DT edits are not baked in
local images = dt.gui.action_images
if #images == 0 then dt.print("No images selected"); return end
local image = images[1]
local src_path = image.path .. PS .. image.filename
local src_ext = (image.filename:match("%.([^%.]+)$") or ""):lower()
local basename = image.filename:match("(.+)%..+$") or image.filename
local out_path = image.path .. PS .. basename .. "_copy." .. src_ext
local counter = 1
while io.open(out_path, "r") do
io.close(io.open(out_path, "r"))
out_path = image.path .. PS .. basename .. "_copy_" .. counter .. "." .. src_ext
counter = counter + 1
end
local ok = os.execute("cp \"" .. src_path .. "\" \"" .. out_path .. "\"")
ok = (ok == 0 or ok == true)
if not ok then dt.print("Copy failed"); return end
-- copy XMP sidecar so the new copy carries the same DT edit history;
-- DT reads the sidecar on import to restore the history stack
local src_xmp = src_path .. ".xmp"
local out_xmp = out_path .. ".xmp"
local xmp_f = io.open(src_xmp, "r")
if xmp_f then
xmp_f:close()
os.execute("cp \"" .. src_xmp .. "\" \"" .. out_xmp .. "\"")
end
local new_image = dt.database.import(out_path)
if new_image then
new_image:group_with(image.group_leader)
apply_tag(new_image, "Copy of Original")
dt.gui.selection({new_image})
end
dt.print("Original copied and selected — press Open In Browser to locate it")
end
local function reload_images()
local images = dt.gui.action_images
if #images == 0 then dt.print("No images selected"); return end
local refreshed = 0
local new_selection = {}
for _, image in ipairs(images) do
local filepath = image.path .. PS .. image.filename
local rating = image.rating
local red, blue, green, yellow, purple = image.red, image.blue, image.green, image.yellow, image.purple
local group_leader = image.group_leader
local is_leader = (image.group_leader == image)
local members = image:get_group_members()
-- hand off group leadership before deleting so the group survives
if is_leader and #members > 1 then
for _, m in ipairs(members) do
if m ~= image then m:make_group_leader(); break end
end
end
image:delete()
-- reimport picks up any external changes on disk and generates an XMP sidecar
local new_image = dt.database.import(filepath)
if new_image then
if not is_leader then new_image:group_with(group_leader) end
new_image.rating = rating
new_image.red = red
new_image.blue = blue
new_image.green = green
new_image.yellow = yellow
new_image.purple = purple
new_image:drop_cache()
new_image:generate_cache(true, 1, 3)
table.insert(new_selection, new_image)
refreshed = refreshed + 1
end
end
for _, img in ipairs(new_selection) do
apply_tag(img, "Externally Edited | Reimported")
end
if #new_selection > 0 then dt.gui.selection(new_selection) end
dt.print(string.format("Refreshed %d image(s)", refreshed))
end
local function sep()
return dt.new_widget("label") { label = "─────────────────────────", halign = "center" }
end
local lbl_browser = dt.new_widget("label") {
label = "Opens the folder of the selected image.\nThe newest copy is auto-selected.",
ellipsize = "none",
halign = "start"
}
local btn_open_browser = dt.new_widget("button") {
label = "Open In Browser",
tooltip = "Opens the folder of the selected image. After copying, the new file is auto-selected so you can press this immediately. Requires XMP — run Reload Image first if none exists.",
clicked_callback = function() open_in_browser() end
}
local lbl_copy_bake = dt.new_widget("label") {
label = "Exports a 16-bit TIFF with DT edits baked\ninto pixels. Imports copy next to original.",
ellipsize = "none",
halign = "start"
}
local btn_copy_bake = dt.new_widget("button") {
label = "Copy and Bake",
tooltip = "Exports a 16-bit TIFF with Darktable edits baked in and imports it into the library grouped with the original.",
clicked_callback = function() copy_for_edit() end
}
local lbl_copy_original = dt.new_widget("label") {
label = "Copies the file and XMP. DT edits\nremain on top of any external edits.",
ellipsize = "none",
halign = "start"
}
local btn_copy_original = dt.new_widget("button") {
label = "Copy Original",
tooltip = "Byte-copies the original file and its XMP sidecar. DT edits are not baked in — they remain on top.",
clicked_callback = function() copy_original() end
}
local lbl_reload = dt.new_widget("label") {
label = "Reimports after external edits.\nDT edits are preserved.",
ellipsize = "none",
halign = "start"
}
local btn_reload = dt.new_widget("button") {
label = "Reload Image",
tooltip = "Reimports the selected image(s) and refreshes thumbnails after external editing.",
clicked_callback = function() reload_images() end
}
local function install_module()
if not module_installed then
dt.register_lib(
MODULE,
"export edit reload",
true,
false,
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 99},
[dt.gui.views.darkroom] = {"DT_UI_CONTAINER_PANEL_LEFT_CENTER", 99}},
dt.new_widget("box") {
orientation = "vertical",
lbl_copy_bake,
btn_copy_bake,
sep(),
lbl_copy_original,
btn_copy_original,
sep(),
lbl_browser,
btn_open_browser,
sep(),
lbl_reload,
btn_reload
},
nil, nil
)
module_installed = true
end
end
-- register keyboard shortcuts for all four actions (bind in Preferences > Shortcuts > Lua)
dt.register_event(MODULE, "shortcut", copy_for_edit, "Copy and Bake selected image")
dt.register_event(MODULE, "shortcut", copy_original, "Copy Original selected image")
dt.register_event(MODULE, "shortcut", open_in_browser, "Open image folder in file browser")
dt.register_event(MODULE, "shortcut", reload_images, "Reload externally edited images")
-- install immediately if already in lighttable, otherwise wait for the view switch
if dt.gui.current_view().id == "lighttable" then
install_module()
else
if not event_registered then
dt.register_event(MODULE, "view-changed",
function(event, old_view, new_view)
if new_view.name == "lighttable" or new_view.name == "darkroom" then
install_module()
end
end
)
event_registered = true
end
end
local function destroy()
dt.destroy_event(MODULE, "shortcut")
dt.destroy_event(MODULE, "view-changed")
dt.gui.libs[MODULE].visible = false
end
script_data.destroy = destroy
script_data.destroy_method = "hide" -- libs cannot be fully destroyed, only hidden
script_data.restart = function() dt.gui.libs[MODULE].visible = true end
script_data.show = function() dt.gui.libs[MODULE].visible = true end
return script_data
Not sure if an issue is the right place for this, but submitting here as I'm not familiar with the PR process.
Im welcome to any input on changes / additions needed.
Currently the included external editors script does not work for my workflow as 1. I use Affinity on Linux and the wine wrapper doesn't open the image, and 2. It requires the editor to be closed to update back in Darktable.
With this you can open the image in any program, and can update live in Darktable while editing externally.
Its made my life easier so far, hopefully it helps someone else.
export_edit_reload.luais a panel that streamlines the workflow for editing images in an external application and reloading the changes back into darktable.Four actions are provided:
All four actions are also bindable as keyboard shortcuts via Preferences > Shortcuts > Lua.
Typical workflow:
Copy and Bake (or Copy Original) → Open In Browser → drag into external editor → save → select image → Reload Image
Notes:
org.freedesktop.FileManager1.ShowItemswhere available (highlights the file), with per-app fallbacks for Nautilus and Dolphin, andxdg-openas a last resort