New script submission: export_edit_reload — external edit workflow panel

未关闭
#708 1 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

还没有人认领这个 Issue。

评估

难度
4/5
预计耗时
3-5 天
新手友好度
45/100
Issue 类型
功能
描述清晰度
基本清楚
活跃度
冷清
技术栈
lua
领域
desktop

调研方向

从 issue 正文中的 export_edit_reload.lua 开始,重点检查其中的 require 语句以及四个操作入口:Copy and Bake、Copy Original、Open In Browser 和 Reload Image。检查 darktable Lua API 的使用情况,并通过 Script Manager 或 luarc 测试安装,包括 lighttable 和 darkroom 两个面板以及快捷键绑定。完成的标准是:脚本连同其外部编辑工作流一起被接受,并且文档化的要求清晰明确。

由索引模型根据 Issue 内容生成。

描述

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
主要语言
Lua
星标
219
派生
142
PR 合并指标
30 天内没有已合并 PR

贡献指南

这个仓库没有索引到贡献指南

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

darktable-org/lua-scripts 的其他 Issue

查看 darktable-org/lua-scripts 的全部 Issue

相似的 Issue

更多 Lua Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。