Skip to content

New script submission: export_edit_reload — external edit workflow panel #708

Description

@Tomomoto10

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.lua is 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, imports it into the library grouped with the original, and auto-selects it
  • Copy Original — byte-copies the original file and its XMP sidecar, imports it grouped with the original, and auto-selects it. DT edits are not baked in — they remain on top of any external edits
  • 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. Requires XMP sidecar (prompt shown if missing)
  • Reload Image — reimports the selected image(s), preserving ratings, colour labels and Darktable edits, regenerates thumbnails, and generates an XMP sidecar if one does not exist

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:

  • Available in both lighttable (right panel) and darkroom (left panel)
  • File manager opened via D-Bus org.freedesktop.FileManager1.ShowItems where available (highlights the file), with per-app fallbacks for Nautilus and Dolphin, and xdg-open as a last resort
  • Copies and reimports are tagged with action type and timestamp for audit trail
--[[
  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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions