diff --git a/contrib/ultrahdr.lua b/contrib/ultrahdr.lua index e17c2a6..35d0203 100644 --- a/contrib/ultrahdr.lua +++ b/contrib/ultrahdr.lua @@ -79,6 +79,7 @@ local GUI = { executable_path_widget = {}, quality_widget = {}, gainmap_downsampling_widget = {}, + target_display_peak_nits_widget = {} }, options = {}, run = {} @@ -100,6 +101,7 @@ local PS = dt.configuration.running_os == "windows" and "\\" or "/" local ENCODING_VARIANT_SDR_AND_GAINMAP = 1 local ENCODING_VARIANT_SDR_AND_HDR = 2 local ENCODING_VARIANT_SDR_AUTO_GAINMAP = 3 +local ENCODING_VARIANT_HDR_ONLY = 4 local SELECTION_TYPE_ONE_STACK = 1 local SELECTION_TYPE_GROUP_BY_FNAME = 2 @@ -146,7 +148,11 @@ local function save_preferences() 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, "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) + end local function default_to(value, default) @@ -179,7 +185,10 @@ local function load_preferences() 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.gainmap_downsampling_widget.value = default_to(dt.preferences.read(namespace, "gainmap_downsampling", "integer"), 0) + 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) end -- Changes the combobox selection blindly until a paired config value is set. @@ -192,11 +201,12 @@ local function set_combobox(path, instance, config_name, new_config_value) end dt.gui.action(path, 0, "selection", "first", 1.0) + dt.control.sleep(50) 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(10) + dt.control.sleep(50) 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 @@ -224,7 +234,8 @@ local function assert_settings_correct(encoding_variant) hdr_capacity_max = GUI.optionwidgets.hdr_capacity_max.value }, quality = GUI.optionwidgets.quality_widget.value, - downsample = 2^GUI.optionwidgets.gainmap_downsampling_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. } @@ -263,23 +274,27 @@ end local function get_stacks(images, encoding_variant, selection_type) local stacks = {} - local extra_image_content_type + local primary = "sdr" + local extra if encoding_variant == ENCODING_VARIANT_SDR_AND_GAINMAP then - extra_image_content_type = "gainmap" + extra = "gainmap" elseif encoding_variant == ENCODING_VARIANT_SDR_AND_HDR then - extra_image_content_type = "hdr" + extra = "hdr" elseif encoding_variant == ENCODING_VARIANT_SDR_AUTO_GAINMAP then - extra_image_content_type = nil + extra = nil + elseif encoding_variant == ENCODING_VARIANT_HDR_ONLY then + extra = nil + primary = "hdr" end local tags = nil - -- Group images into (sdr [,extra]) stacks - -- Assume that the first encountered image from each stack is an sdr one, unless it has a tag matching the expected extra_image_type, or has the expected extension + -- 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_image_content_type and tag.name == extra_image_content_type then + if extra and tag.name == extra then is_extra = true end end @@ -296,27 +311,27 @@ local function get_stacks(images, encoding_variant, selection_type) if stacks[key] == nil then stacks[key] = {} end - if extra_image_content_type and (is_extra or stacks[key]["sdr"]) then + if extra and (is_extra or stacks[key][primary]) then -- Don't overwrite existing entries - if not stacks[key][extra_image_content_type] then - stacks[key][extra_image_content_type] = v + 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]["sdr"] then - stacks[key]["sdr"] = v + 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_image_content_type then - if not v["sdr"] or not v[extra_image_content_type] then + if extra then + if not v[primary] or not v[extra] then stacks[k] = nil else - local sdr_w, sdr_h = get_dimensions(v["sdr"]) - local extra_w, extra_h = get_dimensions(v[extra_image_content_type]) + 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 @@ -346,6 +361,7 @@ end local function generate_ultrahdr(encoding_variant, images, settings, step, total_steps) local total_substeps local substep = 0 + local best_source_image local uhdr local errors = {} local remove_files = {} @@ -408,6 +424,7 @@ local function generate_ultrahdr(encoding_variant, images, settings, step, total 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") @@ -456,9 +473,13 @@ local function generate_ultrahdr(encoding_variant, images, settings, step, total -- Merge files uhdr = df.chop_filetype(sdr) .. "_ultrahdr.jpg" table.insert(remove_files, uhdr) - cmd = settings.bin.ultrahdr_app .. " -m 0 -i " .. df.sanitize_filename(sdr .. ".noexif") .. " -g " .. - df.sanitize_filename(gainmap) .. " -f " .. df.sanitize_filename(metadata_file) .. " -z " .. - df.sanitize_filename(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 @@ -476,6 +497,7 @@ local function generate_ultrahdr(encoding_variant, images, settings, step, total 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) .. @@ -528,15 +550,26 @@ local function generate_ultrahdr(encoding_variant, images, settings, step, total 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)) + 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 .. " -m 0 -y " .. df.sanitize_filename(sdr_raw) .. " -p " .. - df.sanitize_filename(hdr_raw) .. - string.format(" -a 0 -b 3 -c 1 -C 1 -t 2 -M 0 -s 1 -q %d -Q %d -D 1 ", settings.quality, - settings.quality) .. string.format(" -s %d ", settings.downsample) .. " -w " .. tostring(sdr_w - sdr_w % 2) .. " -h " .. tostring(sdr_h - sdr_h % 2) .. - " -z " .. df.sanitize_filename(uhdr) + 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 @@ -551,13 +584,82 @@ local function generate_ultrahdr(encoding_variant, images, settings, step, total 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_dir = settings.use_original_dir and images["sdr"].path or settings.output + local output_dir = settings.use_original_dir and best_source_image.path or settings.output local output_file = df.create_unique_filename(output_dir .. PS .. df.get_filename(uhdr)) ok = df.file_move(uhdr, output_file) if not ok then - table.insert(errors, string.format(_("Error generating UltraHDR for %s"), images["sdr"].filename)) + table.insert(errors, string.format(_("Error generating UltraHDR for %s"), best_source_image.filename)) return cleanup(), errors end if settings.import_to_darktable then @@ -593,7 +695,9 @@ local function main() 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)) + 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)) @@ -737,10 +841,11 @@ 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. -]]), _("SDR + gain map"), _("SDR + HDR"), _("SDR only")), +]]), _("SDR + gain map"), _("SDR + HDR"), _("SDR only"), _("HDR only")), selected = 0, changed_callback = function(self) GUI.run.sensitive = self.selected and self.selected > 0 @@ -754,7 +859,8 @@ You can force the image into a specific stack slot by attaching "hdr" / "gainmap end, _("SDR + gain map"), -- ENCODING_VARIANT_SDR_AND_GAINMAP _("SDR + HDR"), -- ENCODING_VARIANT_SDR_AND_HDR - _("SDR only") -- ENCODING_VARIANT_SDR_AUTO_GAINMAP + _("SDR only"), -- ENCODING_VARIANT_SDR_AUTO_GAINMAP + _("HDR only") -- ENCODING_VARIANT_HDR_ONLY } GUI.optionwidgets.selection_type_combo = dt.new_widget("combobox") { @@ -787,9 +893,24 @@ GUI.optionwidgets.quality_widget = dt.new_widget("slider") { 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)'), + 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, @@ -807,6 +928,7 @@ GUI.optionwidgets.encoding_settings_box = dt.new_widget("box") { 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 }