diff --git a/.gitmodules b/.gitmodules index 930498d3..7a779a91 100644 --- a/.gitmodules +++ b/.gitmodules @@ -66,3 +66,6 @@ path = dependencies/Little-CMS url = https://github.com/mm2/Little-CMS shallow = true +[submodule "dependencies/libexif"] + path = dependencies/libexif + url = https://github.com/francois-random/libexif diff --git a/CMakeLists.txt b/CMakeLists.txt index 4601257a..cff6cf9d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -166,7 +166,7 @@ if (CMAKE_CXX_COMPILER_ID MATCHES "GNU") set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fcoroutines") endif() -set(TEV_LIBS clip OpenEXR::OpenEXR nanogui ${NANOGUI_EXTRA_LIBS}) +set(TEV_LIBS clip exif_static OpenEXR::OpenEXR nanogui ${NANOGUI_EXTRA_LIBS}) if (MSVC) set(TEV_LIBS ${TEV_LIBS} zlibstatic DirectXTex wsock32 ws2_32) endif() @@ -175,10 +175,12 @@ if (TEV_USE_LIBHEIF) endif() set(TEV_SOURCES + include/tev/imageio/AppleMakerNote.h src/imageio/AppleMakerNote.cpp include/tev/imageio/ClipboardImageLoader.h src/imageio/ClipboardImageLoader.cpp include/tev/imageio/EmptyImageLoader.h src/imageio/EmptyImageLoader.cpp include/tev/imageio/ExrImageLoader.h src/imageio/ExrImageLoader.cpp include/tev/imageio/ExrImageSaver.h src/imageio/ExrImageSaver.cpp + include/tev/imageio/GainMap.h src/imageio/GainMap.cpp include/tev/imageio/ImageLoader.h src/imageio/ImageLoader.cpp include/tev/imageio/ImageSaver.h src/imageio/ImageSaver.cpp include/tev/imageio/PfmImageLoader.h src/imageio/PfmImageLoader.cpp @@ -241,6 +243,7 @@ include_directories( ${ARGS_INCLUDE} ${CLIP_INCLUDE} ${DIRECTXTEX_INCLUDE} + ${EXIF_INCLUDE} ${FMT_INCLUDE} ${GLFW_INCLUDE} ${NANOGUI_EXTRA_INCS} diff --git a/dependencies/CMakeLists.txt b/dependencies/CMakeLists.txt index 489b4db9..bee0317b 100644 --- a/dependencies/CMakeLists.txt +++ b/dependencies/CMakeLists.txt @@ -130,13 +130,7 @@ if (TEV_USE_LIBHEIF) # Little-CMS/plugins/fast_float/src/*.c Little-CMS/plugins/fast_float/include/*.h ) - file(GLOB LCMS_HDRS - Little-CMS/include/*.h - # Little-CMS/plugins/fast_float/include/*.h - ) - - add_library(lcms2 STATIC ${LCMS_SRCS} ${LCMS_HDRS}) - set_target_properties(lcms2 PROPERTIES PUBLIC_HEADER "${LCMS_HDRS}") + add_library(lcms2 STATIC ${LCMS_SRCS}) # We don't use the SSE2 components of CMS in tev; disable to simplify ARM compilation target_compile_definitions(lcms2 PRIVATE -DCMS_DONT_USE_SSE2=1) @@ -146,6 +140,9 @@ if (TEV_USE_LIBHEIF) ) endif() +# Compile libexif for Exif metadata parsing +add_subdirectory(libexif EXCLUDE_FROM_ALL) + # Compile OpenEXR set(IMATH_INSTALL OFF CACHE BOOL " " FORCE) set(IMATH_INSTALL_PKG_CONFIG OFF CACHE BOOL " " FORCE) @@ -180,6 +177,8 @@ set(ARGS_INCLUDE ${CMAKE_CURRENT_SOURCE_DIR}/args PARENT_SCOPE) set(CLIP_INCLUDE ${CMAKE_CURRENT_SOURCE_DIR}/clip PARENT_SCOPE) +set(EXIF_INCLUDE ${CMAKE_CURRENT_SOURCE_DIR}/libexif PARENT_SCOPE) + set(FMT_INCLUDE ${CMAKE_CURRENT_SOURCE_DIR}/fmt/include PARENT_SCOPE) if (NOT ${CMAKE_SYSTEM_NAME} MATCHES "Emscripten") diff --git a/dependencies/libexif b/dependencies/libexif new file mode 160000 index 00000000..38678a1a --- /dev/null +++ b/dependencies/libexif @@ -0,0 +1 @@ +Subproject commit 38678a1a1b123850323a9f6e3e5dfbb712605f57 diff --git a/include/tev/Image.h b/include/tev/Image.h index 43c1b476..6363a9be 100644 --- a/include/tev/Image.h +++ b/include/tev/Image.h @@ -42,6 +42,8 @@ struct ImageData { ImageData() = default; ImageData(const ImageData&) = delete; ImageData(ImageData&&) = default; + ImageData& operator=(const ImageData&) = delete; + ImageData& operator=(ImageData&&) = default; std::vector channels; std::vector layers; diff --git a/include/tev/imageio/AppleMakerNote.h b/include/tev/imageio/AppleMakerNote.h new file mode 100644 index 00000000..07b05514 --- /dev/null +++ b/include/tev/imageio/AppleMakerNote.h @@ -0,0 +1,137 @@ +/* + * tev -- the EXR viewer + * + * Copyright (C) 2025 Thomas Müller + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + +#pragma once + +#include + +#include +#include +#include +#include + +namespace tev { + +bool isAppleMakernote(const uint8_t* data, size_t length); + +template T read(const uint8_t* data, bool reverseEndianness) { + if (reverseEndianness) { + T result; + for (size_t i = 0; i < sizeof(T); ++i) { + reinterpret_cast(&result)[i] = data[sizeof(T) - i - 1]; + } + + return result; + } else { + return *reinterpret_cast(data); + } +} + +struct AppleMakerNoteEntry { + enum class EFormat : uint16_t { + Byte = 1, + Ascii = 2, + Short = 3, + Long = 4, + Rational = 5, + Sbyte = 6, + Undefined = 7, + Sshort = 8, + Slong = 9, + Srational = 10, + Float = 11, + Double = 12, + }; + + uint16_t tag; + EFormat format; + uint32_t nComponents; + std::vector data; + + static size_t formatSize(EFormat format) { + switch (format) { + case EFormat::Byte: + case EFormat::Ascii: + case EFormat::Sbyte: + case EFormat::Undefined: return 1; + case EFormat::Short: + case EFormat::Sshort: return 2; + case EFormat::Long: + case EFormat::Slong: + case EFormat::Float: return 4; + case EFormat::Rational: + case EFormat::Srational: + case EFormat::Double: return 8; + default: + // The default size of 4 for unknown types is chosen to make parsing easier. Larger types would be stored at a remote + // location with the 4 bytes interpreted as an offset, which may be invalid depending on the indended behavior of the + // unknown type. Better play it safe and just read 4 bytes, leaving it to the user to know whether they represent an offset + // or a meaningful value by themselves. + return 4; + } + + throw std::invalid_argument{std::string{"Unknown format: "} + std::to_string((uint32_t)format)}; + } + + size_t size() const { return nComponents * formatSize(format); } +}; + +class AppleMakerNote { +public: + AppleMakerNote(const uint8_t* data, size_t length); + + template T getFloat(uint16_t tag) const { + if (mTags.count(tag) == 0) { + throw std::invalid_argument{"Requested tag does not exist."}; + } + + const auto& entry = mTags.at(tag); + const uint8_t* data = entry.data.data(); + + switch (entry.format) { + case AppleMakerNoteEntry::EFormat::Byte: return static_cast(*data); + case AppleMakerNoteEntry::EFormat::Short: return static_cast(read(data, mReverseEndianess)); + case AppleMakerNoteEntry::EFormat::Long: return static_cast(read(data, mReverseEndianess)); + case AppleMakerNoteEntry::EFormat::Rational: { + uint32_t numerator = read(data, mReverseEndianess); + uint32_t denominator = read(data + sizeof(uint32_t), mReverseEndianess); + return static_cast(numerator) / static_cast(denominator); + } + case AppleMakerNoteEntry::EFormat::Sbyte: return static_cast(*reinterpret_cast(data)); + case AppleMakerNoteEntry::EFormat::Sshort: return static_cast(read(data, mReverseEndianess)); + case AppleMakerNoteEntry::EFormat::Slong: return static_cast(read(data, mReverseEndianess)); + case AppleMakerNoteEntry::EFormat::Srational: { + int32_t numerator = read(data, mReverseEndianess); + int32_t denominator = read(data + sizeof(int32_t), mReverseEndianess); + return static_cast(numerator) / static_cast(denominator); + } + case AppleMakerNoteEntry::EFormat::Float: return static_cast(*reinterpret_cast(data)); + case AppleMakerNoteEntry::EFormat::Double: return static_cast(*reinterpret_cast(data)); + case AppleMakerNoteEntry::EFormat::Ascii: + case AppleMakerNoteEntry::EFormat::Undefined: throw std::invalid_argument{"Cannot convert this format to float."}; + } + + throw std::invalid_argument{"Unknown format."}; + } + +private: + std::map mTags; + bool mReverseEndianess = false; +}; + +} // namespace tev diff --git a/include/tev/imageio/GainMap.h b/include/tev/imageio/GainMap.h new file mode 100644 index 00000000..4717e163 --- /dev/null +++ b/include/tev/imageio/GainMap.h @@ -0,0 +1,33 @@ +/* + * tev -- the EXR viewer + * + * Copyright (C) 2025 Thomas Müller + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + +#pragma once + +#include +#include +#include + +class AppleMakerNote; + +namespace tev { + +Task applyAppleGainMap(ImageData& image, const ImageData& gainMap, int priority, const AppleMakerNote& amn); + +} + + diff --git a/src/imageio/AppleMakerNote.cpp b/src/imageio/AppleMakerNote.cpp new file mode 100644 index 00000000..b52d803c --- /dev/null +++ b/src/imageio/AppleMakerNote.cpp @@ -0,0 +1,96 @@ +/* + * tev -- the EXR viewer + * + * Copyright (C) 2025 Thomas Müller + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + +#include +#include + +using namespace std; + +namespace tev { + +static const uint8_t APPLE_SIGNATURE[] = {0x41, 0x70, 0x70, 0x6C, 0x65, 0x20, 0x69, 0x4F, 0x53, 0x00}; // "Apple iOS\0" +static const size_t SIG_LENGTH = sizeof(APPLE_SIGNATURE); + +bool isAppleMakernote(const uint8_t* data, size_t length) { + + if (length < SIG_LENGTH) { + return false; + } + + return memcmp(data, APPLE_SIGNATURE, SIG_LENGTH) == 0; +} + +// This whole function is one huge hack. It was pieced together by referencing the EXIF spec as well as the (non-functional) implementation +// over at libexif. https://github.com/libexif/libexif/blob/master/libexif/apple/exif-mnote-data-apple.c That, plus quite a bit of trial and +// error, finally got this to work. Who knows when Apple will break it. :) +AppleMakerNote::AppleMakerNote(const uint8_t* data, size_t length) { + mReverseEndianess = false; + + size_t ofs = 0; + if ((data[ofs + 12] == 'M') && (data[ofs + 13] == 'M')) { + mReverseEndianess = std::endian::little == std::endian::native; + } else if ((data[ofs + 12] == 'I') && (data[ofs + 13] == 'I')) { + mReverseEndianess = std::endian::big == std::endian::native; + } else { + throw invalid_argument{"Failed to determine byte order."}; + } + + uint32_t tcount = read(data + ofs + 14, mReverseEndianess); + + if (length < ofs + 16 + tcount * 12 + 4) { + throw invalid_argument{"Too short"}; + } + + ofs += 16; + + for (uint32_t i = 0; i < tcount; i++) { + if (ofs + 12 > length) { + throw invalid_argument{"Overflow"}; + } + + AppleMakerNoteEntry entry; + entry.tag = read(data + ofs, mReverseEndianess); + entry.format = read(data + ofs + 2, mReverseEndianess); + entry.nComponents = read(data + ofs + 4, mReverseEndianess); + + if (ofs + 4 + entry.size() > length) { + throw invalid_argument{"Elem overflow"}; + } + + size_t entryOffset; + if (entry.size() > 4) { + // Entry is stored somewhere else, pointed to by the following + entryOffset = read(data + ofs + 8, mReverseEndianess); // -6? + } else { + entryOffset = ofs + 8; + } + + entry.data = vector(data + entryOffset, data + entryOffset + entry.size()); + + if (entryOffset + entry.size() > length) { + throw invalid_argument{"Offset overflow"}; + } + + ofs += 12; + mTags[entry.tag] = entry; + + tlog::debug() << fmt::format("Tag: {} Format: {} Components: {}", entry.tag, (int)entry.format, entry.nComponents); + } +} + +} // namespace tev diff --git a/src/imageio/GainMap.cpp b/src/imageio/GainMap.cpp new file mode 100644 index 00000000..d017a2bb --- /dev/null +++ b/src/imageio/GainMap.cpp @@ -0,0 +1,102 @@ +/* + * tev -- the EXR viewer + * + * Copyright (C) 2025 Thomas Müller + * + * This program 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. + * + * This program 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 this program. If not, see . + */ + +#include +#include +#include + +using namespace nanogui; +using namespace std; + +namespace tev { + +Task applyAppleGainMap(ImageData& image, const ImageData& gainMap, int priority, const AppleMakerNote& amn) { + auto size = image.channels[0].size(); + auto gainMapSize = gainMap.channels[0].size(); + + // Assumption: gain maps are always a power of 2 smaller than the image, give or take rounding for odd image dimensions. + // Algorithm: find first power of 2 downscale of the image that is smaller than the gain map. + uint32_t shift = 0; + while ((size.x() >> shift) > gainMapSize.x() && (size.y() >> shift) > gainMapSize.y()) { + ++shift; + } + + // Warn if final gainmapsize obtained by shifting is off my more than 1 pixels and don't use the gainmap. + if (abs((int)(size.x() >> shift) - (int)gainMapSize.x()) > 1 || abs((int)(size.y() >> shift) - (int)gainMapSize.y()) > 1) { + tlog::warning() << fmt::format( + "Gain map size {}x{} does not match {}-downscaled image size {}x{}", + gainMapSize.x(), + gainMapSize.y(), + shift, + size.x() >> shift, + size.y() >> shift + ); + + co_return; + } + + // Apply gain map per https://developer.apple.com/documentation/appkit/applying-apple-hdr-effect-to-your-photos + float headroom = 1.0f; + try { + float maker33 = amn.getFloat(33); + float maker48 = amn.getFloat(48); + + tlog::debug() << fmt::format("Maker 33: {} Maker 48: {}", maker33, maker48); + + float stops; + if (maker33 < 1.0f) { + if (maker48 <= 0.01f) { + stops = -20.0f * maker48 + 1.8f; + } else { + stops = -0.101f * maker48 + 1.601f; + } + } else { + if (maker48 <= 0.01f) { + stops = -70.0f * maker48 + 3.0f; + } else { + stops = -0.303f * maker48 + 2.303f; + } + } + + headroom = pow(2.0f, max(stops, 0.0f)); + tlog::debug() << fmt::format("Found apple gain map headroom: {}", headroom); + } catch (const std::invalid_argument& e) { + tlog::warning() << fmt::format("Failed to read gain map headroom: {}", e.what()); + co_return; + } + + co_await ThreadPool::global().parallelForAsync( + 0, + size.y(), + [&](int y) { + for (int x = 0; x < size.x(); ++x) { + size_t i = y * (size_t)size.x() + x; + size_t gmi = (x >> shift) + (y >> shift) * gainMapSize.x(); + + for (int c = 0; c < 3; ++c) { + image.channels[c].at(i) *= (1.0f + (headroom - 1.0f) * gainMap.channels[0].at(gmi)); + } + } + }, + priority + ); + + co_return; +} + +} // namespace tev diff --git a/src/imageio/HeifImageLoader.cpp b/src/imageio/HeifImageLoader.cpp index 3bc602f8..5753b162 100644 --- a/src/imageio/HeifImageLoader.cpp +++ b/src/imageio/HeifImageLoader.cpp @@ -17,6 +17,8 @@ */ #include +#include +#include #include #include @@ -26,6 +28,8 @@ #include +#include + using namespace nanogui; using namespace std; @@ -37,6 +41,24 @@ HeifImageLoader::HeifImageLoader() { }); // cmsPlugin(cmsFastFloatExtensions()); + + // ExifLog* exifLog = exif_log_new(); + // exif_log_set_func( + // exifLog, + // [](ExifLog* log, ExifLogCode code, const char* domain, const char* format, va_list args, void* data) { + // // sprintf into string + // string message; + // message.resize(1024); + // vsnprintf(message.data(), message.size(), format, args); + // switch (code) { + // case EXIF_LOG_CODE_NONE: tlog::error() << message; break; + // case EXIF_LOG_CODE_DEBUG: tlog::error() << message; break; + // case EXIF_LOG_CODE_NO_MEMORY: tlog::error() << message; break; + // case EXIF_LOG_CODE_CORRUPT_DATA: tlog::error() << message; break; + // } + // }, + // nullptr + // ); } bool HeifImageLoader::canLoadFile(istream& iStream) const { @@ -55,8 +77,7 @@ bool HeifImageLoader::canLoadFile(istream& iStream) const { } Task> HeifImageLoader::load(istream& iStream, const fs::path&, const string&, int priority) const { - vector result(1); - ImageData& resultData = result.front(); + vector result; iStream.seekg(0, ios_base::end); int64_t fileSize = iStream.tellg(); @@ -114,183 +135,279 @@ Task> HeifImageLoader::load(istream& iStream, const fs::path&, ScopeGuard handleGuard{[handle] { heif_image_handle_release(handle); }}; - int numChannels = heif_image_handle_has_alpha_channel(handle) ? 4 : 3; - bool hasPremultipliedAlpha = numChannels == 4 && heif_image_handle_is_premultiplied_alpha(handle); + auto decodeImage = [priority](heif_image_handle* imgHandle) -> Task { + ImageData resultData; - const bool is_little_endian = std::endian::native == std::endian::little; - auto format = numChannels == 4 ? (is_little_endian ? heif_chroma_interleaved_RRGGBBAA_LE : heif_chroma_interleaved_RRGGBBAA_BE) : - (is_little_endian ? heif_chroma_interleaved_RRGGBB_LE : heif_chroma_interleaved_RRGGBB_BE); + int numChannels = heif_image_handle_has_alpha_channel(imgHandle) ? 4 : 3; + resultData.hasPremultipliedAlpha = numChannels == 4 && heif_image_handle_is_premultiplied_alpha(imgHandle); - Vector2i size = {heif_image_handle_get_width(handle), heif_image_handle_get_height(handle)}; + const bool is_little_endian = std::endian::native == std::endian::little; + auto format = numChannels == 4 ? (is_little_endian ? heif_chroma_interleaved_RRGGBBAA_LE : heif_chroma_interleaved_RRGGBBAA_BE) : + (is_little_endian ? heif_chroma_interleaved_RRGGBB_LE : heif_chroma_interleaved_RRGGBB_BE); - if (size.x() == 0 || size.y() == 0) { - throw invalid_argument{"Image has zero pixels."}; - } + Vector2i size = {heif_image_handle_get_width(imgHandle), heif_image_handle_get_height(imgHandle)}; - heif_image* img; - if (auto error = heif_decode_image(handle, &img, heif_colorspace_RGB, format, nullptr); error.code != heif_error_Ok) { - throw invalid_argument{fmt::format("Failed to decode image: {}", error.message)}; - } + if (size.x() == 0 || size.y() == 0) { + throw invalid_argument{"Image has zero pixels."}; + } - ScopeGuard imgGuard{[img] { heif_image_release(img); }}; + heif_image* img; + if (auto error = heif_decode_image(imgHandle, &img, heif_colorspace_RGB, format, nullptr); error.code != heif_error_Ok) { + throw invalid_argument{fmt::format("Failed to decode image: {}", error.message)}; + } - const int bitsPerPixel = heif_image_get_bits_per_pixel_range(img, heif_channel_interleaved); - const float channelScale = 1.0f / float((1 << bitsPerPixel) - 1); + ScopeGuard imgGuard{[img] { heif_image_release(img); }}; - int bytesPerLine; - const uint8_t* data = heif_image_get_plane_readonly(img, heif_channel_interleaved, &bytesPerLine); - if (!data) { - throw invalid_argument{"Faild to get image data."}; - } + const int bitsPerPixel = heif_image_get_bits_per_pixel_range(img, heif_channel_interleaved); + const float channelScale = 1.0f / float((1 << bitsPerPixel) - 1); - auto getCmsTransform = [&]() { - size_t profileSize = heif_image_handle_get_raw_color_profile_size(handle); - if (profileSize == 0) { - return (cmsHTRANSFORM) nullptr; + int bytesPerLine; + const uint8_t* data = heif_image_get_plane_readonly(img, heif_channel_interleaved, &bytesPerLine); + if (!data) { + throw invalid_argument{"Faild to get image data."}; } - vector profileData(profileSize); - if (auto error = heif_image_handle_get_raw_color_profile(handle, profileData.data()); error.code != heif_error_Ok) { - if (error.code == heif_error_Color_profile_does_not_exist) { + resultData.channels = makeNChannels(numChannels, size); + + auto getCmsTransform = [&imgHandle, &numChannels, &resultData]() { + size_t profileSize = heif_image_handle_get_raw_color_profile_size(imgHandle); + if (profileSize == 0) { return (cmsHTRANSFORM) nullptr; } - tlog::warning() << "Failed to read ICC profile: " << error.message; - return (cmsHTRANSFORM) nullptr; - } - - // Create ICC profile from the raw data - cmsHPROFILE srcProfile = cmsOpenProfileFromMem(profileData.data(), (cmsUInt32Number)profileSize); - if (!srcProfile) { - tlog::warning() << "Failed to create ICC profile from raw data"; - return (cmsHTRANSFORM) nullptr; - } + vector profileData(profileSize); + if (auto error = heif_image_handle_get_raw_color_profile(imgHandle, profileData.data()); error.code != heif_error_Ok) { + if (error.code == heif_error_Color_profile_does_not_exist) { + return (cmsHTRANSFORM) nullptr; + } - ScopeGuard srcProfileGuard{[srcProfile] { cmsCloseProfile(srcProfile); }}; + tlog::warning() << "Failed to read ICC profile: " << error.message; + return (cmsHTRANSFORM) nullptr; + } - cmsCIExyY D65 = {0.3127, 0.3290, 1.0}; - cmsCIExyYTRIPLE Rec709Primaries = { - {0.6400, 0.3300, 1.0}, - {0.3000, 0.6000, 1.0}, - {0.1500, 0.0600, 1.0} - }; + // Create ICC profile from the raw data + cmsHPROFILE srcProfile = cmsOpenProfileFromMem(profileData.data(), (cmsUInt32Number)profileSize); + if (!srcProfile) { + tlog::warning() << "Failed to create ICC profile from raw data"; + return (cmsHTRANSFORM) nullptr; + } - cmsToneCurve* linearCurve[3]; - linearCurve[0] = linearCurve[1] = linearCurve[2] = cmsBuildGamma(0, 1.0f); + ScopeGuard srcProfileGuard{[srcProfile] { cmsCloseProfile(srcProfile); }}; - cmsHPROFILE rec709Profile = cmsCreateRGBProfile(&D65, &Rec709Primaries, linearCurve); + cmsCIExyY D65 = {0.3127, 0.3290, 1.0}; + cmsCIExyYTRIPLE Rec709Primaries = { + {0.6400, 0.3300, 1.0}, + {0.3000, 0.6000, 1.0}, + {0.1500, 0.0600, 1.0} + }; - if (!rec709Profile) { - tlog::warning() << "Failed to create Rec.709 color profile"; - return (cmsHTRANSFORM) nullptr; - } + cmsToneCurve* linearCurve[3]; + linearCurve[0] = linearCurve[1] = linearCurve[2] = cmsBuildGamma(0, 1.0f); - ScopeGuard rec709ProfileGuard{[rec709Profile] { cmsCloseProfile(rec709Profile); }}; + cmsHPROFILE rec709Profile = cmsCreateRGBProfile(&D65, &Rec709Primaries, linearCurve); - // Create transform from source profile to Rec.709 - auto type = numChannels == 4 ? (hasPremultipliedAlpha ? TYPE_RGBA_FLT_PREMUL : TYPE_RGBA_FLT) : TYPE_RGB_FLT; - cmsHTRANSFORM transform = - cmsCreateTransform(srcProfile, type, rec709Profile, TYPE_RGBA_FLT, INTENT_PERCEPTUAL, cmsFLAGS_NOCACHE); + if (!rec709Profile) { + tlog::warning() << "Failed to create Rec.709 color profile"; + return (cmsHTRANSFORM) nullptr; + } - if (!transform) { - tlog::warning() << "Failed to create color transform from ICC profile to Rec.709"; - return (cmsHTRANSFORM) nullptr; - } + ScopeGuard rec709ProfileGuard{[rec709Profile] { cmsCloseProfile(rec709Profile); }}; - return transform; - }; + // Create transform from source profile to Rec.709 + auto type = numChannels == 4 ? (resultData.hasPremultipliedAlpha ? TYPE_RGBA_FLT_PREMUL : TYPE_RGBA_FLT) : TYPE_RGB_FLT; + cmsHTRANSFORM transform = cmsCreateTransform(srcProfile, type, rec709Profile, TYPE_RGBA_FLT, INTENT_PERCEPTUAL, cmsFLAGS_NOCACHE); - resultData.channels = makeNChannels(numChannels, size); - resultData.hasPremultipliedAlpha = hasPremultipliedAlpha; + if (!transform) { + tlog::warning() << "Failed to create color transform from ICC profile to Rec.709"; + return (cmsHTRANSFORM) nullptr; + } - // If we've got an ICC color profile, apply that because it's the most detailed / standardized. - auto transform = getCmsTransform(); - if (transform) { - ScopeGuard transformGuard{[transform] { cmsDeleteTransform(transform); }}; + return transform; + }; - tlog::debug() << "Found ICC color profile."; + // If we've got an ICC color profile, apply that because it's the most detailed / standardized. + auto transform = getCmsTransform(); + if (transform) { + ScopeGuard transformGuard{[transform] { cmsDeleteTransform(transform); }}; + + tlog::debug() << "Found ICC color profile."; + + // lcms can't perform alpha premultiplication, so we leave it up to downstream processing + resultData.hasPremultipliedAlpha = false; + + size_t numPixels = (size_t)size.x() * size.y(); + vector src(numPixels * numChannels); + vector dst(numPixels * numChannels); + + const size_t n_samples_per_row = size.x() * numChannels; + co_await ThreadPool::global().parallelForAsync( + 0, + size.y(), + [&](size_t y) { + size_t src_offset = y * n_samples_per_row; + for (size_t x = 0; x < n_samples_per_row; ++x) { + const uint16_t* typedData = reinterpret_cast(data + y * bytesPerLine); + src[src_offset + x] = (float)typedData[x] * channelScale; + } - // lcms can't perform alpha premultiplication, so we leave it up to downstream processing - resultData.hasPremultipliedAlpha = false; + // Armchair parallelization of lcms: cmsDoTransform is reentrant per the spec, i.e. it can be called from multiple threads. + // So: call cmsDoTransform for each row in parallel. + // NOTE: This core depends on makeNChannels creating RGBA interleaved buffers! + size_t dst_offset = y * (size_t)size.x() * 4; + cmsDoTransform(transform, &src[src_offset], &resultData.channels[0].data()[dst_offset], size.x()); + }, + priority + ); - size_t numPixels = (size_t)size.x() * size.y(); - vector src(numPixels * numChannels); - vector dst(numPixels * numChannels); + co_return resultData; + } - const size_t n_samples_per_row = size.x() * numChannels; - co_await ThreadPool::global().parallelForAsync( + // Otherwise, assume the image is in Rec.709/sRGB and convert it to linear space, followed by an optional change in color space if + // an NCLX profile is present. + co_await ThreadPool::global().parallelForAsync( 0, size.y(), - [&](size_t y) { - size_t src_offset = y * n_samples_per_row; - for (size_t x = 0; x < n_samples_per_row; ++x) { - const uint16_t* typedData = reinterpret_cast(data + y * bytesPerLine); - src[src_offset + x] = (float)typedData[x] * channelScale; + [&](int y) { + for (int x = 0; x < size.x(); ++x) { + size_t i = y * (size_t)size.x() + x; + auto typedData = reinterpret_cast(data + y * bytesPerLine); + int baseIdx = x * numChannels; + + for (int c = 0; c < numChannels; ++c) { + if (c == 3) { + resultData.channels[c].at(i) = typedData[baseIdx + c] * channelScale; + } else { + resultData.channels[c].at(i) = toLinear(typedData[baseIdx + c] * channelScale); + } + } } - - // Armchair parallelization of lcms: cmsDoTransform is reentrant per the spec, i.e. it can be called from multiple threads. - // So: call cmsDoTransform for each row in parallel. - // NOTE: This core depends on makeNChannels creating RGBA interleaved buffers! - size_t dst_offset = y * (size_t)size.x() * 4; - cmsDoTransform(transform, &src[src_offset], &resultData.channels[0].data()[dst_offset], size.x()); }, priority ); - co_return result; - } + heif_color_profile_nclx* nclx = nullptr; + if (auto error = heif_image_handle_get_nclx_color_profile(imgHandle, &nclx); error.code != heif_error_Ok) { + if (error.code == heif_error_Color_profile_does_not_exist) { + co_return resultData; + } - // Otherwise, assume the image is in Rec.709/sRGB and convert it to linear space, followed by an optional change in color space if an - // NCLX profile is present. - co_await ThreadPool::global().parallelForAsync( - 0, - size.y(), - [&](int y) { - for (int x = 0; x < size.x(); ++x) { - size_t i = y * (size_t)size.x() + x; - auto typedData = reinterpret_cast(data + y * bytesPerLine); - int baseIdx = x * numChannels; - - for (int c = 0; c < numChannels; ++c) { - if (c == 3) { - resultData.channels[c].at(i) = typedData[baseIdx + c] * channelScale; - } else { - resultData.channels[c].at(i) = toLinear(typedData[baseIdx + c] * channelScale); - } + tlog::warning() << "Failed to read NCLX profile: " << error.message; + co_return resultData; + } + + ScopeGuard nclxGuard{[nclx] { heif_nclx_color_profile_free(nclx); }}; + + tlog::debug() << "Found NCLX color profile."; + + // Only convert if not already in Rec.709/sRGB + if (nclx->color_primaries != heif_color_primaries_ITU_R_BT_709_5) { + Imf::Chromaticities rec709; // default rec709 (sRGB) primaries + Imf::Chromaticities chroma = { + {nclx->color_primary_red_x, nclx->color_primary_red_y }, + {nclx->color_primary_green_x, nclx->color_primary_green_y}, + {nclx->color_primary_blue_x, nclx->color_primary_blue_y }, + {nclx->color_primary_white_x, nclx->color_primary_white_y} + }; + + Imath::M44f M = Imf::RGBtoXYZ(chroma, 1) * Imf::XYZtoRGB(rec709, 1); + for (int m = 0; m < 4; ++m) { + for (int n = 0; n < 4; ++n) { + resultData.toRec709.m[m][n] = M.x[m][n]; } } - }, - priority - ); - - heif_color_profile_nclx* nclx = nullptr; - if (auto error = heif_image_handle_get_nclx_color_profile(handle, &nclx); error.code != heif_error_Ok) { - if (error.code == heif_error_Color_profile_does_not_exist) { - co_return result; } - tlog::warning() << "Failed to read ICC profile: " << error.message; - co_return result; - } + co_return resultData; + }; - ScopeGuard nclxGuard{[nclx] { heif_nclx_color_profile_free(nclx); }}; + // Read main image + result.emplace_back(co_await decodeImage(handle)); - tlog::debug() << "Found NCLX color profile."; + auto findAppleMakerNote = [&]() -> unique_ptr { + // Extract EXIF metadata + int numMetadataBlocks = heif_image_handle_get_number_of_metadata_blocks(handle, "Exif"); + if (numMetadataBlocks <= 0) { + tlog::warning() << "No EXIF metadata found"; + return nullptr; + } - // Only convert if not already in Rec.709/sRGB - if (nclx->color_primaries != heif_color_primaries_ITU_R_BT_709_5) { - Imf::Chromaticities rec709; // default rec709 (sRGB) primaries - Imf::Chromaticities chroma = { - {nclx->color_primary_red_x, nclx->color_primary_red_y }, - {nclx->color_primary_green_x, nclx->color_primary_green_y}, - {nclx->color_primary_blue_x, nclx->color_primary_blue_y }, - {nclx->color_primary_white_x, nclx->color_primary_white_y} - }; + if (numMetadataBlocks > 1) { + tlog::debug() << "Found " << numMetadataBlocks << " EXIF metadata block(s)"; + } + + vector metadataIDs(numMetadataBlocks); + heif_image_handle_get_list_of_metadata_block_IDs(handle, "Exif", metadataIDs.data(), numMetadataBlocks); + + for (int i = 0; i < numMetadataBlocks; ++i) { + size_t exifSize = heif_image_handle_get_metadata_size(handle, metadataIDs[i]); + if (exifSize <= 4) { + tlog::warning() << "Failed to get size of EXIF data"; + continue; + } + + vector exifData(exifSize); + if (auto error = heif_image_handle_get_metadata(handle, metadataIDs[i], exifData.data()); error.code != heif_error_Ok) { + tlog::warning() << "Failed to read EXIF data: " << error.message; + continue; + } + + ExifData* exif = exif_data_new_from_data(exifData.data() + 4, (unsigned int)(exifSize - 4)); + if (!exif) { + tlog::warning() << "Failed to decode EXIF data"; + continue; + } + + ScopeGuard exifGuard{[exif] { exif_data_unref(exif); }}; + + ExifEntry* makerNote = exif_data_get_entry(exif, EXIF_TAG_MAKER_NOTE); + if (!isAppleMakernote(makerNote->data, makerNote->size)) { + continue; + } + + return make_unique(makerNote->data, makerNote->size); + } + + return nullptr; + }; + + auto amn = findAppleMakerNote(); + + // Read auxiliary images + int num_aux = heif_image_handle_get_number_of_auxiliary_images(handle, 0); + if (num_aux > 0) { + tlog::debug() << "Found " << num_aux << " auxiliary image(s)"; + + vector aux_ids(num_aux); + heif_image_handle_get_list_of_auxiliary_image_IDs(handle, 0, aux_ids.data(), num_aux); + + for (int i = 0; i < num_aux; ++i) { + heif_image_handle* auxImgHandle; + if (auto error = heif_image_handle_get_auxiliary_image_handle(handle, aux_ids[i], &auxImgHandle); error.code != heif_error_Ok) { + tlog::warning() << fmt::format("Failed to get auxiliary image handle: {}", error.message); + continue; + } + + const char* auxType = nullptr; + if (auto error = heif_image_handle_get_auxiliary_type(auxImgHandle, &auxType); error.code != heif_error_Ok) { + tlog::warning() << fmt::format("Failed to get auxiliary image type: {}", error.message); + continue; + } - Imath::M44f M = Imf::RGBtoXYZ(chroma, 1) * Imf::XYZtoRGB(rec709, 1); - for (int m = 0; m < 4; ++m) { - for (int n = 0; n < 4; ++n) { - resultData.toRec709.m[m][n] = M.x[m][n]; + ScopeGuard typeGuard{[auxImgHandle, &auxType] { heif_image_handle_release_auxiliary_type(auxImgHandle, &auxType); }}; + string auxTypeStr = auxType ? auxType : "unknown"; + + // TODO: Better handling of auxiliary images than to just decode them and list them as separate images + result.emplace_back(co_await decodeImage(auxImgHandle)); + + // If we found an apple-style gainmap, apply it to the main image. + if (amn && auxTypeStr.find("apple") != string::npos && auxTypeStr.find("hdrgainmap") != string::npos) { + tlog::debug() << fmt::format("Found hdrgainmap: {}", auxTypeStr); + co_await applyAppleGainMap( + result.front(), // primary image + co_await decodeImage(auxImgHandle), + priority, + *amn + ); } } }