--[[ UltraHDR image generation for darktable copyright (c) 2024 Krzysztof Kotowicz 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/>. ]] --[[ ULTRAHDR Generate UltraHDR JPEG images from various combinations of source files (SDR, HDR, gain map). https://developer.android.com/media/platform/hdr-image-format The images are merged using libultrahdr example application (ultrahdr_app). ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT * ultrahdr_app (built using https://github.com/google/libultrahdr/blob/main/docs/building.md instructions) * exiftool * ffmpeg USAGE * require this file from your main luarc config file * set binary tool paths * Use UltraHDR module to generate UltraHDR images from selection ]] 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" local dtsys = require "lib/dtutils.system" local dd = require "lib/dtutils.debug" local gettext = dt.gettext.gettext local namespace <const> = "ultrahdr" local LOG_LEVEL <const> = log.info -- works with darktable API version from 4.8.0 on du.check_min_api_version("9.3.0", "ultrahdr") local function _(msgid) return gettext(msgid) end local job local GUI = { optionwidgets = { settings_label = {}, encoding_variant_combo = {}, selection_type_combo = {}, encoding_settings_box = {}, output_settings_label = {}, output_settings_box = {}, output_filepath_label = {}, output_filepath_widget = {}, overwrite_on_conflict = {}, copy_exif = {}, import_to_darktable = {}, min_content_boost = {}, max_content_boost = {}, hdr_capacity_min = {}, hdr_capacity_max = {}, metadata_label = {}, metadata_box = {}, edit_executables_button = {}, executable_path_widget = {}, quality_widget = {}, gainmap_downsampling_widget = {}, target_display_peak_nits_widget = {} }, options = {}, run = {} } local flags = {} flags.event_registered = false -- keep track of whether we've added an event callback or not flags.module_installed = false -- keep track of whether the module is module_installed local script_data = {} script_data.metadata = { name = _("UltraHDR"), purpose = _("generate UltraHDR images"), author = "Krzysztof Kotowicz" } local PS <const> = dt.configuration.running_os == "windows" and "\\" or "/" local ENCODING_VARIANT_SDR_AND_GAINMAP <const> = 1 local ENCODING_VARIANT_SDR_AND_HDR <const> = 2 local ENCODING_VARIANT_SDR_AUTO_GAINMAP <const> = 3 local ENCODING_VARIANT_HDR_ONLY <const> = 4 local SELECTION_TYPE_ONE_STACK <const> = 1 local SELECTION_TYPE_GROUP_BY_FNAME <const> = 2 -- Values are defined in darktable/src/common/colorspaces.h local DT_COLORSPACE_PQ_P3 <const> = 24 local DT_COLORSPACE_DISPLAY_P3 <const> = 26 -- 1-based position of a colorspace in export profile combobox. local COLORSPACE_TO_GUI_ACTION <const> = { [DT_COLORSPACE_PQ_P3] = 9, [DT_COLORSPACE_DISPLAY_P3] = 11 } local UI_SLEEP_MS <const> = 50 -- How many ms to sleep after UI action. local function set_log_level(level) local old_log_level = log.log_level() log.log_level(level) return old_log_level end local function restore_log_level(level) log.log_level(level) end local function generate_metadata_file(settings) local old_log_level = set_log_level(LOG_LEVEL) local metadata_file_fmt = [[--maxContentBoost %f --minContentBoost %f --gamma 1.0 --offsetSdr 0.0 --offsetHdr 0.0 --hdrCapacityMin %f --hdrCapacityMax %f]] local filename = df.create_unique_filename(settings.tmpdir .. PS .. "metadata.cfg") local f, err = io.open(filename, "w+") if not f then dt.print(err) return nil end local content = string.format(metadata_file_fmt, settings.metadata.max_content_boost, settings.metadata.min_content_boost, settings.metadata.hdr_capacity_min, settings.metadata.hdr_capacity_max) f:write(content) f:close() restore_log_level(old_log_level) return filename end local function save_preferences() local old_log_level = set_log_level(LOG_LEVEL) dt.preferences.write(namespace, "encoding_variant", "integer", GUI.optionwidgets.encoding_variant_combo.selected) dt.preferences.write(namespace, "selection_type", "integer", GUI.optionwidgets.selection_type_combo.selected) dt.preferences.write(namespace, "output_filepath_pattern", "string", GUI.optionwidgets.output_filepath_widget.text) dt.preferences.write(namespace, "overwrite_on_conflict", "bool", GUI.optionwidgets.overwrite_on_conflict.value) dt.preferences.write(namespace, "import_to_darktable", "bool", GUI.optionwidgets.import_to_darktable.value) dt.preferences.write(namespace, "copy_exif", "bool", GUI.optionwidgets.copy_exif.value) if GUI.optionwidgets.min_content_boost.value then dt.preferences.write(namespace, "min_content_boost", "float", GUI.optionwidgets.min_content_boost.value) dt.preferences.write(namespace, "max_content_boost", "float", GUI.optionwidgets.max_content_boost.value) dt.preferences.write(namespace, "hdr_capacity_min", "float", GUI.optionwidgets.hdr_capacity_min.value) dt.preferences.write(namespace, "hdr_capacity_max", "float", GUI.optionwidgets.hdr_capacity_max.value) end dt.preferences.write(namespace, "quality", "integer", GUI.optionwidgets.quality_widget.value) dt.preferences.write(namespace, "gainmap_downsampling", "integer", GUI.optionwidgets.gainmap_downsampling_widget.value) dt.preferences.write(namespace, "target_display_peak_nits", "integer", (GUI.optionwidgets.target_display_peak_nits_widget.value + 0.5) // 1) restore_log_level(old_log_level) end local function default_to(value, default) if value == 0 or value == "" then return default end return value end local function load_preferences() local old_log_level = set_log_level(LOG_LEVEL) -- Since the option #1 is the default, and empty numeric prefs are 0, we can use math.max GUI.optionwidgets.encoding_variant_combo.selected = math.max( dt.preferences.read(namespace, "encoding_variant", "integer"), ENCODING_VARIANT_SDR_AND_GAINMAP) GUI.optionwidgets.selection_type_combo.selected = math.max( dt.preferences.read(namespace, "selection_type", "integer"), SELECTION_TYPE_ONE_STACK) GUI.optionwidgets.output_filepath_widget.text = default_to(dt.preferences.read(namespace, "output_filepath_pattern", "string"), "$(FILE_FOLDER)/$(FILE_NAME)_ultrahdr") GUI.optionwidgets.overwrite_on_conflict.value = dt.preferences.read(namespace, "overwrite_on_conflict", "bool") GUI.optionwidgets.import_to_darktable.value = dt.preferences.read(namespace, "import_to_darktable", "bool") GUI.optionwidgets.copy_exif.value = dt.preferences.read(namespace, "copy_exif", "bool") GUI.optionwidgets.min_content_boost.value = default_to(dt.preferences.read(namespace, "min_content_boost", "float"), 1.0) GUI.optionwidgets.max_content_boost.value = default_to(dt.preferences.read(namespace, "max_content_boost", "float"), 6.0) GUI.optionwidgets.hdr_capacity_min.value = default_to(dt.preferences.read(namespace, "hdr_capacity_min", "float"), 1.0) GUI.optionwidgets.hdr_capacity_max.value = default_to(dt.preferences.read(namespace, "hdr_capacity_max", "float"), 6.0) GUI.optionwidgets.quality_widget.value = default_to(dt.preferences.read(namespace, "quality", "integer"), 95) GUI.optionwidgets.target_display_peak_nits_widget.value = default_to( dt.preferences.read(namespace, "target_display_peak_nits", "integer"), 10000) GUI.optionwidgets.gainmap_downsampling_widget.value = default_to( dt.preferences.read(namespace, "gainmap_downsampling", "integer"), 0) restore_log_level(old_log_level) end local function set_profile(colorspace) local set_directly = true if set_directly then -- New method, with hardcoded export profile values. local old = dt.gui.action("lib/export/profile", 0, "selection", "", "") * -1 local new = COLORSPACE_TO_GUI_ACTION[colorspace] or colorspace log.msg(log.debug, string.format("Changing export profile from %d to %d", old, new)) dt.gui.action("lib/export/profile", 0, "selection", "next", new - old) dt.control.sleep(UI_SLEEP_MS) return old else -- Old method return set_combobox("lib/export/profile", 0, "plugins/lighttable/export/icctype", colorspace) end end -- Changes the combobox selection blindly until a paired config value is set. -- Workaround for https://github.com/darktable-org/lua-scripts/issues/522 local function set_combobox(path, instance, config_name, new_config_value) local old_log_level = set_log_level(LOG_LEVEL) local pref = dt.preferences.read("darktable", config_name, "integer") if pref == new_config_value then return new_config_value end dt.gui.action(path, 0, "selection", "first", 1.0) dt.control.sleep(UI_SLEEP_MS) local limit, i = 30, 0 -- in case there is no matching config value in the first n entries of a combobox. while i < limit do i = i + 1 dt.gui.action(path, 0, "selection", "next", 1.0) dt.control.sleep(UI_SLEEP_MS) if dt.preferences.read("darktable", config_name, "integer") == new_config_value then log.msg(log.debug, string.format("Changed %s from %d to %d", config_name, pref, new_config_value)) return pref end end log.msg(log.error, string.format("Could not change %s from %d to %d", config_name, pref, new_config_value)) restore_log_level(old_log_level) end local function assert_settings_correct(encoding_variant) local old_log_level = set_log_level(LOG_LEVEL) local errors = {} local settings = { bin = { ultrahdr_app = df.check_if_bin_exists("ultrahdr_app"), exiftool = df.check_if_bin_exists("exiftool"), ffmpeg = df.check_if_bin_exists("ffmpeg") }, overwrite_on_conflict = GUI.optionwidgets.overwrite_on_conflict.value, output_filepath_pattern = GUI.optionwidgets.output_filepath_widget.text, import_to_darktable = GUI.optionwidgets.import_to_darktable.value, copy_exif = GUI.optionwidgets.copy_exif.value, metadata = { min_content_boost = GUI.optionwidgets.min_content_boost.value, max_content_boost = GUI.optionwidgets.max_content_boost.value, hdr_capacity_min = GUI.optionwidgets.hdr_capacity_min.value, hdr_capacity_max = GUI.optionwidgets.hdr_capacity_max.value }, quality = GUI.optionwidgets.quality_widget.value, target_display_peak_nits = (GUI.optionwidgets.target_display_peak_nits_widget.value + 0.5) // 1, downsample = 2 ^ GUI.optionwidgets.gainmap_downsampling_widget.value, tmpdir = dt.configuration.tmp_dir, skip_cleanup = false, -- keep temporary files around, for debugging. force_export = true -- if false, will copy source files instead of exporting if the file extension matches the format expectation. } for k, v in pairs(settings.bin) do if not v then table.insert(errors, string.format(_("%s binary not found"), k)) end end if encoding_variant == ENCODING_VARIANT_SDR_AND_GAINMAP or encoding_variant == ENCODING_VARIANT_SDR_AUTO_GAINMAP then if settings.metadata.min_content_boost >= settings.metadata.max_content_boost then table.insert(errors, _("min_content_boost should not be greater than max_content_boost")) end if settings.metadata.hdr_capacity_min >= settings.metadata.hdr_capacity_max then table.insert(errors, _("hdr_capacity_min should not be greater than hdr_capacity_max")) end end restore_log_level(old_log_level) if #errors > 0 then return nil, errors end return settings, nil end local function get_dimensions(image) if image.final_width > 0 then return image.final_width, image.final_height end return image.width, image.height end local function get_stacks(images, encoding_variant, selection_type) local old_log_level = set_log_level(LOG_LEVEL) local stacks = {} local primary = "sdr" local extra if encoding_variant == ENCODING_VARIANT_SDR_AND_GAINMAP then extra = "gainmap" elseif encoding_variant == ENCODING_VARIANT_SDR_AND_HDR then extra = "hdr" elseif encoding_variant == ENCODING_VARIANT_SDR_AUTO_GAINMAP then extra = nil elseif encoding_variant == ENCODING_VARIANT_HDR_ONLY then extra = nil primary = "hdr" end local tags = nil -- Group images into (primary [,extra]) stacks -- Assume that the first encountered image from each stack is a primary one, unless it has a tag matching the expected extra_image_type, or has the expected extension for k, v in pairs(images) do local is_extra = false tags = dt.tags.get_tags(v) for ignore, tag in pairs(tags) do if extra and tag.name == extra then is_extra = true end end if extra_image_extension and df.get_filetype(v.filename) == extra_image_extension then is_extra = true end -- We assume every image in the stack is generated from the same source image file local key if selection_type == SELECTION_TYPE_GROUP_BY_FNAME then key = df.chop_filetype(v.path .. PS .. v.filename) elseif selection_type == SELECTION_TYPE_ONE_STACK then key = "the_one_and_only" end if stacks[key] == nil then stacks[key] = {} end if extra and (is_extra or stacks[key][primary]) then -- Don't overwrite existing entries if not stacks[key][extra] then stacks[key][extra] = v end elseif not is_extra then -- Don't overwrite existing entries if not stacks[key][primary] then stacks[key][primary] = v end end end -- remove invalid stacks local count = 0 for k, v in pairs(stacks) do if extra then if not v[primary] or not v[extra] then stacks[k] = nil else local sdr_w, sdr_h = get_dimensions(v[primary]) local extra_w, extra_h = get_dimensions(v[extra]) if (sdr_w ~= extra_w) or (sdr_h ~= extra_h) then stacks[k] = nil end end end if stacks[k] then count = count + 1 end end restore_log_level(old_log_level) return stacks, count end local function stop_job(job) job.valid = false end local function file_size(path) local f, err = io.open(path, "r") if not f then return 0 end local size = f:seek("end") f:close() return size end local function generate_ultrahdr(encoding_variant, images, settings, step, total_steps) local old_log_level = set_log_level(LOG_LEVEL) local total_substeps local substep = 0 local best_source_image local uhdr local errors = {} local remove_files = {} local ok local cmd local function execute_cmd(cmd, errormsg) log.msg(log.debug, cmd) local code = dtsys.external_command(cmd) if errormsg and code > 0 then table.insert(errors, errormsg) end return code == 0 end function update_job_progress() substep = substep + 1 if substep > total_substeps then log.msg(log.debug, string.format("total_substeps count is too low for encoding_variant %d", encoding_variant)) end job.percent = (total_substeps * step + substep) / (total_steps * total_substeps) end function copy_or_export(src_image, dest, format, colorspace, props) -- Workaround for https://github.com/darktable-org/darktable/issues/17528 local needs_workaround = dt.configuration.api_version_string == "9.3.0" if not settings.force_export and df.get_filetype(src_image.filename) == df.get_filetype(dest) and not src_image.is_altered then return df.file_copy(src_image.path .. PS .. src_image.filename, dest) else local prev = set_profile(colorspace) if not prev then return false end local exporter = dt.new_format(format) for k, v in pairs(props) do exporter[k] = v end local ok = exporter:write_image(src_image, dest) if needs_workaround then ok = not ok end log.msg(log.info, string.format("Exporting %s to %s (format: %s): %s", src_image.filename, dest, format, ok)) if prev then set_profile(prev) end return ok end return true end function cleanup() if settings.skip_cleanup then return false end for _, v in pairs(remove_files) do os.remove(v) end return false end if encoding_variant == ENCODING_VARIANT_SDR_AND_GAINMAP or encoding_variant == ENCODING_VARIANT_SDR_AUTO_GAINMAP then total_substeps = 5 best_source_image = images["sdr"] -- Export/copy both SDR and gainmap to JPEGs local sdr = df.create_unique_filename(settings.tmpdir .. PS .. df.chop_filetype(images["sdr"].filename) .. ".jpg") table.insert(remove_files, sdr) ok = copy_or_export(images["sdr"], sdr, "jpeg", DT_COLORSPACE_DISPLAY_P3, { quality = settings.quality }) if not ok then table.insert(errors, string.format(_("Error exporting %s to %s"), images["sdr"].filename, "jpeg")) return cleanup(), errors end local gainmap if encoding_variant == ENCODING_VARIANT_SDR_AUTO_GAINMAP then -- SDR is also a gainmap gainmap = sdr else gainmap = df.create_unique_filename(settings.tmpdir .. PS .. images["gainmap"].filename .. "_gainmap.jpg") table.insert(remove_files, gainmap) ok = copy_or_export(images["gainmap"], gainmap, "jpeg", DT_COLORSPACE_DISPLAY_P3, { quality = settings.quality }) if not ok then table.insert(errors, string.format(_("Error exporting %s to %s"), images["gainmap"].filename, "jpeg")) return cleanup(), errors end end log.msg(log.debug, string.format("Exported files: %s, %s", sdr, gainmap)) update_job_progress() -- Strip EXIFs table.insert(remove_files, sdr .. ".noexif") cmd = settings.bin.exiftool .. " -all= " .. df.sanitize_filename(sdr) .. " -o " .. df.sanitize_filename(sdr .. ".noexif") if not execute_cmd(cmd, string.format(_("Error stripping EXIF from %s"), sdr)) then return cleanup(), errors end if sdr ~= gainmap then if not execute_cmd(settings.bin.exiftool .. " -all= " .. df.sanitize_filename(gainmap) .. " -overwrite_original", string.format(_("Error stripping EXIF from %s"), gainmap)) then return cleanup(), errors end end update_job_progress() -- Generate metadata.cfg file local metadata_file = generate_metadata_file(settings) table.insert(remove_files, metadata_file) -- Merge files uhdr = df.chop_filetype(sdr) .. "_ultrahdr.jpg" table.insert(remove_files, uhdr) cmd = settings.bin.ultrahdr_app .. string.format(" -m 0 -i %s -g %s -L %d -f %s -z %s", df.sanitize_filename(sdr .. ".noexif"), -- -i df.sanitize_filename(gainmap), -- -g settings.target_display_peak_nits, -- -L df.sanitize_filename(metadata_file), -- -f df.sanitize_filename(uhdr) -- -z ) if not execute_cmd(cmd, string.format(_("Error merging UltraHDR to %s"), uhdr)) then return cleanup(), errors end update_job_progress() -- Copy SDR's EXIF to UltraHDR file if settings.copy_exif then -- Restricting tags to EXIF only, to make sure we won't mess up XMP tags (-all>all). -- This might hapen e.g. when the source files are Adobe gainmap HDRs. cmd = settings.bin.exiftool .. " -tagsfromfile " .. df.sanitize_filename(sdr) .. " -exif " .. df.sanitize_filename(uhdr) .. " -overwrite_original -preserve" if not execute_cmd(cmd, string.format(_("Error adding EXIF to %s"), uhdr)) then return cleanup(), errors end end update_job_progress() elseif encoding_variant == ENCODING_VARIANT_SDR_AND_HDR then total_substeps = 6 best_source_image = images["sdr"] -- https://discuss.pixls.us/t/manual-creation-of-ultrahdr-images/45004/20 -- Step 1: Export HDR to JPEG-XL with DT_COLORSPACE_PQ_P3 local hdr = df.create_unique_filename(settings.tmpdir .. PS .. df.chop_filetype(images["hdr"].filename) .. ".jxl") table.insert(remove_files, hdr) ok = copy_or_export(images["hdr"], hdr, "jpegxl", DT_COLORSPACE_PQ_P3, { bpp = 10, quality = 100, -- lossless effort = 1 -- we don't care about the size, the file is temporary. }) if not ok then table.insert(errors, string.format(_("Error exporting %s to %s"), images["hdr"].filename, "jxl")) return cleanup(), errors end update_job_progress() -- Step 2: Export SDR to PNG local sdr = df.create_unique_filename(settings.tmpdir .. PS .. df.chop_filetype(images["sdr"].filename) .. ".png") table.insert(remove_files, sdr) ok = copy_or_export(images["sdr"], sdr, "png", DT_COLORSPACE_DISPLAY_P3, { bpp = 8 }) if not ok then table.insert(errors, string.format(_("Error exporting %s to %s"), images["sdr"].filename, "png")) return cleanup(), errors end uhdr = df.chop_filetype(sdr) .. "_ultrahdr.jpg" table.insert(remove_files, uhdr) update_job_progress() -- Step 3: Generate libultrahdr RAW images local sdr_raw, hdr_raw = sdr .. ".raw", hdr .. ".raw" table.insert(remove_files, sdr_raw) table.insert(remove_files, hdr_raw) local sdr_w, sdr_h = get_dimensions(images["sdr"]) local resize_cmd = "" if sdr_h % 2 + sdr_w % 2 > 0 then -- needs resizing to even dimensions. resize_cmd = string.format(" -vf 'crop=%d:%d:0:0' ", sdr_w - sdr_w % 2, sdr_h - sdr_h % 2) end local size_in_px = (sdr_w - sdr_w % 2) * (sdr_h - sdr_h % 2) cmd = settings.bin.ffmpeg .. " -i " .. df.sanitize_filename(sdr) .. resize_cmd .. " -pix_fmt rgba -f rawvideo " .. df.sanitize_filename(sdr_raw) if not execute_cmd(cmd, string.format(_("Error generating %s"), sdr_raw)) then return cleanup(), errors end cmd = settings.bin.ffmpeg .. " -i " .. df.sanitize_filename(hdr) .. resize_cmd .. " -pix_fmt p010le -f rawvideo " .. df.sanitize_filename(hdr_raw) if not execute_cmd(cmd, string.format(_("Error generating %s"), hdr_raw)) then return cleanup(), errors end -- sanity check for file sizes (sometimes dt exports different size images if the files were never opened in darktable view) if file_size(sdr_raw) ~= size_in_px * 4 or file_size(hdr_raw) ~= size_in_px * 3 then table.insert(errors, string.format( _("Wrong raw image resolution: %s, expected %dx%d. Try opening the image in darktable mode first."), images["sdr"].filename, sdr_w, sdr_h)) return cleanup(), errors end update_job_progress() cmd = settings.bin.ultrahdr_app .. string.format( " -m 0 -y %s -p %s -a 0 -b 3 -c 1 -C 1 -t 2 -M 0 -q %d -Q %d -L %d -D 1 -s %d -w %d -h %d -z %s", df.sanitize_filename(sdr_raw), -- -y df.sanitize_filename(hdr_raw), -- -p settings.quality, -- -q settings.quality, -- -Q settings.target_display_peak_nits, -- -L settings.downsample, -- -s sdr_w - sdr_w % 2, -- w sdr_h - sdr_h % 2, -- h df.sanitize_filename(uhdr) -- z ) if not execute_cmd(cmd, string.format(_("Error merging %s"), uhdr)) then return cleanup(), errors end update_job_progress() if settings.copy_exif then -- Restricting tags to EXIF only, to make sure we won't mess up XMP tags (-all>all). -- This might hapen e.g. when the source files are Adobe gainmap HDRs. cmd = settings.bin.exiftool .. " -tagsfromfile " .. df.sanitize_filename(sdr) .. " -exif " .. df.sanitize_filename(uhdr) .. " -overwrite_original -preserve" if not execute_cmd(cmd, string.format(_("Error adding EXIF to %s"), uhdr)) then return cleanup(), errors end end update_job_progress() elseif encoding_variant == ENCODING_VARIANT_HDR_ONLY then total_substeps = 5 best_source_image = images["hdr"] -- TODO: Check if exporting to JXL would be ok too. -- Step 1: Export HDR to JPEG-XL with DT_COLORSPACE_PQ_P3 local hdr = df.create_unique_filename(settings.tmpdir .. PS .. df.chop_filetype(images["hdr"].filename) .. ".jxl") table.insert(remove_files, hdr) ok = copy_or_export(images["hdr"], hdr, "jpegxl", DT_COLORSPACE_PQ_P3, { bpp = 10, quality = 100, -- lossless effort = 1 -- we don't care about the size, the file is temporary. }) if not ok then table.insert(errors, string.format(_("Error exporting %s to %s"), images["hdr"].filename, "jxl")) return cleanup(), errors end update_job_progress() -- Step 1: Generate raw HDR image local hdr_raw = df.create_unique_filename(settings.tmpdir .. PS .. df.chop_filetype(images["hdr"].filename) .. ".raw") table.insert(remove_files, hdr_raw) local hdr_w, hdr_h = get_dimensions(images["hdr"]) local resize_cmd = "" if hdr_h % 2 + hdr_w % 2 > 0 then -- needs resizing to even dimensions. resize_cmd = string.format(" -vf 'crop=%d:%d:0:0' ", hdr_w - hdr_w % 2, hdr_h - hdr_h % 2) end local size_in_px = (hdr_w - hdr_w % 2) * (hdr_h - hdr_h % 2) cmd = settings.bin.ffmpeg .. " -i " .. df.sanitize_filename(hdr) .. resize_cmd .. " -pix_fmt p010le -f rawvideo " .. df.sanitize_filename(hdr_raw) if not execute_cmd(cmd, string.format(_("Error generating %s"), hdr_raw)) then return cleanup(), errors end if file_size(hdr_raw) ~= size_in_px * 3 then table.insert(errors, string.format( _("Wrong raw image resolution: %s, expected %dx%d. Try opening the image in darktable mode first."), images["hdr"].filename, hdr_w, hdr_h)) return cleanup(), errors end update_job_progress() uhdr = df.chop_filetype(hdr_raw) .. "_ultrahdr.jpg" table.insert(remove_files, uhdr) cmd = settings.bin.ultrahdr_app .. string.format( " -m 0 -p %s -a 0 -b 3 -c 1 -C 1 -t 2 -M 0 -q %d -Q %d -D 1 -L %d -s %d -w %d -h %d -z %s", df.sanitize_filename(hdr_raw), -- -p settings.quality, -- -q settings.quality, -- -Q settings.target_display_peak_nits, -- -L settings.downsample, -- s hdr_w - hdr_w % 2, -- -w hdr_h - hdr_h % 2, -- -h df.sanitize_filename(uhdr) -- -z ) if not execute_cmd(cmd, string.format(_("Error merging %s"), uhdr)) then return cleanup(), errors end update_job_progress() if settings.copy_exif then -- Restricting tags to EXIF only, to make sure we won't mess up XMP tags (-all>all). -- This might hapen e.g. when the source files are Adobe gainmap HDRs. cmd = settings.bin.exiftool .. " -tagsfromfile " .. df.sanitize_filename(hdr) .. " -exif " .. df.sanitize_filename(uhdr) .. " -overwrite_original -preserve" if not execute_cmd(cmd, string.format(_("Error adding EXIF to %s"), uhdr)) then return cleanup(), errors end end update_job_progress() end local output_file = ds.substitute(best_source_image, step + 1, settings.output_filepath_pattern) .. ".jpg" if not settings.overwrite_on_conflict then output_file = df.create_unique_filename(output_file) end local output_path = ds.get_path(output_file) df.mkdir(output_path) ok = df.file_move(uhdr, output_file) if not ok then table.insert(errors, string.format(_("Error generating UltraHDR for %s"), best_source_image.filename)) return cleanup(), errors end if settings.import_to_darktable then local img = dt.database.import(output_file) -- Add "ultrahdr" tag to the imported image local tagnr = dt.tags.find("ultrahdr") if tagnr == nil then dt.tags.create("ultrahdr") tagnr = dt.tags.find("ultrahdr") end dt.tags.attach(tagnr, img) end cleanup() update_job_progress() log.msg(log.info, string.format("Generated %s.", df.get_filename(output_file))) dt.print(string.format(_("Generated %s."), df.get_filename(output_file))) restore_log_level(old_log_level) return true, nil end local function main() local old_log_level = set_log_level(LOG_LEVEL) save_preferences() local selection_type = GUI.optionwidgets.selection_type_combo.selected local encoding_variant = GUI.optionwidgets.encoding_variant_combo.selected log.msg(log.info, string.format("using selection type %d, encoding variant %d", selection_type, encoding_variant)) local settings, errors = assert_settings_correct(encoding_variant) if not settings then dt.print(string.format(_("Export settings are incorrect, exiting:\n\n- %s"), table.concat(errors, "\n- "))) return end local stacks, stack_count = get_stacks(dt.gui.selection(), encoding_variant, selection_type) if stack_count == 0 then dt.print(string.format(_( "No image stacks detected.\n\nMake sure that the image pairs have the same widths and heights."), stack_count)) return end dt.print(string.format(_("Detected %d image stack(s)"), stack_count)) job = dt.gui.create_job(_("Generating UltraHDR images"), true, stop_job) local count = 0 local msg for i, v in pairs(stacks) do local ok, errors = generate_ultrahdr(encoding_variant, v, settings, count, stack_count) if not ok then dt.print(string.format(_("Generating UltraHDR images failed:\n\n- %s"), table.concat(errors, "\n- "))) job.valid = false return end count = count + 1 -- sleep for a short moment to give stop_job callback function a chance to run dt.control.sleep(10) end -- stop job and remove progress_bar from ui, but only if not alreay canceled if (job.valid) then job.valid = false end log.msg(log.info, string.format("Generated %d UltraHDR image(s).", count)) dt.print(string.format(_("Generated %d UltraHDR image(s)."), count)) restore_log_level(old_log_level) end GUI.optionwidgets.settings_label = dt.new_widget("section_label") { label = _("UltraHDR settings") } GUI.optionwidgets.output_settings_label = dt.new_widget("section_label") { label = _("output") } GUI.optionwidgets.output_filepath_label = dt.new_widget("label") { label = _("file path pattern"), tooltip = ds.get_substitution_tooltip() } GUI.optionwidgets.output_filepath_widget = dt.new_widget("entry") { tooltip = ds.get_substitution_tooltip(), placeholder = _("e.g. $(FILE_FOLDER)/$(FILE_NAME)_ultrahdr") } GUI.optionwidgets.overwrite_on_conflict = dt.new_widget("check_button") { label = _("overwrite if exists"), tooltip = _( "If the output file already exists, overwrite it. If unchecked, a unique filename will be created instead.") } GUI.optionwidgets.import_to_darktable = dt.new_widget("check_button") { label = _("import UltraHDRs to library"), tooltip = _("Import UltraHDR images to darktable library after generating, with an 'ultrahdr' tag attached.") } GUI.optionwidgets.copy_exif = dt.new_widget("check_button") { label = _("copy EXIF data"), tooltip = _("Copy EXIF data into UltraHDR file(s) from their SDR sources.") } GUI.optionwidgets.output_settings_box = dt.new_widget("box") { orientation = "vertical", GUI.optionwidgets.output_settings_label, GUI.optionwidgets.output_filepath_label, GUI.optionwidgets.output_filepath_widget, GUI.optionwidgets.overwrite_on_conflict, GUI.optionwidgets.import_to_darktable, GUI.optionwidgets.copy_exif } GUI.optionwidgets.metadata_label = dt.new_widget("label") { label = _("gain map metadata") } GUI.optionwidgets.min_content_boost = dt.new_widget("slider") { label = _('min content boost'), tooltip = _( 'How much darker an image can get, when shown on an HDR display, relative to the SDR rendition (linear, SDR = 1.0). Also called "GainMapMin". '), hard_min = 0.9, hard_max = 10, soft_min = 0.9, soft_max = 2, step = 1, digits = 1, reset_callback = function(self) self.value = 1.0 end } GUI.optionwidgets.max_content_boost = dt.new_widget("slider") { label = _('max content boost'), tooltip = _( 'How much brighter an image can get, when shown on an HDR display, relative to the SDR rendition (linear, SDR = 1.0). Also called "GainMapMax". \n\nMust not be lower than Min content boost'), hard_min = 1, hard_max = 10, soft_min = 2, soft_max = 10, step = 1, digits = 1, reset_callback = function(self) self.value = 6.0 end } GUI.optionwidgets.hdr_capacity_min = dt.new_widget("slider") { label = _('min HDR capacity'), tooltip = _('Minimum display boost value for which the gain map is applied at all (linear, SDR = 1.0).'), hard_min = 0.9, hard_max = 10, soft_min = 1, soft_max = 2, step = 1, digits = 1, reset_callback = function(self) self.value = 1.0 end } GUI.optionwidgets.hdr_capacity_max = dt.new_widget("slider") { label = _('max HDR capacity'), tooltip = _('Maximum display boost value for which the gain map is applied completely (linear, SDR = 1.0).'), hard_min = 1, hard_max = 10, soft_min = 2, soft_max = 10, digits = 1, step = 1, reset_callback = function(self) self.value = 6.0 end } GUI.optionwidgets.metadata_box = dt.new_widget("box") { orientation = "vertical", GUI.optionwidgets.metadata_label, GUI.optionwidgets.min_content_boost, GUI.optionwidgets.max_content_boost, GUI.optionwidgets.hdr_capacity_min, GUI.optionwidgets.hdr_capacity_max } GUI.optionwidgets.encoding_variant_combo = dt.new_widget("combobox") { label = _("each stack contains"), tooltip = string.format(_([[Select the types of images in each stack. This will determine the method used to generate UltraHDR. - %s: SDR image paired with a gain map image. - %s: SDR image paired with an HDR image. - %s: Each stack consists of a single SDR image. Gain maps will be copies of SDR images. - %s: Each stack consists of a single HDR image. HDR will be tone mapped to SDR. By default, the first image in a stack is treated as SDR, and the second one is a gain map/HDR. You can force the image into a specific stack slot by attaching "hdr" / "gainmap" tags to it. For HDR source images, apply a log2(203 nits/10000 nits) = -5.62 EV exposure correction before generating UltraHDR.]]), _("SDR + gain map"), _("SDR + HDR"), _("SDR only"), _("HDR only")), selected = 0, changed_callback = function(self) GUI.run.sensitive = self.selected and self.selected > 0 if self.selected == ENCODING_VARIANT_SDR_AND_GAINMAP or self.selected == ENCODING_VARIANT_SDR_AUTO_GAINMAP then GUI.optionwidgets.metadata_box.visible = true GUI.optionwidgets.gainmap_downsampling_widget.visible = false else GUI.optionwidgets.metadata_box.visible = false GUI.optionwidgets.gainmap_downsampling_widget.visible = true end end, _("SDR + gain map"), -- ENCODING_VARIANT_SDR_AND_GAINMAP _("SDR + HDR"), -- ENCODING_VARIANT_SDR_AND_HDR _("SDR only"), -- ENCODING_VARIANT_SDR_AUTO_GAINMAP _("HDR only") -- ENCODING_VARIANT_HDR_ONLY } GUI.optionwidgets.selection_type_combo = dt.new_widget("combobox") { label = _("selection contains"), tooltip = string.format(_([[Select types of images selected in darktable. This determines how the plugin groups images into separate stacks (each stack will produce a single UltraHDR image). - %s: All selected image(s) belong to one stack. There will be 1 output UltraHDR image. - %s: Group images into stacks, using the source image path + filename (ignoring extension). Use this method if the source images for a given stack are darktable duplicates. As an added precaution, each image in a stack needs to have the same resolution. ]]), _("one stack"), _("multiple stacks (use filename)")), selected = 0, _("one stack"), -- SELECTION_TYPE_ONE_STACK _("multiple stacks (use filename)") -- SELECTION_TYPE_GROUP_BY_FNAME } GUI.optionwidgets.quality_widget = dt.new_widget("slider") { label = _('quality'), tooltip = _('Quality of the output UltraHDR JPEG file'), hard_min = 0, hard_max = 100, soft_min = 0, soft_max = 100, step = 1, digits = 0, reset_callback = function(self) self.value = 95 end } GUI.optionwidgets.target_display_peak_nits_widget = dt.new_widget("slider") { label = _('target display peak brightness (nits)'), tooltip = _('Peak brightness of target display in nits (defaults to 10000)'), hard_min = 203, hard_max = 10000, soft_min = 1000, soft_max = 10000, step = 10, digits = 0, reset_callback = function(self) self.value = 10000 end } GUI.optionwidgets.gainmap_downsampling_widget = dt.new_widget("slider") { label = _('gain map downsampling steps'), tooltip = _( 'Exponent (2^x) of the gain map downsampling factor.\nDownsampling reduces the gain map resolution.\n\n0 = don\'t downsample the gain map, 7 = maximum downsampling (128x)'), hard_min = 0, hard_max = 7, soft_min = 0, soft_max = 7, step = 1, digits = 0, reset_callback = function(self) self.value = 0 end } GUI.optionwidgets.encoding_settings_box = dt.new_widget("box") { orientation = "vertical", GUI.optionwidgets.selection_type_combo, GUI.optionwidgets.encoding_variant_combo, GUI.optionwidgets.quality_widget, GUI.optionwidgets.gainmap_downsampling_widget, GUI.optionwidgets.target_display_peak_nits_widget, GUI.optionwidgets.metadata_box } GUI.optionwidgets.executable_path_widget = df.executable_path_widget({"ultrahdr_app", "exiftool", "ffmpeg"}) GUI.optionwidgets.executable_path_widget.visible = false GUI.optionwidgets.edit_executables_button = dt.new_widget("button") { label = _("show / hide executables"), tooltip = _("Show / hide settings for executable files required for the plugin functionality"), clicked_callback = function() GUI.optionwidgets.executable_path_widget.visible = not GUI.optionwidgets.executable_path_widget.visible end } GUI.options = dt.new_widget("box") { orientation = "vertical", GUI.optionwidgets.settings_label, GUI.optionwidgets.encoding_settings_box, GUI.optionwidgets.edit_executables_button, GUI.optionwidgets.executable_path_widget, GUI.optionwidgets.output_settings_box } GUI.run = dt.new_widget("button") { label = _("generate UltraHDR"), tooltip = _("Generate UltraHDR image(s) from selection"), clicked_callback = main } load_preferences() local function install_module() if flags.module_installed then return end dt.register_lib( -- register module namespace, -- Module name _("UltraHDR"), -- name true, -- expandable true, -- resetable { [dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 99} }, -- containers dt.new_widget("box") { orientation = "vertical", GUI.options, GUI.run }, nil, -- view_enter nil -- view_leave ) end local function destroy() dt.gui.libs[namespace].visible = false end local function restart() dt.gui.libs[namespace].visible = true end if dt.gui.current_view().id == "lighttable" then -- make sure we are in lighttable view install_module() -- register the lib else if not flags.event_registered then -- if we are not in lighttable view then register an event to signal when we might be -- https://www.darktable.org/lua-api/index.html#darktable_register_event dt.register_event(namespace, "view-changed", -- we want to be informed when the view changes function(event, old_view, new_view) if new_view.name == "lighttable" and old_view.name == "darkroom" then -- if the view changes from darkroom to lighttable install_module() -- register the lib end end) flags.event_registered = true -- keep track of whether we have an event handler installed end end script_data.destroy = destroy script_data.restart = restart script_data.destroy_method = "hide" script_data.show = restart return script_data