From 24aa96a545934718e8dea2acf587181aa7850531 Mon Sep 17 00:00:00 2001 From: Greg Benz Date: Wed, 26 Aug 2026 19:44:45 -0500 Subject: [PATCH 1/5] Add runtime-aware gain map routing predicate --- .gitignore | 6 +- CMakeLists.txt | 18 +- cmake/Findlibheif.cmake | 9 +- fuzzer/ultrahdr_dec_fuzzer.cpp | 1 + lib/include/ultrahdr/jpegr.h | 14 +- lib/src/jpegr.cpp | 167 ++++++++++++- lib/src/ultrahdr_api.cpp | 221 ++++++++++++++--- tests/data/GAINMAP_FIXTURE_README.md | 18 ++ tests/data/gainmap_hevc_16x16.heic | Bin 0 -> 1523 bytes tests/jpegr_test.cpp | 1 + tests/ultrahdr_api_test.cpp | 356 +++++++++++++++++++++++++++ ultrahdr_api.h | 37 ++- 12 files changed, 795 insertions(+), 53 deletions(-) create mode 100644 tests/data/GAINMAP_FIXTURE_README.md create mode 100644 tests/data/gainmap_hevc_16x16.heic diff --git a/.gitignore b/.gitignore index f7687219..3ea725a1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,8 @@ build* third_party/googletest third_party/turbojpeg third_party/benchmark -tests/data \ No newline at end of file +tests/data +!tests/data/ +tests/data/* +!tests/data/gainmap_hevc_16x16.heic +!tests/data/GAINMAP_FIXTURE_README.md diff --git a/CMakeLists.txt b/CMakeLists.txt index 37c184fd..6f05945a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,8 +12,8 @@ cmake_minimum_required(VERSION 3.15) cmake_policy(SET CMP0091 NEW) set(UHDR_MAJOR_VERSION 2) -set(UHDR_MINOR_VERSION 0) -set(UHDR_PATCH_VERSION 2) +set(UHDR_MINOR_VERSION 1) +set(UHDR_PATCH_VERSION 0) project(libuhdr VERSION ${UHDR_MAJOR_VERSION}.${UHDR_MINOR_VERSION}.${UHDR_PATCH_VERSION} LANGUAGES C CXX @@ -631,7 +631,7 @@ file(GLOB UHDR_TEST_SRCS_LIST "${TESTS_DIR}/*.cpp") file(GLOB UHDR_BM_SRCS_LIST "${BENCHMARK_DIR}/*.cpp") file(GLOB IMAGE_IO_SRCS_LIST "${THIRD_PARTY_DIR}/image_io/src/**/*.cc") -set(PRIVATE_INCLUDE_DIR ${SOURCE_DIR}/include/ ${JPEG_INCLUDE_DIRS}) +set(PRIVATE_INCLUDE_DIR ${SOURCE_DIR}/include/ ${EXPORT_INCLUDE_DIR} ${JPEG_INCLUDE_DIRS}) set(PRIVATE_LINK_LIBS ${JPEG_LIBRARIES} Threads::Threads) if(UHDR_ENABLE_SMPTE2094_50) list(APPEND PRIVATE_LINK_LIBS smpte2094_50_utils) @@ -641,11 +641,13 @@ if(UHDR_ENABLE_GLES) list(APPEND PRIVATE_LINK_LIBS ${EGL_LIBRARIES} ${OPENGLES3_LIBRARIES}) endif() if(UHDR_ENABLE_HEIF) + set(LIBHEIF_SYSTEM_INCOMPATIBLE FALSE) if(NOT UHDR_BUILD_DEPS) find_package(libheif QUIET) - if(LIBHEIF_FOUND AND NOT LIBHEIF_HAS_GAIN_MAP) - message(WARNING "System libheif found but lacks ISO 21496-1 gain map API (PR 1503). " - "Switching to bundled dependency build.") + if(LIBHEIF_FOUND AND (NOT LIBHEIF_HAS_GAIN_MAP OR NOT LIBHEIF_HAS_ITEM_API)) + message(WARNING "System libheif found but lacks required gain map or item inspection APIs. " + "HEIF/AVIF support will be disabled because UHDR_BUILD_DEPS is OFF.") + set(LIBHEIF_SYSTEM_INCOMPATIBLE TRUE) set(LIBHEIF_FOUND FALSE) endif() endif() @@ -734,7 +736,9 @@ if(UHDR_ENABLE_HEIF) add_compile_options(-DUHDR_ENABLE_HEIF) list(APPEND PRIVATE_LINK_LIBS libheif::heif) else() - message(WARNING "libheif not found. Building without HEIF/AVIF support.") + if(NOT LIBHEIF_SYSTEM_INCOMPATIBLE) + message(WARNING "libheif not found. Building without HEIF/AVIF support.") + endif() set(UHDR_ENABLE_HEIF FALSE) endif() endif() diff --git a/cmake/Findlibheif.cmake b/cmake/Findlibheif.cmake index d4ff65ab..f88c6edb 100644 --- a/cmake/Findlibheif.cmake +++ b/cmake/Findlibheif.cmake @@ -8,6 +8,7 @@ # Result Variables: # LIBHEIF_FOUND - True if libheif was found # LIBHEIF_HAS_GAIN_MAP - True if libheif includes ISO 21496-1 gain map APIs +# LIBHEIF_HAS_ITEM_API - True if libheif exposes item inspection APIs # LIBHEIF_VERSION - Version of libheif found # LIBHEIF_INCLUDE_DIRS - Include directories for libheif # LIBHEIF_LIBRARIES - Libraries needed to link against libheif @@ -91,7 +92,8 @@ if(LIBHEIF_TARGET) list(APPEND CMAKE_REQUIRED_INCLUDES ${LIBHEIF_INCLUDE_DIR}) endif() if(LIBHEIF_DEFS) - set(CMAKE_REQUIRED_DEFINITIONS "-D${LIBHEIF_DEFS}") + set(CMAKE_REQUIRED_DEFINITIONS ${LIBHEIF_DEFS}) + list(TRANSFORM CMAKE_REQUIRED_DEFINITIONS PREPEND "-D") endif() # Perform a compile-only check to avoid linking transitive dependencies @@ -104,6 +106,11 @@ if(LIBHEIF_TARGET) "libheif/heif.h" LIBHEIF_HAS_GAIN_MAP ) + check_cxx_symbol_exists( + heif_item_get_item_type + "libheif/heif_items.h" + LIBHEIF_HAS_ITEM_API + ) set(CMAKE_TRY_COMPILE_TARGET_TYPE ${_saved_try_compile_target_type}) unset(CMAKE_REQUIRED_INCLUDES) diff --git a/fuzzer/ultrahdr_dec_fuzzer.cpp b/fuzzer/ultrahdr_dec_fuzzer.cpp index e1008b47..33c93144 100644 --- a/fuzzer/ultrahdr_dec_fuzzer.cpp +++ b/fuzzer/ultrahdr_dec_fuzzer.cpp @@ -118,6 +118,7 @@ void UltraHdrDecFuzzer::process() { } extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + (void)uhdr_is_supported_gainmap_image(data, size); UltraHdrDecFuzzer fuzzHandle(data, size); fuzzHandle.process(); return 0; diff --git a/lib/include/ultrahdr/jpegr.h b/lib/include/ultrahdr/jpegr.h index abf32933..72d03121 100644 --- a/lib/include/ultrahdr/jpegr.h +++ b/lib/include/ultrahdr/jpegr.h @@ -221,6 +221,11 @@ class JpegR : public UltraHdr { uhdr_error_info_t getJPEGRInfo(uhdr_compressed_image_t* uhdr_compressed_img, jr_info_ptr uhdr_image_info); + // Internal fast-probe variant. Compressed JPEG payloads are borrowed while parsing and the MPF + // directory must identify the exact secondary JPEG range. + uhdr_error_info_t getJPEGRInfoForProbe(uhdr_compressed_image_t* uhdr_compressed_img, + jr_info_ptr uhdr_image_info); + /* \brief Alias of Encode API-0. * * \deprecated This function is deprecated. Use its alias @@ -276,6 +281,10 @@ class JpegR : public UltraHdr { status_t getJPEGRInfo(jr_compressed_ptr jpegr_image_ptr, jr_info_ptr jpegr_image_info_ptr); private: + uhdr_error_info_t getJPEGRInfo(uhdr_compressed_image_t* uhdr_compressed_img, + jr_info_ptr uhdr_image_info, bool copy_image_data, + bool require_mpf_association); + /*!\brief compress gainmap image * * \param[in] gainmap_img gainmap image descriptor @@ -296,7 +305,8 @@ class JpegR : public UltraHdr { */ uhdr_error_info_t extractPrimaryImageAndGainMap(uhdr_compressed_image_t* jpegr_image, uhdr_compressed_image_t* primary_image, - uhdr_compressed_image_t* gainmap_image); + uhdr_compressed_image_t* gainmap_image, + bool require_mpf_association = false); /*!\brief This function parses the bitstream and returns metadata that is useful for actual * decoding. This does not decode the image. That is handled by decompressImage(). @@ -310,7 +320,7 @@ class JpegR : public UltraHdr { */ uhdr_error_info_t parseJpegInfo(uhdr_compressed_image_t* jpeg_image, j_info_ptr image_info, unsigned int* img_width = nullptr, - unsigned int* img_height = nullptr); + unsigned int* img_height = nullptr, bool copy_image_data = true); /*!\brief This method takes compressed sdr intent, compressed gainmap coefficient, gainmap * metadata and creates a ultrahdr image. This is done by first generating XMP packet from gainmap diff --git a/lib/src/jpegr.cpp b/lib/src/jpegr.cpp index 4a5ba667..97fa6bd0 100644 --- a/lib/src/jpegr.cpp +++ b/lib/src/jpegr.cpp @@ -1418,14 +1418,27 @@ uhdr_error_info_t JpegR::appendGainMap(uhdr_compressed_image_t* sdr_intent_compr uhdr_error_info_t JpegR::getJPEGRInfo(uhdr_compressed_image_t* uhdr_compressed_img, jr_info_ptr uhdr_image_info) { + return getJPEGRInfo(uhdr_compressed_img, uhdr_image_info, true, false); +} + +uhdr_error_info_t JpegR::getJPEGRInfoForProbe(uhdr_compressed_image_t* uhdr_compressed_img, + jr_info_ptr uhdr_image_info) { + return getJPEGRInfo(uhdr_compressed_img, uhdr_image_info, false, true); +} + +uhdr_error_info_t JpegR::getJPEGRInfo(uhdr_compressed_image_t* uhdr_compressed_img, + jr_info_ptr uhdr_image_info, bool copy_image_data, + bool require_mpf_association) { uhdr_compressed_image_t primary_image, gainmap; - UHDR_ERR_CHECK(extractPrimaryImageAndGainMap(uhdr_compressed_img, &primary_image, &gainmap)) + UHDR_ERR_CHECK(extractPrimaryImageAndGainMap(uhdr_compressed_img, &primary_image, &gainmap, + require_mpf_association)) UHDR_ERR_CHECK(parseJpegInfo(&primary_image, uhdr_image_info->primaryImgInfo, - &uhdr_image_info->width, &uhdr_image_info->height)) + &uhdr_image_info->width, &uhdr_image_info->height, copy_image_data)) if (uhdr_image_info->gainmapImgInfo != nullptr) { - UHDR_ERR_CHECK(parseJpegInfo(&gainmap, uhdr_image_info->gainmapImgInfo)) + UHDR_ERR_CHECK( + parseJpegInfo(&gainmap, uhdr_image_info->gainmapImgInfo, nullptr, nullptr, copy_image_data)) } return g_no_error; @@ -1841,9 +1854,137 @@ uhdr_error_info_t UltraHdr::applyGainMap(uhdr_raw_image_t* sdr_intent, uhdr_raw_ return g_no_error; } +namespace { + +bool readMpfU16(const uint8_t* data, size_t size, size_t offset, bool big_endian, uint16_t* value) { + if (offset > size || size - offset < sizeof(uint16_t)) return false; + *value = big_endian ? static_cast((data[offset] << 8) | data[offset + 1]) + : static_cast(data[offset] | (data[offset + 1] << 8)); + return true; +} + +bool readMpfU32(const uint8_t* data, size_t size, size_t offset, bool big_endian, uint32_t* value) { + if (offset > size || size - offset < sizeof(uint32_t)) return false; + if (big_endian) { + *value = (static_cast(data[offset]) << 24) | + (static_cast(data[offset + 1]) << 16) | + (static_cast(data[offset + 2]) << 8) | data[offset + 3]; + } else { + *value = data[offset] | (static_cast(data[offset + 1]) << 8) | + (static_cast(data[offset + 2]) << 16) | + (static_cast(data[offset + 3]) << 24); + } + return true; +} + +bool hasValidMpfAssociation(const uhdr_compressed_image_t* image, const JpegInfo& info) { + const auto& image_ranges = info.GetImageRanges(); + if (image_ranges.size() != 2) return false; + const JpegSegmentInfo mpf = info.GetSegmentInfo(0, kMpf); + if (!mpf.IsValid() || image->data == nullptr) return false; + const DataRange& mpf_range = mpf.GetDataRange(); + if (mpf_range.GetEnd() > image->data_sz || mpf_range.GetBegin() > mpf_range.GetEnd()) + return false; + const size_t mpf_end = mpf_range.GetEnd(); + + const uint8_t* bytes = static_cast(image->data); + constexpr size_t kJpegSegmentHeaderSize = 4; + if (mpf_range.GetBegin() > mpf_end || + mpf_end - mpf_range.GetBegin() < kJpegSegmentHeaderSize + sizeof(kMpfSig)) { + return false; + } + const size_t signature = mpf_range.GetBegin() + kJpegSegmentHeaderSize; + if (memcmp(bytes + signature, kMpfSig, sizeof(kMpfSig)) != 0) return false; + + const size_t tiff = signature + sizeof(kMpfSig); + if (tiff > mpf_end || mpf_end - tiff < kMpEndianSize + sizeof(uint32_t)) { + return false; + } + const bool big_endian = memcmp(bytes + tiff, kMpBigEndian, kMpEndianSize) == 0; + if (!big_endian && memcmp(bytes + tiff, kMpLittleEndian, kMpEndianSize) != 0) return false; + + uint32_t ifd_offset = 0; + if (!readMpfU32(bytes, mpf_end, tiff + kMpEndianSize, big_endian, &ifd_offset) || + ifd_offset > mpf_end - tiff) { + return false; + } + const size_t ifd = tiff + ifd_offset; + uint16_t tag_count = 0; + if (!readMpfU16(bytes, mpf_end, ifd, big_endian, &tag_count) || tag_count > 64) { + return false; + } + + size_t entries_offset = 0; + uint32_t entries_size = 0; + uint32_t image_count = 0; + bool has_valid_version = false; + bool has_image_count = false; + bool has_mp_entries = false; + size_t tag = ifd + sizeof(uint16_t); + for (uint16_t i = 0; i < tag_count; ++i, tag += kTagSize) { + uint16_t id = 0; + uint16_t type = 0; + uint32_t count = 0; + uint32_t value = 0; + if (!readMpfU16(bytes, mpf_end, tag, big_endian, &id) || + !readMpfU16(bytes, mpf_end, tag + 2, big_endian, &type) || + !readMpfU32(bytes, mpf_end, tag + 4, big_endian, &count) || + !readMpfU32(bytes, mpf_end, tag + 8, big_endian, &value)) { + return false; + } + if (id == kVersionTag) { + if (has_valid_version || type != kVersionType || count != kVersionCount || + memcmp(bytes + tag + 8, kVersionExpected, kVersionSize) != 0) { + return false; + } + has_valid_version = true; + } else if (id == kNumberOfImagesTag) { + if (has_image_count || type != kTypeLong || count != kNumberOfImagesCount) return false; + has_image_count = true; + image_count = value; + } else if (id == kMPEntryTag) { + if (has_mp_entries || type != kTypeUndefined || count < 2 * kMPEntrySize) return false; + has_mp_entries = true; + entries_offset = value; + entries_size = count; + } + } + if (!has_valid_version || !has_image_count || !has_mp_entries || image_count != 2 || + entries_size != image_count * kMPEntrySize || + entries_offset > mpf_end - tiff || mpf_end - (tiff + entries_offset) < entries_size) { + return false; + } + + const size_t primary_entry = tiff + entries_offset; + const size_t secondary_entry = tiff + entries_offset + kMPEntrySize; + uint32_t primary_attributes = 0; + uint32_t primary_offset = 0; + uint32_t secondary_attributes = 0; + uint32_t secondary_size = 0; + uint32_t secondary_offset = 0; + constexpr uint32_t kMpfFormatAndTypeMask = 0x0fffffff; + if (!readMpfU32(bytes, mpf_end, primary_entry, big_endian, &primary_attributes) || + !readMpfU32(bytes, mpf_end, primary_entry + 8, big_endian, &primary_offset) || + !readMpfU32(bytes, mpf_end, secondary_entry, big_endian, &secondary_attributes) || + !readMpfU32(bytes, mpf_end, secondary_entry + 4, big_endian, &secondary_size) || + !readMpfU32(bytes, mpf_end, secondary_entry + 8, big_endian, &secondary_offset) || + (primary_attributes & kMpfFormatAndTypeMask) != kMPEntryAttributeTypePrimary || + (secondary_attributes & kMpfFormatAndTypeMask) != kMPEntryAttributeFormatJpeg || + primary_offset != 0 || secondary_offset > image->data_sz - tiff) { + return false; + } + // Only the secondary entry is needed to associate the gain-map JPEG. Some Apple-authored files + // use legacy primary-size bookkeeping, so do not reject an otherwise exact association for it. + return tiff + secondary_offset == image_ranges[1].GetBegin() && + secondary_size == image_ranges[1].GetLength(); +} + +} // namespace + uhdr_error_info_t JpegR::extractPrimaryImageAndGainMap(uhdr_compressed_image_t* jpegr_image, uhdr_compressed_image_t* primary_image, - uhdr_compressed_image_t* gainmap_image) { + uhdr_compressed_image_t* gainmap_image, + bool require_mpf_association) { MessageHandler msg_handler; msg_handler.SetMessageWriter(make_unique(AlogMessageWriter())); @@ -1896,6 +2037,15 @@ uhdr_error_info_t JpegR::extractPrimaryImageAndGainMap(uhdr_compressed_image_t* return status; } + if (require_mpf_association && !hasValidMpfAssociation(jpegr_image, jpeg_info)) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "input uhdr image does not contain a valid MPF gain map association"); + return status; + } + if (gainmap_image != nullptr) { gainmap_image->data = static_cast(jpegr_image->data) + image_ranges[1].GetBegin(); gainmap_image->data_sz = image_ranges[1].GetLength(); @@ -1911,7 +2061,8 @@ uhdr_error_info_t JpegR::extractPrimaryImageAndGainMap(uhdr_compressed_image_t* } uhdr_error_info_t JpegR::parseJpegInfo(uhdr_compressed_image_t* jpeg_image, j_info_ptr image_info, - unsigned int* img_width, unsigned int* img_height) { + unsigned int* img_width, unsigned int* img_height, + bool copy_image_data) { JpegDecoderHelper jpeg_dec_obj; UHDR_ERR_CHECK(jpeg_dec_obj.parseImage(jpeg_image->data, jpeg_image->data_sz)) unsigned int imgWidth, imgHeight, numComponents; @@ -1923,8 +2074,10 @@ uhdr_error_info_t JpegR::parseJpegInfo(uhdr_compressed_image_t* jpeg_image, j_in image_info->width = imgWidth; image_info->height = imgHeight; image_info->numComponents = numComponents; - image_info->imgData.resize(jpeg_image->data_sz, 0); - memcpy(static_cast(image_info->imgData.data()), jpeg_image->data, jpeg_image->data_sz); + if (copy_image_data) { + image_info->imgData.resize(jpeg_image->data_sz, 0); + memcpy(static_cast(image_info->imgData.data()), jpeg_image->data, jpeg_image->data_sz); + } if (jpeg_dec_obj.getICCSize() != 0) { image_info->iccData.resize(jpeg_dec_obj.getICCSize(), 0); memcpy(static_cast(image_info->iccData.data()), jpeg_dec_obj.getICCPtr(), diff --git a/lib/src/ultrahdr_api.cpp b/lib/src/ultrahdr_api.cpp index 74420083..9928d79c 100644 --- a/lib/src/ultrahdr_api.cpp +++ b/lib/src/ultrahdr_api.cpp @@ -8,8 +8,11 @@ * except according to those terms. */ +#include #include #include +#include +#include #include "ultrahdr_api.h" #include "ultrahdr/ultrahdrcommon.h" @@ -21,13 +24,7 @@ #include "ultrahdr/heifultrahdr.h" #include "ultrahdr/avifultrahdr.h" #include "ultrahdr/gainmapmetadata.h" -#endif - - -#ifdef UHDR_ENABLE_HEIF -#include "ultrahdr/heifultrahdr.h" -#include "ultrahdr/avifultrahdr.h" -#include "ultrahdr/gainmapmetadata.h" +#include "libheif/heif_items.h" #endif #include "ultrahdr/jpegrutils.h" @@ -504,6 +501,184 @@ uhdr_error_info_t uhdr_validate_gainmap_metadata_descriptor(uhdr_gainmap_metadat } // namespace ultrahdr +namespace { + +enum class GainmapProbeRequirement { + kStructural, + kRuntimeSupport, +}; + +bool inspect_jpeg_gainmap(const void* data, size_t size) { + uhdr_compressed_image_t image{}; + image.data = const_cast(data); + image.data_sz = size; + image.capacity = size; + image.cg = UHDR_CG_UNSPECIFIED; + image.ct = UHDR_CT_UNSPECIFIED; + image.range = UHDR_CR_UNSPECIFIED; + + ultrahdr::jpeg_info_struct primary; + ultrahdr::jpeg_info_struct gainmap; + ultrahdr::jpegr_info_struct info; + info.primaryImgInfo = &primary; + info.gainmapImgInfo = &gainmap; + + ultrahdr::JpegR jpegr; + if (jpegr.getJPEGRInfoForProbe(&image, &info).error_code != UHDR_CODEC_OK) return false; + + ultrahdr::uhdr_gainmap_metadata_ext_t metadata; + return jpegr + .parseGainMapMetadata(gainmap.isoData.data(), gainmap.isoData.size(), + gainmap.xmpData.data(), gainmap.xmpData.size(), + primary.exifData.data(), primary.exifData.size(), &metadata) + .error_code == UHDR_CODEC_OK; +} + +#ifdef UHDR_ENABLE_HEIF + +constexpr size_t kMaxProbeMetadataSize = 64 * 1024; + +struct HeifContextDeleter { + void operator()(heif_context* context) const { heif_context_free(context); } +}; + +struct HeifHandleDeleter { + void operator()(heif_image_handle* handle) const { heif_image_handle_release(handle); } +}; + +bool is_supported_heif_dimensions(const heif_image_handle* handle) { + const int width = heif_image_handle_get_width(handle); + const int height = heif_image_handle_get_height(handle); + return width > 0 && height > 0 && width <= ultrahdr::kMaxWidth && height <= ultrahdr::kMaxHeight; +} + +bool is_supported_heif_layout(const heif_image_handle* handle, bool allow_monochrome) { + heif_colorspace colorspace; + heif_chroma chroma; + const heif_error error = + heif_image_handle_get_preferred_decoding_colorspace(handle, &colorspace, &chroma); + if (error.code != heif_error_Ok) return false; + if (colorspace == heif_colorspace_monochrome) { + return allow_monochrome && chroma == heif_chroma_monochrome; + } + if (colorspace == heif_colorspace_RGB) return true; + return colorspace == heif_colorspace_YCbCr && + (chroma == heif_chroma_420 || chroma == heif_chroma_422 || chroma == heif_chroma_444); +} + +bool get_direct_image_decoder_format(const heif_context* context, const heif_image_handle* handle, + heif_compression_format* format) { + if (heif_image_handle_has_alpha_channel(handle)) return false; + const uint32_t type = heif_item_get_item_type(context, heif_image_handle_get_item_id(handle)); + if (type == heif_fourcc('a', 'v', '0', '1')) { + *format = heif_compression_AV1; + return true; + } + if (type == heif_fourcc('h', 'v', 'c', '1') || type == heif_fourcc('h', 'e', 'v', '1')) { + *format = heif_compression_HEVC; + return true; + } + return false; +} + +bool inspect_heif_gainmap(const void* data, size_t size, GainmapProbeRequirement requirement) { + const int sniff_size = + static_cast((std::min)(size, static_cast((std::numeric_limits::max)()))); + const heif_filetype_result filetype = + heif_check_filetype(static_cast(data), sniff_size); + if (filetype != heif_filetype_yes_supported && filetype != heif_filetype_maybe) return false; + + std::unique_ptr context(heif_context_alloc()); + if (context == nullptr) return false; + if (heif_context_read_from_memory_without_copy(context.get(), data, size, nullptr).code != + heif_error_Ok) { + return false; + } + + heif_image_handle* primary_raw = nullptr; + if (heif_context_get_primary_image_handle(context.get(), &primary_raw).code != heif_error_Ok || + primary_raw == nullptr) { + return false; + } + std::unique_ptr primary(primary_raw); + if (!heif_image_handle_is_primary_image(primary.get())) return false; + + heif_image_handle* gainmap_raw = nullptr; + if (heif_image_handle_get_gain_map_image_handle(primary.get(), &gainmap_raw).code != + heif_error_Ok || + gainmap_raw == nullptr) { + return false; + } + std::unique_ptr gainmap(gainmap_raw); + + const size_t metadata_size = heif_image_handle_get_gain_map_metadata_size(primary.get()); + if (metadata_size == 0 || metadata_size > kMaxProbeMetadataSize) return false; + std::vector metadata_bytes(metadata_size); + if (heif_image_handle_get_gain_map_metadata(primary.get(), metadata_bytes.data()).code != + heif_error_Ok) { + return false; + } + ultrahdr::uhdr_gainmap_metadata_frac metadata_fraction; + ultrahdr::uhdr_gainmap_metadata_ext_t metadata; + if (ultrahdr::uhdr_gainmap_metadata_frac::decodeGainmapMetadata(metadata_bytes, + &metadata_fraction) + .error_code != UHDR_CODEC_OK || + ultrahdr::uhdr_gainmap_metadata_frac::gainmapMetadataFractionToFloat(&metadata_fraction, + &metadata) + .error_code != UHDR_CODEC_OK) { + return false; + } + + if (requirement == GainmapProbeRequirement::kStructural) return true; + + if (!is_supported_heif_dimensions(primary.get()) || + !is_supported_heif_dimensions(gainmap.get()) || + !is_supported_heif_layout(primary.get(), false) || + !is_supported_heif_layout(gainmap.get(), true)) { + return false; + } + + heif_compression_format primary_format; + heif_compression_format gainmap_format; + if (!get_direct_image_decoder_format(context.get(), primary.get(), &primary_format) || + !get_direct_image_decoder_format(context.get(), gainmap.get(), &gainmap_format)) { + return false; + } + return heif_have_decoder_for_format(primary_format) && + heif_have_decoder_for_format(gainmap_format); +} + +#endif // UHDR_ENABLE_HEIF + +bool inspect_gainmap_image(const void* data, size_t size, GainmapProbeRequirement requirement) { + if (data == nullptr || size == 0) return false; + const uint8_t* bytes = static_cast(data); + if (size >= 3 && memcmp(bytes, "\377\330\377", 3) == 0) { + return inspect_jpeg_gainmap(data, size); + } +#ifdef UHDR_ENABLE_HEIF + return inspect_heif_gainmap(data, size, requirement); +#else + (void)requirement; + return false; +#endif +} + +int inspect_gainmap_image_for_c_api(const void* data, size_t size, + GainmapProbeRequirement requirement) { +#if defined(__cpp_exceptions) || defined(__EXCEPTIONS) || defined(_CPPUNWIND) + try { + return inspect_gainmap_image(data, size, requirement); + } catch (...) { + return 0; + } +#else + return inspect_gainmap_image(data, size, requirement); +#endif +} + +} // namespace + uhdr_codec_private::~uhdr_codec_private() { for (auto it : m_effects) delete it; m_effects.clear(); @@ -1486,33 +1661,13 @@ void uhdr_reset_encoder(uhdr_codec_private_t* enc) { } int is_uhdr_image(void* data, int size) { - if (data == nullptr || size < 4) return 0; - -#define RET_IF_ERR(x) \ - { \ - uhdr_error_info_t status = (x); \ - if (status.error_code != UHDR_CODEC_OK) { \ - uhdr_release_decoder(obj); \ - return 0; \ - } \ - } - - uhdr_codec_private_t* obj = uhdr_create_decoder(); - uhdr_compressed_image_t uhdr_image; - uhdr_image.data = data; - uhdr_image.data_sz = size; - uhdr_image.capacity = size; - uhdr_image.cg = UHDR_CG_UNSPECIFIED; - uhdr_image.ct = UHDR_CT_UNSPECIFIED; - uhdr_image.range = UHDR_CR_UNSPECIFIED; - - RET_IF_ERR(uhdr_dec_set_image(obj, &uhdr_image)); - RET_IF_ERR(uhdr_dec_probe(obj)); -#undef RET_IF_ERR - - uhdr_release_decoder(obj); + if (size <= 0) return 0; + return inspect_gainmap_image_for_c_api(data, static_cast(size), + GainmapProbeRequirement::kStructural); +} - return 1; +UHDR_EXTERN int uhdr_is_supported_gainmap_image(const void* data, size_t size) { + return inspect_gainmap_image_for_c_api(data, size, GainmapProbeRequirement::kRuntimeSupport); } uhdr_codec_private_t* uhdr_create_decoder(void) { diff --git a/tests/data/GAINMAP_FIXTURE_README.md b/tests/data/GAINMAP_FIXTURE_README.md new file mode 100644 index 00000000..89a3d36c --- /dev/null +++ b/tests/data/GAINMAP_FIXTURE_README.md @@ -0,0 +1,18 @@ +# Generated routing-probe fixture: `gainmap_hevc_16x16.heic` + +This synthetic fixture was generated during test development with this repository's public HEIF +encoder from programmatically initialized, uniform 16x16 P010 HLG pixels. No third-party source +image was used. The encoder used its default gain-map scale, quality, and metadata settings. The +result contains direct-coded HEVC primary and gain-map items plus ISO 21496-1 metadata and exists to +test both sides of runtime decoder availability. It was generated from libultrahdr revision +`418b6b361e252a91c435a56cf386afb37d7d1c9d`; the temporary generation helper was not retained. + +SHA-256: `6dd810ee6d118d3ce876936d9b265338b94d44cde187bce2ba384ce93edb1b8e` + +## Fixture-specific license + +The license below applies specifically to `gainmap_hevc_16x16.heic` and this provenance document; +it is an explicit exception to the general CC-BY-4.0 license used by the pre-existing files in this +directory. + +SPDX-License-Identifier: Apache-2.0 OR MIT diff --git a/tests/data/gainmap_hevc_16x16.heic b/tests/data/gainmap_hevc_16x16.heic new file mode 100644 index 0000000000000000000000000000000000000000..9158af25772ad3fda88d86e4339af85ec59c0cff GIT binary patch literal 1523 zcmcIkTSydP6#iyLO&7bVL4nB{L0Tkg&`TE<6{R2uGw4FWG&{RHFta<%PFH#`D#Eww zp@%3?3VJi_sS8L!K|w_j^%6$CR780up%z%1+L{gfuXSPw(MN++pYvO z2!rxHp(b1lHDJ!nGMgDCF%M>$7MeRPE6a&c#Jhxs84TqAGKY*daV(o9>5;kjY}4IR zhazJodd5*!Z@C^Lw9l4n}h#w0+5aMrw4cBA5T2H*uu@6cv5jhss zW_ITe>Tb4rbiqO%E^O#Hia54o9s3?y4i3U6KFkOo8KOLT>FFTqgU5(0nG{T7MD;uQ z8rU>iC=~8Q+}2TGWIYhO6>&`)fW_6o!zU3J)zsBJo%7=+v8gHU+1G2p{M|t9GyZ39 z;A8i%u}VIM)YoV|WhbynUrsYMbq zt;w*1E3=5U!WL+jj7x)B)WW;g5sMmHWo6}Wb}Sa79i^*64XXM{{kOaICXtx)Zk0?# zLBo^&)fDc37*)QkHR|={+R0*5!e-R literal 0 HcmV?d00001 diff --git a/tests/jpegr_test.cpp b/tests/jpegr_test.cpp index 8748dac1..73d75623 100644 --- a/tests/jpegr_test.cpp +++ b/tests/jpegr_test.cpp @@ -1722,6 +1722,7 @@ TEST(JpegRTest, decodeApple) { std::string content((std::istreambuf_iterator(ifs)), (std::istreambuf_iterator())); ASSERT_GT(content.size(), 4u); ASSERT_EQ(is_uhdr_image(content.data(), content.size()), 1); + ASSERT_EQ(uhdr_is_supported_gainmap_image(content.data(), content.size()), 1); uhdrCompressedImg.data = content.data(); uhdrCompressedImg.data_sz = content.size(); diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index b9b2f005..c2c5ed15 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -20,12 +20,16 @@ #include "ultrahdr/jpegr.h" #include "ultrahdr/heifultrahdr.h" #include "ultrahdr/avifultrahdr.h" +#if defined(UHDR_ENABLE_HEIF) +#include "libheif/heif.h" +#endif namespace ultrahdr { static const char* kYCbCrP010FileName = "raw_p010_image.p010"; static const char* kYCbCr420FileName = "raw_yuv420_image.yuv420"; static const char* kSdrJpgFileName = "jpeg_image.jpg"; +static const char* kHevcGainmapFileName = "gainmap_hevc_16x16.heic"; static const size_t kImageWidth = 1280; static const size_t kImageHeight = 720; @@ -52,6 +56,214 @@ static bool loadFile(const char* filename, std::vector& buffer) { return false; } +static bool readMpfU16ForTest(const std::vector& data, size_t offset, bool big_endian, + uint16_t* value) { + if (offset > data.size() || data.size() - offset < 2) return false; + *value = big_endian ? static_cast((data[offset] << 8) | data[offset + 1]) + : static_cast(data[offset] | (data[offset + 1] << 8)); + return true; +} + +static bool readMpfU32ForTest(const std::vector& data, size_t offset, bool big_endian, + uint32_t* value) { + if (offset > data.size() || data.size() - offset < 4) return false; + if (big_endian) { + *value = (static_cast(data[offset]) << 24) | + (static_cast(data[offset + 1]) << 16) | + (static_cast(data[offset + 2]) << 8) | data[offset + 3]; + } else { + *value = data[offset] | (static_cast(data[offset + 1]) << 8) | + (static_cast(data[offset + 2]) << 16) | + (static_cast(data[offset + 3]) << 24); + } + return true; +} + +static void writeMpfU16ForTest(std::vector& data, size_t offset, bool big_endian, + uint16_t value) { + data[offset] = static_cast(value >> (big_endian ? 8 : 0)); + data[offset + 1] = static_cast(value >> (big_endian ? 0 : 8)); +} + +static void writeMpfU32ForTest(std::vector& data, size_t offset, bool big_endian, + uint32_t value) { + for (int i = 0; i < 4; ++i) { + const int shift = big_endian ? 24 - 8 * i : 8 * i; + data[offset + i] = static_cast(value >> shift); + } +} + +struct MpfEntryLocations { + bool big_endian; + size_t signature; + size_t ifd; + size_t number_of_images_tag; + size_t mp_entry_tag; + size_t mp_entry_value; + size_t version_id; + size_t version_type; + size_t version_count; + size_t version_value; + size_t count; + size_t primary_attributes; + size_t primary_offset; + size_t secondary_attributes; + size_t secondary_offset; +}; + +static bool findMpfEntryLocations(const std::vector& data, MpfEntryLocations* locations) { + const uint8_t signature[] = {'M', 'P', 'F', 0}; + const auto found = + std::search(data.begin(), data.end(), std::begin(signature), std::end(signature)); + if (found == data.end()) return false; + const size_t signature_offset = static_cast(found - data.begin()); + const size_t tiff = signature_offset + sizeof signature; + if (tiff > data.size() || data.size() - tiff < 8) return false; + const bool big_endian = data[tiff] == 'M' && data[tiff + 1] == 'M'; + if (!big_endian && !(data[tiff] == 'I' && data[tiff + 1] == 'I')) return false; + + uint32_t ifd_relative_offset = 0; + if (!readMpfU32ForTest(data, tiff + 4, big_endian, &ifd_relative_offset) || + ifd_relative_offset > data.size() - tiff) { + return false; + } + const size_t ifd = tiff + ifd_relative_offset; + uint16_t tag_count = 0; + if (!readMpfU16ForTest(data, ifd, big_endian, &tag_count)) return false; + bool found_version = false; + bool found_number_of_images = false; + bool found_entries = false; + locations->big_endian = big_endian; + locations->signature = signature_offset; + locations->ifd = ifd; + for (uint16_t i = 0; i < tag_count; ++i) { + const size_t tag = ifd + 2 + 12 * i; + uint16_t id = 0; + uint32_t entries_offset = 0; + if (!readMpfU16ForTest(data, tag, big_endian, &id) || + !readMpfU32ForTest(data, tag + 8, big_endian, &entries_offset)) { + return false; + } + if (id == 0xb000) { + locations->version_id = tag; + locations->version_type = tag + 2; + locations->version_count = tag + 4; + locations->version_value = tag + 8; + found_version = true; + continue; + } + if (id == 0xb001) { + locations->number_of_images_tag = tag; + found_number_of_images = true; + continue; + } + if (id != 0xb002) continue; + if (entries_offset > data.size() - tiff || data.size() - (tiff + entries_offset) < 32) { + return false; + } + const size_t entries = tiff + entries_offset; + locations->count = tag + 4; + locations->mp_entry_tag = tag; + locations->mp_entry_value = tag + 8; + locations->primary_attributes = entries; + locations->primary_offset = entries + 8; + locations->secondary_attributes = entries + 16; + locations->secondary_offset = entries + 24; + found_entries = true; + } + return found_version && found_number_of_images && found_entries; +} + +static bool insertDuplicateMpfTag(std::vector& data, uint16_t tag_id) { + MpfEntryLocations locations; + if (!findMpfEntryLocations(data, &locations) || locations.signature < 2) return false; + + uint16_t tag_count = 0; + uint16_t app2_length = 0; + uint32_t entries_offset = 0; + uint32_t secondary_offset = 0; + if (!readMpfU16ForTest(data, locations.ifd, locations.big_endian, &tag_count) || + !readMpfU16ForTest(data, locations.signature - 2, true, &app2_length) || + !readMpfU32ForTest(data, locations.mp_entry_value, locations.big_endian, &entries_offset) || + !readMpfU32ForTest(data, locations.secondary_offset, locations.big_endian, + &secondary_offset)) { + return false; + } + const size_t source_tag = + tag_id == 0xb001 ? locations.number_of_images_tag : locations.mp_entry_tag; + const size_t insertion = locations.ifd + 2 + static_cast(tag_count) * 12; + if (source_tag > data.size() || data.size() - source_tag < 12 || insertion > data.size() || + app2_length > UINT16_MAX - 12) { + return false; + } + + const std::vector duplicate(data.begin() + source_tag, data.begin() + source_tag + 12); + data.insert(data.begin() + insertion, duplicate.begin(), duplicate.end()); + writeMpfU16ForTest(data, locations.ifd, locations.big_endian, tag_count + 1); + writeMpfU16ForTest(data, locations.signature - 2, true, app2_length + 12); + writeMpfU32ForTest(data, locations.mp_entry_value, locations.big_endian, entries_offset + 12); + if (tag_id == 0xb002) { + writeMpfU32ForTest(data, insertion + 8, locations.big_endian, entries_offset + 12); + } + writeMpfU32ForTest(data, locations.secondary_offset + 12, locations.big_endian, + secondary_offset + 12); + return true; +} + +#if defined(UHDR_ENABLE_HEIF) +static heif_error writeHeifToVector([[maybe_unused]] heif_context* context, const void* data, + size_t size, void* userdata) { + auto* output = static_cast*>(userdata); + const uint8_t* bytes = static_cast(data); + output->insert(output->end(), bytes, bytes + size); + return {heif_error_Ok, heif_suberror_Unspecified, nullptr}; +} + +static bool encodeOrdinaryAvif(std::vector& output) { + constexpr int width = 16; + constexpr int height = 16; + heif_context* context = heif_context_alloc(); + heif_image* image = nullptr; + heif_encoder* encoder = nullptr; + heif_image_handle* handle = nullptr; + bool success = false; + if (context == nullptr) return false; + + if (heif_image_create(width, height, heif_colorspace_YCbCr, heif_chroma_420, &image).code != + heif_error_Ok || + heif_image_add_plane(image, heif_channel_Y, width, height, 8).code != heif_error_Ok || + heif_image_add_plane(image, heif_channel_Cb, width / 2, height / 2, 8).code != + heif_error_Ok || + heif_image_add_plane(image, heif_channel_Cr, width / 2, height / 2, 8).code != + heif_error_Ok) { + goto cleanup; + } + for (const heif_channel channel : {heif_channel_Y, heif_channel_Cb, heif_channel_Cr}) { + int stride = 0; + uint8_t* plane = heif_image_get_plane(image, channel, &stride); + const int plane_height = channel == heif_channel_Y ? height : height / 2; + if (plane == nullptr || stride <= 0) goto cleanup; + std::fill(plane, plane + stride * plane_height, 128); + } + if (heif_context_get_encoder_for_format(context, heif_compression_AV1, &encoder).code != + heif_error_Ok || + heif_context_encode_image(context, image, encoder, nullptr, &handle).code != heif_error_Ok) { + goto cleanup; + } + { + heif_writer writer{1, writeHeifToVector}; + success = heif_context_write(context, &writer, &output).code == heif_error_Ok; + } + +cleanup: + if (handle != nullptr) heif_image_handle_release(handle); + if (encoder != nullptr) heif_encoder_release(encoder); + if (image != nullptr) heif_image_release(image); + heif_context_free(context); + return success; +} +#endif + static void expectGainMapsEqual(const uhdr_raw_image_t* expected, const uhdr_raw_image_t* actual) { ASSERT_NE(expected, nullptr); @@ -161,6 +373,16 @@ static heif_transfer_characteristics getPrimaryImageTransfer( } #endif +TEST_F(UltraHdrApiTest, RoutingProbeRejectsInvalidInputAndOrdinaryJpeg) { + EXPECT_EQ(uhdr_is_supported_gainmap_image(nullptr, 1), 0); + EXPECT_EQ(uhdr_is_supported_gainmap_image(mSdrJpgData.data(), 0), 0); + EXPECT_EQ(is_uhdr_image(mSdrJpgData.data(), static_cast(mSdrJpgData.size())), 0); + EXPECT_EQ(uhdr_is_supported_gainmap_image(mSdrJpgData.data(), mSdrJpgData.size()), 0); + + const uint8_t truncated_jpeg[] = {0xff, 0xd8, 0xff}; + EXPECT_EQ(uhdr_is_supported_gainmap_image(truncated_jpeg, sizeof truncated_jpeg), 0); +} + TEST_F(UltraHdrApiTest, InvalidCompressedImageIntentDoesNotMutateEncoder) { uhdr_codec_private_t* enc = uhdr_create_encoder(); ASSERT_NE(enc, nullptr); @@ -209,6 +431,91 @@ TEST_F(UltraHdrApiTest, JpegEncodeApi0AndDecode) { uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); ASSERT_NE(output, nullptr); ASSERT_GT(output->data_sz, 0u); + EXPECT_EQ(is_uhdr_image(output->data, static_cast(output->data_sz)), 1); + EXPECT_EQ(uhdr_is_supported_gainmap_image(output->data, output->data_sz), 1); + + std::vector broken_association(static_cast(output->data), + static_cast(output->data) + output->data_sz); + MpfEntryLocations locations; + ASSERT_TRUE(findMpfEntryLocations(broken_association, &locations)); + uint32_t secondary_offset = 0; + ASSERT_TRUE(readMpfU32ForTest(broken_association, locations.secondary_offset, + locations.big_endian, &secondary_offset)); + writeMpfU32ForTest(broken_association, locations.secondary_offset, locations.big_endian, + secondary_offset + 1); + EXPECT_EQ(is_uhdr_image(broken_association.data(), static_cast(broken_association.size())), + 0); + EXPECT_EQ(uhdr_is_supported_gainmap_image(broken_association.data(), broken_association.size()), + 0); + + std::vector malformed_table(static_cast(output->data), + static_cast(output->data) + output->data_sz); + ASSERT_TRUE(findMpfEntryLocations(malformed_table, &locations)); + writeMpfU32ForTest(malformed_table, locations.count, locations.big_endian, 0xffffffffu); + EXPECT_EQ(is_uhdr_image(malformed_table.data(), static_cast(malformed_table.size())), 0); + EXPECT_EQ(uhdr_is_supported_gainmap_image(malformed_table.data(), malformed_table.size()), 0); + + std::vector displaced_signature(static_cast(output->data), + static_cast(output->data) + output->data_sz); + ASSERT_TRUE(findMpfEntryLocations(displaced_signature, &locations)); + ASSERT_GE(locations.signature, 2u); + uint16_t app2_length = 0; + ASSERT_TRUE(readMpfU16ForTest(displaced_signature, locations.signature - 2, true, &app2_length)); + displaced_signature[locations.signature + 3] = 'x'; + displaced_signature.insert(displaced_signature.begin() + locations.signature + 4, + {'M', 'P', 'F', 0}); + writeMpfU16ForTest(displaced_signature, locations.signature - 2, true, app2_length + 4); + EXPECT_EQ(is_uhdr_image(displaced_signature.data(), static_cast(displaced_signature.size())), + 0); + EXPECT_EQ( + uhdr_is_supported_gainmap_image(displaced_signature.data(), displaced_signature.size()), 0); + + for (const uint16_t duplicate_tag : {uint16_t{0xb001}, uint16_t{0xb002}}) { + std::vector duplicate_mandatory_tag( + static_cast(output->data), + static_cast(output->data) + output->data_sz); + ASSERT_TRUE(insertDuplicateMpfTag(duplicate_mandatory_tag, duplicate_tag)); + EXPECT_EQ(is_uhdr_image(duplicate_mandatory_tag.data(), + static_cast(duplicate_mandatory_tag.size())), + 0); + EXPECT_EQ(uhdr_is_supported_gainmap_image(duplicate_mandatory_tag.data(), + duplicate_mandatory_tag.size()), + 0); + } + + ASSERT_TRUE(findMpfEntryLocations(malformed_table, &locations)); + struct VersionMutation { + size_t offset; + bool is_u16; + }; + for (const VersionMutation mutation : { + VersionMutation{locations.version_id, true}, + VersionMutation{locations.version_type, true}, + VersionMutation{locations.version_count, false}, + VersionMutation{locations.version_value, false}, + }) { + std::vector malformed_version(static_cast(output->data), + static_cast(output->data) + output->data_sz); + if (mutation.is_u16) { + writeMpfU16ForTest(malformed_version, mutation.offset, locations.big_endian, 0); + } else { + writeMpfU32ForTest(malformed_version, mutation.offset, locations.big_endian, 0); + } + EXPECT_EQ(is_uhdr_image(malformed_version.data(), static_cast(malformed_version.size())), + 0); + EXPECT_EQ(uhdr_is_supported_gainmap_image(malformed_version.data(), malformed_version.size()), + 0); + } + + for (const size_t invalid_field : + {locations.primary_attributes, locations.primary_offset, locations.secondary_attributes}) { + std::vector malformed_entry(static_cast(output->data), + static_cast(output->data) + output->data_sz); + ASSERT_TRUE(findMpfEntryLocations(malformed_entry, &locations)); + writeMpfU32ForTest(malformed_entry, invalid_field, locations.big_endian, 1); + EXPECT_EQ(is_uhdr_image(malformed_entry.data(), static_cast(malformed_entry.size())), 0); + EXPECT_EQ(uhdr_is_supported_gainmap_image(malformed_entry.data(), malformed_entry.size()), 0); + } // Decode stream uhdr_codec_private_t* dec = uhdr_create_decoder(); @@ -326,6 +633,47 @@ static bool setBackwardDirectionFlag(const uhdr_compressed_image_t* image, return true; } +TEST_F(UltraHdrApiTest, RoutingProbeReflectsHevcDecoderAvailability) { + std::vector hevc_gainmap; + ASSERT_TRUE(loadFile(kHevcGainmapFileName, hevc_gainmap)); + + EXPECT_EQ(is_uhdr_image(hevc_gainmap.data(), static_cast(hevc_gainmap.size())), 1); + const bool have_hevc_decoder = heif_have_decoder_for_format(heif_compression_HEVC); + EXPECT_EQ(uhdr_is_supported_gainmap_image(hevc_gainmap.data(), hevc_gainmap.size()), + have_hevc_decoder ? 1 : 0); + + if (have_hevc_decoder) { + uhdr_compressed_image_t input{}; + input.data = hevc_gainmap.data(); + input.data_sz = hevc_gainmap.size(); + input.capacity = hevc_gainmap.size(); + input.cg = UHDR_CG_UNSPECIFIED; + input.ct = UHDR_CT_UNSPECIFIED; + input.range = UHDR_CR_UNSPECIFIED; + uhdr_codec_private_t* decoder = uhdr_create_decoder(); + ASSERT_NE(decoder, nullptr); + EXPECT_EQ(uhdr_dec_set_image(decoder, &input).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_dec_set_out_color_transfer(decoder, UHDR_CT_HLG).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_dec_set_out_img_format(decoder, UHDR_IMG_FMT_32bppRGBA1010102).error_code, + UHDR_CODEC_OK); + EXPECT_EQ(uhdr_dec_probe(decoder).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_decode(decoder).error_code, UHDR_CODEC_OK); + uhdr_release_decoder(decoder); + } +} + +TEST_F(UltraHdrApiTest, RoutingProbeRejectsOrdinaryAvif) { + if (!heif_have_encoder_for_format(heif_compression_AV1)) { + GTEST_SKIP() << "AV1 encoder plugin not available in environment"; + } + std::vector ordinary_avif; + ASSERT_TRUE(encodeOrdinaryAvif(ordinary_avif)); + ASSERT_FALSE(ordinary_avif.empty()); + + EXPECT_EQ(is_uhdr_image(ordinary_avif.data(), static_cast(ordinary_avif.size())), 0); + EXPECT_EQ(uhdr_is_supported_gainmap_image(ordinary_avif.data(), ordinary_avif.size()), 0); +} + TEST_F(UltraHdrApiTest, HeicEncodeApi0AndDecode) { uhdr_codec_private_t* enc = uhdr_create_encoder(); ASSERT_NE(enc, nullptr); @@ -351,6 +699,9 @@ TEST_F(UltraHdrApiTest, HeicEncodeApi0AndDecode) { ASSERT_NE(output, nullptr); ASSERT_GT(output->data_sz, 0u); + EXPECT_EQ(is_uhdr_image(output->data, static_cast(output->data_sz)), 1); + EXPECT_EQ(uhdr_is_supported_gainmap_image(output->data, output->data_sz), 1); + // Decode HEIC stream uhdr_codec_private_t* dec = uhdr_create_decoder(); ASSERT_NE(dec, nullptr); @@ -418,6 +769,8 @@ TEST_F(UltraHdrApiTest, HeicEncodeApi1AndDecode) { ASSERT_NE(output, nullptr); ASSERT_GT(output->data_sz, 0u); EXPECT_EQ(getPrimaryImageTransfer(output), heif_transfer_characteristic_IEC_61966_2_1); + EXPECT_EQ(is_uhdr_image(output->data, static_cast(output->data_sz)), 1); + EXPECT_EQ(uhdr_is_supported_gainmap_image(output->data, output->data_sz), 1); uhdr_codec_private_t* dec = uhdr_create_decoder(); ASSERT_NE(dec, nullptr); @@ -492,6 +845,9 @@ TEST_F(UltraHdrApiTest, AvifEncodeApi0AndDecode) { ASSERT_NE(output, nullptr); ASSERT_GT(output->data_sz, 0u); + EXPECT_EQ(is_uhdr_image(output->data, static_cast(output->data_sz)), 1); + EXPECT_EQ(uhdr_is_supported_gainmap_image(output->data, output->data_sz), 1); + // Decode AVIF stream uhdr_codec_private_t* dec = uhdr_create_decoder(); ASSERT_NE(dec, nullptr); diff --git a/ultrahdr_api.h b/ultrahdr_api.h index 39e529f5..928d40f9 100644 --- a/ultrahdr_api.h +++ b/ultrahdr_api.h @@ -83,12 +83,14 @@ * 2.0.2 2.0.2 fix static libheif feature probing in Findlibheif.cmake; * make core target linking PRIVATE to prevent overlinking * downstream consumers. + * 2.1.0 2.1.0 add a runtime-aware gain-map routing predicate and + * correct structural gain-map recognition. */ // This needs to be kept in sync with version in CMakeLists.txt #define UHDR_LIB_VER_MAJOR 2 -#define UHDR_LIB_VER_MINOR 0 -#define UHDR_LIB_VER_PATCH 2 +#define UHDR_LIB_VER_MINOR 1 +#define UHDR_LIB_VER_PATCH 0 #define UHDR_LIB_VERSION \ ((UHDR_LIB_VER_MAJOR * 10000) + (UHDR_LIB_VER_MINOR * 100) + UHDR_LIB_VER_PATCH) @@ -614,9 +616,40 @@ UHDR_EXTERN void uhdr_reset_encoder(uhdr_codec_private_t* enc); * @returns 1 if the input data has a primary image, gain map image and gain map metadata. 0 if any * errors are encountered during parsing process or if the image does not have primary * image or gainmap image or gainmap metadata + * + * This is a structural check and does not guarantee that the current runtime can decode the image. */ UHDR_EXTERN int is_uhdr_image(void* data, int size); +/*!\brief Check whether this runtime supports routing a complete gain-map image to libultrahdr. + * + * This function validates the gain-map structure and metadata without decoding pixels. For + * HEIF/AVIF inputs, the current runtime-support policy is conservative: the primary and gain-map + * items must each be directly coded as AV1 (av01) or HEVC (hvc1/hev1), without alpha channels or + * derived-image layouts, and must use dimensions and color layouts supported by libultrahdr. The + * decoder families required by both items must also be currently available. Other structurally + * valid HEIF/AVIF gain-map structures may return 0. A positive result is a routing hint, not a + * guarantee that the compressed payload is complete or that every payload or codec profile will + * decode successfully. + * + * Because this function includes runtime and layout checks, a structurally recognized image may + * return 1 from is_uhdr_image() but 0 here. + * JPEG validation covers the MPF fields needed to associate the exact secondary image; unrelated + * primary-image size bookkeeping is tolerated for compatibility with existing Apple-authored files. + * + * The input buffer is borrowed for the duration of the call and is not modified or retained. The + * complete encoded stream and embedded JPEG payloads are not duplicated; parsers may allocate + * storage for container structures and metadata. + * + * @param[in] data pointer to a complete input compressed stream + * @param[in] size size of the compressed stream + * + * @returns 1 if the image has a supported gain-map structure and the required decoder families are + * available. 0 for ordinary images, malformed gain-map structure or metadata, unsupported + * HEIF/AVIF item or layout forms, unavailable decoder families, or parsing errors. + */ +UHDR_EXTERN int uhdr_is_supported_gainmap_image(const void* data, size_t size); + /*!\brief Create a new decoder instance. The instance is initialized with default settings. * To override the settings use uhdr_dec_set_*() * From af90073a7f02bb8bec27fe2cf6d5a8cfd03f7d2b Mon Sep 17 00:00:00 2001 From: Greg Benz Date: Wed, 26 Aug 2026 20:00:12 -0500 Subject: [PATCH 2/5] Avoid GCC warning in displaced MPF test --- tests/ultrahdr_api_test.cpp | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index c2c5ed15..cf9cc1e0 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -462,8 +462,11 @@ TEST_F(UltraHdrApiTest, JpegEncodeApi0AndDecode) { uint16_t app2_length = 0; ASSERT_TRUE(readMpfU16ForTest(displaced_signature, locations.signature - 2, true, &app2_length)); displaced_signature[locations.signature + 3] = 'x'; - displaced_signature.insert(displaced_signature.begin() + locations.signature + 4, - {'M', 'P', 'F', 0}); + const size_t displaced_mpf_offset = locations.signature + 4; + displaced_signature.insert(displaced_signature.begin() + displaced_mpf_offset, 4, uint8_t{0}); + displaced_signature[displaced_mpf_offset] = 'M'; + displaced_signature[displaced_mpf_offset + 1] = 'P'; + displaced_signature[displaced_mpf_offset + 2] = 'F'; writeMpfU16ForTest(displaced_signature, locations.signature - 2, true, app2_length + 4); EXPECT_EQ(is_uhdr_image(displaced_signature.data(), static_cast(displaced_signature.size())), 0); From dd6405dd62df73158f3a0225449b0fe83dd11bcd Mon Sep 17 00:00:00 2001 From: Greg Benz Date: Tue, 15 Sep 2026 20:29:52 -0500 Subject: [PATCH 3/5] Leave release version unchanged in routing API change --- CMakeLists.txt | 4 ++-- ultrahdr_api.h | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6f05945a..98b89f98 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,8 +12,8 @@ cmake_minimum_required(VERSION 3.15) cmake_policy(SET CMP0091 NEW) set(UHDR_MAJOR_VERSION 2) -set(UHDR_MINOR_VERSION 1) -set(UHDR_PATCH_VERSION 0) +set(UHDR_MINOR_VERSION 0) +set(UHDR_PATCH_VERSION 2) project(libuhdr VERSION ${UHDR_MAJOR_VERSION}.${UHDR_MINOR_VERSION}.${UHDR_PATCH_VERSION} LANGUAGES C CXX diff --git a/ultrahdr_api.h b/ultrahdr_api.h index 928d40f9..a4f23552 100644 --- a/ultrahdr_api.h +++ b/ultrahdr_api.h @@ -83,14 +83,12 @@ * 2.0.2 2.0.2 fix static libheif feature probing in Findlibheif.cmake; * make core target linking PRIVATE to prevent overlinking * downstream consumers. - * 2.1.0 2.1.0 add a runtime-aware gain-map routing predicate and - * correct structural gain-map recognition. */ // This needs to be kept in sync with version in CMakeLists.txt #define UHDR_LIB_VER_MAJOR 2 -#define UHDR_LIB_VER_MINOR 1 -#define UHDR_LIB_VER_PATCH 0 +#define UHDR_LIB_VER_MINOR 0 +#define UHDR_LIB_VER_PATCH 2 #define UHDR_LIB_VERSION \ ((UHDR_LIB_VER_MAJOR * 10000) + (UHDR_LIB_VER_MINOR * 100) + UHDR_LIB_VER_PATCH) From a74426af581b890bb750d68f0941fa64c6a17c7d Mon Sep 17 00:00:00 2001 From: Greg Benz Date: Sat, 19 Sep 2026 08:26:36 -0500 Subject: [PATCH 4/5] Keep gain-map routing additive and support alpha dependencies Preserve the legacy probe implementation, make item inspection optional for system libheif, and validate direct alpha decoder dependencies. Update the public contract and compatibility regressions. --- CMakeLists.txt | 14 +++- cmake/Findlibheif.cmake | 28 +++++++- lib/src/ultrahdr_api.cpp | 130 +++++++++++++++++++++++++++++------- tests/ultrahdr_api_test.cpp | 57 ++++++++++++---- ultrahdr_api.h | 22 +++--- 5 files changed, 199 insertions(+), 52 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7aaed5d6..b5f748e9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -559,8 +559,8 @@ if(UHDR_ENABLE_HEIF) set(LIBHEIF_SYSTEM_INCOMPATIBLE FALSE) if(NOT UHDR_BUILD_DEPS) find_package(libheif QUIET) - if(LIBHEIF_FOUND AND (NOT LIBHEIF_HAS_GAIN_MAP OR NOT LIBHEIF_HAS_ITEM_API)) - message(WARNING "System libheif found but lacks required gain map or item inspection APIs. " + if(LIBHEIF_FOUND AND NOT LIBHEIF_HAS_GAIN_MAP) + message(WARNING "System libheif found but lacks the required ISO 21496-1 gain map API. " "HEIF/AVIF support will be disabled because UHDR_BUILD_DEPS is OFF.") set(LIBHEIF_SYSTEM_INCOMPATIBLE TRUE) set(LIBHEIF_FOUND FALSE) @@ -644,11 +644,21 @@ if(UHDR_ENABLE_HEIF) INTERFACE_LINK_LIBRARIES "${CMAKE_DL_LIBS}" ) endif() + set(LIBHEIF_HAS_GAIN_MAP TRUE) + set(LIBHEIF_HAS_ITEM_API TRUE) set(LIBHEIF_FOUND TRUE) endif() if(LIBHEIF_FOUND) message(STATUS "Found libheif: ${LIBHEIF_LIBRARIES}") add_compile_options(-DUHDR_ENABLE_HEIF) + if(LIBHEIF_HAS_ITEM_API) + message(STATUS "libheif item inspection API - Available") + add_compile_options(-DUHDR_HAS_HEIF_ITEM_API) + else() + message(WARNING "libheif item inspection API unavailable. HEIF/AVIF encoding and decoding " + "remain enabled, but uhdr_is_supported_gainmap_image() returns 0 for " + "HEIF/AVIF.") + endif() list(APPEND PRIVATE_LINK_LIBS libheif::heif) else() if(NOT LIBHEIF_SYSTEM_INCOMPATIBLE) diff --git a/cmake/Findlibheif.cmake b/cmake/Findlibheif.cmake index f88c6edb..4c67f319 100644 --- a/cmake/Findlibheif.cmake +++ b/cmake/Findlibheif.cmake @@ -14,6 +14,7 @@ # LIBHEIF_LIBRARIES - Libraries needed to link against libheif include(CheckCXXSymbolExists) +include(CheckCXXSourceCompiles) include(FindPackageHandleStandardArgs) set(LIBHEIF_TARGET "") @@ -106,9 +107,30 @@ if(LIBHEIF_TARGET) "libheif/heif.h" LIBHEIF_HAS_GAIN_MAP ) - check_cxx_symbol_exists( - heif_item_get_item_type - "libheif/heif_items.h" + check_cxx_source_compiles( + [=[ +#include "libheif/heif.h" +#include "libheif/heif_items.h" + +int main() { + const heif_context* context = nullptr; + const heif_image_handle* handle = nullptr; + const heif_item_id item_id = heif_image_handle_get_item_id(handle); + const uint32_t item_type = heif_item_get_item_type(context, item_id); + const int aux_filter = 0; + heif_item_id auxiliary_ids[1] = {}; + heif_image_handle* auxiliary_handle = nullptr; + const char* auxiliary_type = nullptr; + + (void)heif_image_handle_get_number_of_auxiliary_images(handle, aux_filter); + (void)heif_image_handle_get_list_of_auxiliary_image_IDs(handle, aux_filter, auxiliary_ids, 1); + (void)heif_image_handle_get_auxiliary_type(handle, &auxiliary_type); + (void)heif_image_handle_release_auxiliary_type(handle, &auxiliary_type); + (void)heif_image_handle_get_auxiliary_image_handle(handle, item_id, &auxiliary_handle); + (void)item_type; + return 0; +} +]=] LIBHEIF_HAS_ITEM_API ) diff --git a/lib/src/ultrahdr_api.cpp b/lib/src/ultrahdr_api.cpp index d2acb27a..bbd3f699 100644 --- a/lib/src/ultrahdr_api.cpp +++ b/lib/src/ultrahdr_api.cpp @@ -24,8 +24,10 @@ #include "ultrahdr/heifultrahdr.h" #include "ultrahdr/avifultrahdr.h" #include "ultrahdr/gainmapmetadata.h" +#if defined(UHDR_HAS_HEIF_ITEM_API) #include "libheif/heif_items.h" #endif +#endif #include "ultrahdr/jpegrutils.h" @@ -505,11 +507,6 @@ uhdr_error_info_t uhdr_validate_gainmap_metadata_descriptor(uhdr_gainmap_metadat namespace { -enum class GainmapProbeRequirement { - kStructural, - kRuntimeSupport, -}; - bool inspect_jpeg_gainmap(const void* data, size_t size) { uhdr_compressed_image_t image{}; image.data = const_cast(data); @@ -536,9 +533,10 @@ bool inspect_jpeg_gainmap(const void* data, size_t size) { .error_code == UHDR_CODEC_OK; } -#ifdef UHDR_ENABLE_HEIF +#if defined(UHDR_ENABLE_HEIF) && defined(UHDR_HAS_HEIF_ITEM_API) constexpr size_t kMaxProbeMetadataSize = 64 * 1024; +constexpr int kMaxProbeAuxiliaryImages = 16; struct HeifContextDeleter { void operator()(heif_context* context) const { heif_context_free(context); } @@ -570,7 +568,6 @@ bool is_supported_heif_layout(const heif_image_handle* handle, bool allow_monoch bool get_direct_image_decoder_format(const heif_context* context, const heif_image_handle* handle, heif_compression_format* format) { - if (heif_image_handle_has_alpha_channel(handle)) return false; const uint32_t type = heif_item_get_item_type(context, heif_image_handle_get_item_id(handle)); if (type == heif_fourcc('a', 'v', '0', '1')) { *format = heif_compression_AV1; @@ -583,7 +580,66 @@ bool get_direct_image_decoder_format(const heif_context* context, const heif_ima return false; } -bool inspect_heif_gainmap(const void* data, size_t size, GainmapProbeRequirement requirement) { +bool is_known_alpha_auxiliary_type(const char* type) { + return type != nullptr && + (strcmp(type, "urn:mpeg:avc:2015:auxid:1") == 0 || + strcmp(type, "urn:mpeg:hevc:2015:auxid:1") == 0 || + strcmp(type, "urn:mpeg:mpegB:cicp:systems:auxiliary:alpha") == 0); +} + +bool inspect_heif_alpha_dependencies(const heif_context* context, + const heif_image_handle* handle) { + if (!heif_image_handle_has_alpha_channel(handle)) return true; + + const int auxiliary_count = heif_image_handle_get_number_of_auxiliary_images(handle, 0); + if (auxiliary_count <= 0 || auxiliary_count > kMaxProbeAuxiliaryImages) return false; + + std::vector auxiliary_ids(auxiliary_count); + if (heif_image_handle_get_list_of_auxiliary_image_IDs(handle, 0, auxiliary_ids.data(), + auxiliary_count) != auxiliary_count) { + return false; + } + + bool found_alpha = false; + for (heif_item_id auxiliary_id : auxiliary_ids) { + heif_image_handle* auxiliary_raw = nullptr; + if (heif_image_handle_get_auxiliary_image_handle(handle, auxiliary_id, &auxiliary_raw).code != + heif_error_Ok || + auxiliary_raw == nullptr) { + return false; + } + std::unique_ptr auxiliary(auxiliary_raw); + + const char* auxiliary_type = nullptr; + const heif_error type_error = + heif_image_handle_get_auxiliary_type(auxiliary.get(), &auxiliary_type); + const bool type_resolved = type_error.code == heif_error_Ok && auxiliary_type != nullptr; + const bool is_alpha = type_resolved && is_known_alpha_auxiliary_type(auxiliary_type); + heif_image_handle_release_auxiliary_type(auxiliary.get(), &auxiliary_type); + if (!type_resolved) return false; + if (!is_alpha) continue; + if (found_alpha) return false; + found_alpha = true; + + heif_compression_format auxiliary_format; + if (!get_direct_image_decoder_format(context, auxiliary.get(), &auxiliary_format) || + !heif_have_decoder_for_format(auxiliary_format)) { + return false; + } + + // Alpha is supported as a direct independent dependency, allowing monochrome layouts. Do not + // follow an alpha dependency's own auxiliary graph. + if (heif_image_handle_has_alpha_channel(auxiliary.get()) || + !is_supported_heif_dimensions(auxiliary.get()) || + !is_supported_heif_layout(auxiliary.get(), true)) { + return false; + } + } + + return found_alpha; +} + +bool inspect_heif_gainmap(const void* data, size_t size) { const int sniff_size = static_cast((std::min)(size, static_cast((std::numeric_limits::max)()))); const heif_filetype_result filetype = @@ -631,8 +687,6 @@ bool inspect_heif_gainmap(const void* data, size_t size, GainmapProbeRequirement return false; } - if (requirement == GainmapProbeRequirement::kStructural) return true; - if (!is_supported_heif_dimensions(primary.get()) || !is_supported_heif_dimensions(gainmap.get()) || !is_supported_heif_layout(primary.get(), false) || @@ -646,36 +700,38 @@ bool inspect_heif_gainmap(const void* data, size_t size, GainmapProbeRequirement !get_direct_image_decoder_format(context.get(), gainmap.get(), &gainmap_format)) { return false; } - return heif_have_decoder_for_format(primary_format) && - heif_have_decoder_for_format(gainmap_format); + if (!heif_have_decoder_for_format(primary_format) || + !heif_have_decoder_for_format(gainmap_format)) { + return false; + } + return inspect_heif_alpha_dependencies(context.get(), primary.get()) && + inspect_heif_alpha_dependencies(context.get(), gainmap.get()); } -#endif // UHDR_ENABLE_HEIF +#endif // defined(UHDR_ENABLE_HEIF) && defined(UHDR_HAS_HEIF_ITEM_API) -bool inspect_gainmap_image(const void* data, size_t size, GainmapProbeRequirement requirement) { +bool inspect_gainmap_image(const void* data, size_t size) { if (data == nullptr || size == 0) return false; const uint8_t* bytes = static_cast(data); if (size >= 3 && memcmp(bytes, "\377\330\377", 3) == 0) { return inspect_jpeg_gainmap(data, size); } -#ifdef UHDR_ENABLE_HEIF - return inspect_heif_gainmap(data, size, requirement); +#if defined(UHDR_ENABLE_HEIF) && defined(UHDR_HAS_HEIF_ITEM_API) + return inspect_heif_gainmap(data, size); #else - (void)requirement; return false; #endif } -int inspect_gainmap_image_for_c_api(const void* data, size_t size, - GainmapProbeRequirement requirement) { +int inspect_gainmap_image_for_c_api(const void* data, size_t size) { #if defined(__cpp_exceptions) || defined(__EXCEPTIONS) || defined(_CPPUNWIND) try { - return inspect_gainmap_image(data, size, requirement); + return inspect_gainmap_image(data, size); } catch (...) { return 0; } #else - return inspect_gainmap_image(data, size, requirement); + return inspect_gainmap_image(data, size); #endif } @@ -1663,13 +1719,37 @@ void uhdr_reset_encoder(uhdr_codec_private_t* enc) { } int is_uhdr_image(void* data, int size) { - if (size <= 0) return 0; - return inspect_gainmap_image_for_c_api(data, static_cast(size), - GainmapProbeRequirement::kStructural); + if (data == nullptr || size < 4) return 0; + +#define RET_IF_ERR(x) \ + { \ + uhdr_error_info_t status = (x); \ + if (status.error_code != UHDR_CODEC_OK) { \ + uhdr_release_decoder(obj); \ + return 0; \ + } \ + } + + uhdr_codec_private_t* obj = uhdr_create_decoder(); + uhdr_compressed_image_t uhdr_image; + uhdr_image.data = data; + uhdr_image.data_sz = size; + uhdr_image.capacity = size; + uhdr_image.cg = UHDR_CG_UNSPECIFIED; + uhdr_image.ct = UHDR_CT_UNSPECIFIED; + uhdr_image.range = UHDR_CR_UNSPECIFIED; + + RET_IF_ERR(uhdr_dec_set_image(obj, &uhdr_image)); + RET_IF_ERR(uhdr_dec_probe(obj)); +#undef RET_IF_ERR + + uhdr_release_decoder(obj); + + return 1; } UHDR_EXTERN int uhdr_is_supported_gainmap_image(const void* data, size_t size) { - return inspect_gainmap_image_for_c_api(data, size, GainmapProbeRequirement::kRuntimeSupport); + return inspect_gainmap_image_for_c_api(data, size); } uhdr_codec_private_t* uhdr_create_decoder(void) { diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index 16e5e6c2..91bb5d13 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -212,6 +212,17 @@ static bool insertDuplicateMpfTag(std::vector& data, uint16_t tag_id) { } #if defined(UHDR_ENABLE_HEIF) +static void expectSupportedHeifGainmap(const void* data, size_t size, bool decoder_available) { +#if defined(UHDR_HAS_HEIF_ITEM_API) + EXPECT_EQ(uhdr_is_supported_gainmap_image(data, size), decoder_available ? 1 : 0); +#else + // The legacy HEIF gain-map APIs keep encode/decode coverage available, but routing requires + // the optional item-inspection API. The documented fallback is an unsupported routing result. + EXPECT_EQ(uhdr_is_supported_gainmap_image(data, size), 0); + (void)decoder_available; +#endif +} + static heif_error writeHeifToVector([[maybe_unused]] heif_context* context, const void* data, size_t size, void* userdata) { auto* output = static_cast*>(userdata); @@ -444,8 +455,10 @@ TEST_F(UltraHdrApiTest, JpegEncodeApi0AndDecode) { locations.big_endian, &secondary_offset)); writeMpfU32ForTest(broken_association, locations.secondary_offset, locations.big_endian, secondary_offset + 1); + // is_uhdr_image() retains its historical decoder-probe behavior. The stricter MPF association + // check belongs to the new routing predicate. EXPECT_EQ(is_uhdr_image(broken_association.data(), static_cast(broken_association.size())), - 0); + 1); EXPECT_EQ(uhdr_is_supported_gainmap_image(broken_association.data(), broken_association.size()), 0); @@ -453,7 +466,7 @@ TEST_F(UltraHdrApiTest, JpegEncodeApi0AndDecode) { static_cast(output->data) + output->data_sz); ASSERT_TRUE(findMpfEntryLocations(malformed_table, &locations)); writeMpfU32ForTest(malformed_table, locations.count, locations.big_endian, 0xffffffffu); - EXPECT_EQ(is_uhdr_image(malformed_table.data(), static_cast(malformed_table.size())), 0); + EXPECT_EQ(is_uhdr_image(malformed_table.data(), static_cast(malformed_table.size())), 1); EXPECT_EQ(uhdr_is_supported_gainmap_image(malformed_table.data(), malformed_table.size()), 0); std::vector displaced_signature(static_cast(output->data), @@ -470,7 +483,7 @@ TEST_F(UltraHdrApiTest, JpegEncodeApi0AndDecode) { displaced_signature[displaced_mpf_offset + 2] = 'F'; writeMpfU16ForTest(displaced_signature, locations.signature - 2, true, app2_length + 4); EXPECT_EQ(is_uhdr_image(displaced_signature.data(), static_cast(displaced_signature.size())), - 0); + 1); EXPECT_EQ( uhdr_is_supported_gainmap_image(displaced_signature.data(), displaced_signature.size()), 0); @@ -481,7 +494,7 @@ TEST_F(UltraHdrApiTest, JpegEncodeApi0AndDecode) { ASSERT_TRUE(insertDuplicateMpfTag(duplicate_mandatory_tag, duplicate_tag)); EXPECT_EQ(is_uhdr_image(duplicate_mandatory_tag.data(), static_cast(duplicate_mandatory_tag.size())), - 0); + 1); EXPECT_EQ(uhdr_is_supported_gainmap_image(duplicate_mandatory_tag.data(), duplicate_mandatory_tag.size()), 0); @@ -506,7 +519,7 @@ TEST_F(UltraHdrApiTest, JpegEncodeApi0AndDecode) { writeMpfU32ForTest(malformed_version, mutation.offset, locations.big_endian, 0); } EXPECT_EQ(is_uhdr_image(malformed_version.data(), static_cast(malformed_version.size())), - 0); + 1); EXPECT_EQ(uhdr_is_supported_gainmap_image(malformed_version.data(), malformed_version.size()), 0); } @@ -517,7 +530,7 @@ TEST_F(UltraHdrApiTest, JpegEncodeApi0AndDecode) { static_cast(output->data) + output->data_sz); ASSERT_TRUE(findMpfEntryLocations(malformed_entry, &locations)); writeMpfU32ForTest(malformed_entry, invalid_field, locations.big_endian, 1); - EXPECT_EQ(is_uhdr_image(malformed_entry.data(), static_cast(malformed_entry.size())), 0); + EXPECT_EQ(is_uhdr_image(malformed_entry.data(), static_cast(malformed_entry.size())), 1); EXPECT_EQ(uhdr_is_supported_gainmap_image(malformed_entry.data(), malformed_entry.size()), 0); } @@ -643,8 +656,7 @@ TEST_F(UltraHdrApiTest, RoutingProbeReflectsHevcDecoderAvailability) { EXPECT_EQ(is_uhdr_image(hevc_gainmap.data(), static_cast(hevc_gainmap.size())), 1); const bool have_hevc_decoder = heif_have_decoder_for_format(heif_compression_HEVC); - EXPECT_EQ(uhdr_is_supported_gainmap_image(hevc_gainmap.data(), hevc_gainmap.size()), - have_hevc_decoder ? 1 : 0); + expectSupportedHeifGainmap(hevc_gainmap.data(), hevc_gainmap.size(), have_hevc_decoder); if (have_hevc_decoder) { uhdr_compressed_image_t input{}; @@ -666,7 +678,7 @@ TEST_F(UltraHdrApiTest, RoutingProbeReflectsHevcDecoderAvailability) { } } -TEST_F(UltraHdrApiTest, RoutingProbeRejectsOrdinaryAvif) { +TEST_F(UltraHdrApiTest, RoutingProbeRejectsOrdinaryAvifButLegacyPredicateRemainsCompatible) { if (!heif_have_encoder_for_format(heif_compression_AV1)) { GTEST_SKIP() << "AV1 encoder plugin not available in environment"; } @@ -674,8 +686,10 @@ TEST_F(UltraHdrApiTest, RoutingProbeRejectsOrdinaryAvif) { ASSERT_TRUE(encodeOrdinaryAvif(ordinary_avif)); ASSERT_FALSE(ordinary_avif.empty()); - EXPECT_EQ(is_uhdr_image(ordinary_avif.data(), static_cast(ordinary_avif.size())), 0); - EXPECT_EQ(uhdr_is_supported_gainmap_image(ordinary_avif.data(), ordinary_avif.size()), 0); + // The historical decoder-probe implementation returns 1 for a parseable HEIF/AVIF container + // even when it has no gain map. Keep that compatibility behavior explicit here. + EXPECT_EQ(is_uhdr_image(ordinary_avif.data(), static_cast(ordinary_avif.size())), 1); + expectSupportedHeifGainmap(ordinary_avif.data(), ordinary_avif.size(), false); } namespace { @@ -894,7 +908,8 @@ TEST_F(UltraHdrApiTest, HeicEncodeApi0AndDecode) { ASSERT_GT(output->data_sz, 0u); EXPECT_EQ(is_uhdr_image(output->data, static_cast(output->data_sz)), 1); - EXPECT_EQ(uhdr_is_supported_gainmap_image(output->data, output->data_sz), 1); + expectSupportedHeifGainmap(output->data, output->data_sz, + heif_have_decoder_for_format(heif_compression_HEVC)); // Decode HEIC stream uhdr_codec_private_t* dec = uhdr_create_decoder(); @@ -964,7 +979,8 @@ TEST_F(UltraHdrApiTest, HeicEncodeApi1AndDecode) { ASSERT_GT(output->data_sz, 0u); EXPECT_EQ(getPrimaryImageTransfer(output), heif_transfer_characteristic_IEC_61966_2_1); EXPECT_EQ(is_uhdr_image(output->data, static_cast(output->data_sz)), 1); - EXPECT_EQ(uhdr_is_supported_gainmap_image(output->data, output->data_sz), 1); + expectSupportedHeifGainmap(output->data, output->data_sz, + heif_have_decoder_for_format(heif_compression_HEVC)); uhdr_codec_private_t* dec = uhdr_create_decoder(); ASSERT_NE(dec, nullptr); @@ -1040,7 +1056,8 @@ TEST_F(UltraHdrApiTest, AvifEncodeApi0AndDecode) { ASSERT_GT(output->data_sz, 0u); EXPECT_EQ(is_uhdr_image(output->data, static_cast(output->data_sz)), 1); - EXPECT_EQ(uhdr_is_supported_gainmap_image(output->data, output->data_sz), 1); + expectSupportedHeifGainmap(output->data, output->data_sz, + heif_have_decoder_for_format(heif_compression_AV1)); // Decode AVIF stream uhdr_codec_private_t* dec = uhdr_create_decoder(); @@ -1105,6 +1122,9 @@ TEST_F(UltraHdrApiTest, AvifEncodeApi1AndDecode) { ASSERT_NE(output, nullptr); ASSERT_GT(output->data_sz, 0u); EXPECT_EQ(getPrimaryImageTransfer(output), heif_transfer_characteristic_IEC_61966_2_1); + EXPECT_EQ(is_uhdr_image(output->data, static_cast(output->data_sz)), 1); + expectSupportedHeifGainmap(output->data, output->data_sz, + heif_have_decoder_for_format(heif_compression_AV1)); uhdr_codec_private_t* dec = uhdr_create_decoder(); ASSERT_NE(dec, nullptr); @@ -1331,6 +1351,12 @@ TEST_F(UltraHdrApiTest, AvifPreservesRawAlpha) { GTEST_SKIP() << "AV1 encoder plugin not available in environment: " << status.detail; } ASSERT_EQ(status.error_code, UHDR_CODEC_OK) << (status.has_detail ? status.detail : "no detail"); + // This fixture has straight alpha on the primary AVIF item. Its gain-map item is ordinary + // monochrome and is not alpha-bearing. When item inspection is available, the routing assertion + // covers the primary-alpha form; the helper below also performs a real SDR decode to RGBA. + EXPECT_EQ(is_uhdr_image(encoded.data(), static_cast(encoded.size())), 1); + expectSupportedHeifGainmap(encoded.data(), encoded.size(), + heif_have_decoder_for_format(heif_compression_AV1)); EXPECT_TRUE(decodedAlphaMatches(encoded)); expectRawAlphaPreserved(UHDR_CODEC_AVIF, &images.hdrHalfFloatDesc); images.makeHdr1010102AlphaOpaque(); @@ -1346,6 +1372,9 @@ TEST_F(UltraHdrApiTest, HeifPreservesRawAlpha) { GTEST_SKIP() << "HEVC encoder plugin not available in environment: " << status.detail; } ASSERT_EQ(status.error_code, UHDR_CODEC_OK) << (status.has_detail ? status.detail : "no detail"); + EXPECT_EQ(is_uhdr_image(encoded.data(), static_cast(encoded.size())), 1); + expectSupportedHeifGainmap(encoded.data(), encoded.size(), + heif_have_decoder_for_format(heif_compression_HEVC)); EXPECT_TRUE(decodedAlphaMatches(encoded)); expectRawAlphaPreserved(UHDR_CODEC_HEIF, &images.hdrHalfFloatDesc); images.makeHdr1010102AlphaOpaque(); diff --git a/ultrahdr_api.h b/ultrahdr_api.h index a4f23552..4a112359 100644 --- a/ultrahdr_api.h +++ b/ultrahdr_api.h @@ -616,6 +616,7 @@ UHDR_EXTERN void uhdr_reset_encoder(uhdr_codec_private_t* enc); * image or gainmap image or gainmap metadata * * This is a structural check and does not guarantee that the current runtime can decode the image. + * The legacy implementation retains its historical decoder-probe behavior. */ UHDR_EXTERN int is_uhdr_image(void* data, int size); @@ -623,17 +624,22 @@ UHDR_EXTERN int is_uhdr_image(void* data, int size); * * This function validates the gain-map structure and metadata without decoding pixels. For * HEIF/AVIF inputs, the current runtime-support policy is conservative: the primary and gain-map - * items must each be directly coded as AV1 (av01) or HEVC (hvc1/hev1), without alpha channels or - * derived-image layouts, and must use dimensions and color layouts supported by libultrahdr. The - * decoder families required by both items must also be currently available. Other structurally - * valid HEIF/AVIF gain-map structures may return 0. A positive result is a routing hint, not a - * guarantee that the compressed payload is complete or that every payload or codec profile will - * decode successfully. + * items must each be directly coded as AV1 (av01) or HEVC (hvc1/hev1), and any directly associated + * alpha dependency must also be directly coded and use a supported color layout (including + * monochrome for alpha). Derived-image layouts, unresolved dependencies, and nested alpha + * dependencies are rejected. The primary and gain-map items must use dimensions and color layouts + * supported by libultrahdr. Their required decoder families must all be available. Other + * structurally valid HEIF/AVIF gain-map structures may return 0. If the build lacks libheif item + * inspection, this function returns 0 for HEIF/AVIF; existing encode/decode APIs and JPEG routing + * remain available. A positive result is a routing hint, not a guarantee that the compressed + * payload is complete or that every payload or codec profile will decode successfully. * * Because this function includes runtime and layout checks, a structurally recognized image may * return 1 from is_uhdr_image() but 0 here. - * JPEG validation covers the MPF fields needed to associate the exact secondary image; unrelated - * primary-image size bookkeeping is tolerated for compatibility with existing Apple-authored files. + * This function is the strict MPF validator for JPEG routing and covers the fields needed to + * associate the exact secondary image; unrelated primary-image size bookkeeping is tolerated for + * compatibility with existing Apple-authored files. The legacy is_uhdr_image() probe is not + * replaced by this stricter MPF validation. * * The input buffer is borrowed for the duration of the call and is not modified or retained. The * complete encoded stream and embedded JPEG payloads are not duplicated; parsers may allocate From e92c3c9c666d66803887b6962334a5faf9d5c379 Mon Sep 17 00:00:00 2001 From: Greg Benz Date: Sat, 19 Sep 2026 13:52:35 -0500 Subject: [PATCH 5/5] Test routing support for gain-map-side AVIF alpha --- tests/ultrahdr_api_test.cpp | 180 ++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index 91bb5d13..f8b9d213 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -19,6 +19,7 @@ #include "ultrahdr/ultrahdrcommon.h" #include "ultrahdr/jpegr.h" #include "ultrahdr/gainmapmath.h" +#include "ultrahdr/gainmapmetadata.h" #include "ultrahdr/heifultrahdr.h" #include "ultrahdr/avifultrahdr.h" #if defined(UHDR_ENABLE_HEIF) @@ -274,6 +275,151 @@ static bool encodeOrdinaryAvif(std::vector& output) { heif_context_free(context); return success; } + +using HeifContextPtr = std::unique_ptr; +using HeifEncoderPtr = std::unique_ptr; +using HeifImagePtr = std::unique_ptr; +using HeifHandlePtr = + std::unique_ptr; +using HeifOptionsPtr = + std::unique_ptr; +using HeifProfilePtr = + std::unique_ptr; + +static bool fillHeifPlane(heif_image* image, heif_channel channel, int width, int height, + uint8_t value) { + int stride = 0; + uint8_t* plane = heif_image_get_plane(image, channel, &stride); + if (plane == nullptr || stride < width) return false; + for (int y = 0; y < height; ++y) { + std::fill(plane + y * stride, plane + y * stride + width, value); + } + return true; +} + +static HeifProfilePtr makeNclxProfile(uint16_t primaries, uint16_t transfer, uint16_t matrix) { + HeifProfilePtr profile(heif_nclx_color_profile_alloc(), heif_nclx_color_profile_free); + if (profile == nullptr || + heif_nclx_color_profile_set_color_primaries(profile.get(), primaries).code != + heif_error_Ok || + heif_nclx_color_profile_set_transfer_characteristics(profile.get(), transfer).code != + heif_error_Ok || + heif_nclx_color_profile_set_matrix_coefficients(profile.get(), matrix).code != + heif_error_Ok) { + return HeifProfilePtr(nullptr, heif_nclx_color_profile_free); + } + profile->full_range_flag = 1; + return profile; +} + +static bool encodeGainmapSideAlphaAvif(std::vector& output) { + constexpr int base_width = 64; + constexpr int base_height = 64; + constexpr int gainmap_width = 32; + constexpr int gainmap_height = 32; + + HeifContextPtr context(heif_context_alloc(), heif_context_free); + if (context == nullptr) return false; + + heif_image* base_raw = nullptr; + if (heif_image_create(base_width, base_height, heif_colorspace_YCbCr, heif_chroma_420, + &base_raw) + .code != heif_error_Ok) { + return false; + } + HeifImagePtr base(base_raw, heif_image_release); + if (heif_image_add_plane(base.get(), heif_channel_Y, base_width, base_height, 8).code != + heif_error_Ok || + heif_image_add_plane(base.get(), heif_channel_Cb, base_width / 2, base_height / 2, 8) + .code != heif_error_Ok || + heif_image_add_plane(base.get(), heif_channel_Cr, base_width / 2, base_height / 2, 8) + .code != heif_error_Ok || + !fillHeifPlane(base.get(), heif_channel_Y, base_width, base_height, 128) || + !fillHeifPlane(base.get(), heif_channel_Cb, base_width / 2, base_height / 2, 128) || + !fillHeifPlane(base.get(), heif_channel_Cr, base_width / 2, base_height / 2, 128)) { + return false; + } + + heif_image* gainmap_raw = nullptr; + if (heif_image_create(gainmap_width, gainmap_height, heif_colorspace_monochrome, + heif_chroma_monochrome, &gainmap_raw) + .code != heif_error_Ok) { + return false; + } + HeifImagePtr gainmap(gainmap_raw, heif_image_release); + if (heif_image_add_plane(gainmap.get(), heif_channel_Y, gainmap_width, gainmap_height, 8) + .code != heif_error_Ok || + heif_image_add_plane(gainmap.get(), heif_channel_Alpha, gainmap_width, gainmap_height, 8) + .code != heif_error_Ok || + !fillHeifPlane(gainmap.get(), heif_channel_Y, gainmap_width, gainmap_height, 96) || + !fillHeifPlane(gainmap.get(), heif_channel_Alpha, gainmap_width, gainmap_height, 128)) { + return false; + } + heif_image_set_premultiplied_alpha(gainmap.get(), 0); + + HeifProfilePtr base_profile = + makeNclxProfile(heif_color_primaries_ITU_R_BT_709_5, + heif_transfer_characteristic_ITU_R_BT_709_5, + heif_matrix_coefficients_ITU_R_BT_709_5); + HeifProfilePtr gainmap_profile = + makeNclxProfile(heif_color_primaries_unspecified, heif_transfer_characteristic_unspecified, + heif_matrix_coefficients_ITU_R_BT_601_6); + HeifProfilePtr derived_profile = + makeNclxProfile(heif_color_primaries_ITU_R_BT_2020_2_and_2100_0, + heif_transfer_characteristic_ITU_R_BT_2100_0_HLG, + heif_matrix_coefficients_ITU_R_BT_2020_2_non_constant_luminance); + if (base_profile == nullptr || gainmap_profile == nullptr || derived_profile == nullptr || + heif_image_set_nclx_color_profile(base.get(), base_profile.get()).code != heif_error_Ok || + heif_image_set_nclx_color_profile(gainmap.get(), gainmap_profile.get()).code != + heif_error_Ok) { + return false; + } + + heif_encoder* encoder_raw = nullptr; + if (heif_context_get_encoder_for_format(context.get(), heif_compression_AV1, &encoder_raw) + .code != heif_error_Ok) { + return false; + } + HeifEncoderPtr encoder(encoder_raw, heif_encoder_release); + if (heif_encoder_set_lossy_quality(encoder.get(), 100).code != heif_error_Ok) return false; + + HeifOptionsPtr options(heif_encoding_options_alloc(), heif_encoding_options_free); + if (options == nullptr) return false; + options->save_alpha_channel = 1; + options->output_nclx_profile = base_profile.get(); + + heif_image_handle* base_handle_raw = nullptr; + if (heif_context_encode_image(context.get(), base.get(), encoder.get(), options.get(), + &base_handle_raw) + .code != heif_error_Ok) { + return false; + } + HeifHandlePtr base_handle(base_handle_raw, heif_image_handle_release); + + uhdr_gainmap_metadata_frac metadata; + metadata.alternateHdrHeadroomN = 2; + std::vector iso_metadata; + if (uhdr_gainmap_metadata_frac::encodeGainmapMetadata(&metadata, iso_metadata).error_code != + UHDR_CODEC_OK) { + return false; + } + + options->output_nclx_profile = gainmap_profile.get(); + if (heif_encoder_set_lossy_quality(encoder.get(), 100).code != heif_error_Ok) return false; + heif_image_handle* gainmap_handle_raw = nullptr; + if (heif_context_encode_gain_map_image( + context.get(), base_handle.get(), encoder.get(), gainmap.get(), options.get(), + iso_metadata.data(), static_cast(iso_metadata.size()), derived_profile.get(), + &gainmap_handle_raw) + .code != heif_error_Ok) { + return false; + } + HeifHandlePtr gainmap_handle(gainmap_handle_raw, heif_image_handle_release); + + heif_writer writer{1, writeHeifToVector}; + return heif_context_write(context.get(), &writer, &output).code == heif_error_Ok && + !output.empty(); +} #endif static void expectGainMapsEqual(const uhdr_raw_image_t* expected, @@ -692,6 +838,40 @@ TEST_F(UltraHdrApiTest, RoutingProbeRejectsOrdinaryAvifButLegacyPredicateRemains expectSupportedHeifGainmap(ordinary_avif.data(), ordinary_avif.size(), false); } +TEST(UltraHdrRoutingTest, GainmapSideAlphaIsSupported) { + if (!heif_have_encoder_for_format(heif_compression_AV1)) { + GTEST_SKIP() << "AV1 encoder plugin not available in environment"; + } + + std::vector encoded; + ASSERT_TRUE(encodeGainmapSideAlphaAvif(encoded)); + + HeifContextPtr context(heif_context_alloc(), heif_context_free); + ASSERT_NE(context, nullptr); + ASSERT_EQ(heif_context_read_from_memory_without_copy(context.get(), encoded.data(), + encoded.size(), nullptr) + .code, + heif_error_Ok); + + heif_image_handle* primary_raw = nullptr; + ASSERT_EQ(heif_context_get_primary_image_handle(context.get(), &primary_raw).code, + heif_error_Ok); + HeifHandlePtr primary(primary_raw, heif_image_handle_release); + ASSERT_NE(primary, nullptr); + + heif_image_handle* gainmap_raw = nullptr; + ASSERT_EQ(heif_image_handle_get_gain_map_image_handle(primary.get(), &gainmap_raw).code, + heif_error_Ok); + HeifHandlePtr gainmap(gainmap_raw, heif_image_handle_release); + ASSERT_NE(gainmap, nullptr); + + EXPECT_EQ(heif_image_handle_has_alpha_channel(primary.get()), 0); + EXPECT_EQ(heif_image_handle_has_alpha_channel(gainmap.get()), 1); + EXPECT_EQ(heif_image_handle_is_premultiplied_alpha(gainmap.get()), 0); + expectSupportedHeifGainmap(encoded.data(), encoded.size(), + heif_have_decoder_for_format(heif_compression_AV1)); +} + namespace { constexpr size_t kAlphaTestWidth = 64;