diff --git a/CMakeLists.txt b/CMakeLists.txt index 37c184fd..191300da 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -823,6 +823,7 @@ if(UHDR_BUILD_TESTS) target_include_directories(ultrahdr_unit_test PRIVATE ${PRIVATE_INCLUDE_DIR} ${GTEST_INCLUDE_DIRS} + "${THIRD_PARTY_DIR}/image_io/includes" ) if(UHDR_BUILD_FUZZERS) target_link_options(ultrahdr_unit_test PRIVATE -fsanitize=fuzzer-no-link) diff --git a/examples/ultrahdr_app.cpp b/examples/ultrahdr_app.cpp index f047c2f1..ee442d75 100644 --- a/examples/ultrahdr_app.cpp +++ b/examples/ultrahdr_app.cpp @@ -258,8 +258,9 @@ class UltraHdrAppInput { public: UltraHdrAppInput(const char* hdrIntentRawFile, const char* sdrIntentRawFile, const char* sdrIntentCompressedFile, const char* gainmapCompressedFile, - const char* gainmapMetadataCfgFile, const char* exifFile, const char* outputFile, - int width, int height, uhdr_img_fmt_t hdrCf = UHDR_IMG_FMT_32bppRGBA1010102, + const char* gainmapMetadataCfgFile, const char* exifFile, const char* xmpFile, + const char* outputFile, int width, int height, + uhdr_img_fmt_t hdrCf = UHDR_IMG_FMT_32bppRGBA1010102, uhdr_img_fmt_t sdrCf = UHDR_IMG_FMT_32bppRGBA8888, uhdr_color_gamut_t hdrCg = UHDR_CG_DISPLAY_P3, uhdr_color_gamut_t sdrCg = UHDR_CG_BT_709, @@ -277,6 +278,7 @@ class UltraHdrAppInput { mGainMapCompressedFile(gainmapCompressedFile), mGainMapMetadataCfgFile(gainmapMetadataCfgFile), mExifFile(exifFile), + mXmpFile(xmpFile), mUhdrFile(nullptr), mOutputFile(outputFile), mWidth(width), @@ -312,6 +314,7 @@ class UltraHdrAppInput { mGainMapCompressedFile(nullptr), mGainMapMetadataCfgFile(gainmapMetadataCfgFile), mExifFile(nullptr), + mXmpFile(nullptr), mUhdrFile(uhdrFile), mOutputFile(outputFile), mWidth(0), @@ -370,6 +373,7 @@ class UltraHdrAppInput { } } if (mExifBlock.data) free(mExifBlock.data); + if (mXmpBlock.data) free(mXmpBlock.data); if (mUhdrImage.data) free(mUhdrImage.data); } @@ -385,6 +389,7 @@ class UltraHdrAppInput { bool fillGainMapCompressedImageHandle(); bool fillGainMapMetadataDescriptor(); bool fillExifMemoryBlock(); + bool fillXmpMemoryBlock(); void writeGainMapMetadataToFile(uhdr_gainmap_metadata_t* metadata, std::ostream& file); bool convertRgba8888ToYUV444Image(); bool convertRgba1010102ToYUV444Image(); @@ -401,6 +406,7 @@ class UltraHdrAppInput { const char* mGainMapCompressedFile; const char* mGainMapMetadataCfgFile; const char* mExifFile; + const char* mXmpFile; const char* mUhdrFile; const char* mOutputFile; const int mWidth; @@ -435,6 +441,7 @@ class UltraHdrAppInput { uhdr_compressed_image_t mGainMapCompressedImage{}; uhdr_gainmap_metadata mGainMapMetadata{}; uhdr_mem_block_t mExifBlock{}; + uhdr_mem_block_t mXmpBlock{}; uhdr_compressed_image_t mUhdrImage{}; uhdr_raw_image_t mDecodedUhdrRgbImage{}; uhdr_raw_image_t mDecodedUhdrYuv444Image{}; @@ -614,6 +621,19 @@ bool UltraHdrAppInput::fillExifMemoryBlock() { return false; } +bool UltraHdrAppInput::fillXmpMemoryBlock() { + std::ifstream ifd(mXmpFile, std::ios::binary | std::ios::ate); + if (ifd.good()) { + auto size = ifd.tellg(); + mXmpBlock.data = nullptr; + mXmpBlock.data_sz = size; + mXmpBlock.capacity = size; + ifd.close(); + return loadFile(mXmpFile, mXmpBlock.data, size); + } + return false; +} + void UltraHdrAppInput::writeGainMapMetadataToFile(uhdr_gainmap_metadata_t* metadata, std::ostream& file) { bool allChannelsIdentical = metadata->max_content_boost[0] == metadata->max_content_boost[1] && @@ -725,6 +745,12 @@ bool UltraHdrAppInput::encode() { return false; } } + if (mXmpFile != nullptr) { + if (!fillXmpMemoryBlock()) { + std::cerr << " failed to load file " << mXmpFile << std::endl; + return false; + } + } #define RET_IF_ERR(x) \ { \ @@ -766,6 +792,9 @@ bool UltraHdrAppInput::encode() { if (mExifFile != nullptr) { RET_IF_ERR(uhdr_enc_set_exif_data(handle, &mExifBlock)) } + if (mXmpFile != nullptr) { + RET_IF_ERR(uhdr_enc_set_xmp_data(handle, &mXmpBlock)) + } if (mOutputFile != nullptr) { std::string outStr(mOutputFile); @@ -856,6 +885,11 @@ bool UltraHdrAppInput::decode() { if (mProbe) { std::cout << "Ultra HDR Image: Yes \nGainMap Metadata: " << std::endl; writeGainMapMetadataToFile(metadata, std::cout); + uhdr_mem_block_t* xmp = uhdr_dec_get_xmp(handle); + if (xmp != nullptr && xmp->data != nullptr && xmp->data_sz > 0) { + std::cout << "XMP Metadata (" << xmp->data_sz << " bytes): \n" + << std::string(static_cast(xmp->data), xmp->data_sz) << std::endl; + } uhdr_release_decoder(handle); return true; } @@ -1501,6 +1535,7 @@ static void usage(const char* name) { " For PQ content, this defaults to 10000 nits. \n" " any real number in range [203, 10000]. \n"); fprintf(stderr, " -x binary input resource containing exif data to insert, optional. \n"); + fprintf(stderr, " -X resource containing xmp data to insert, optional. \n"); fprintf(stderr, "\n## decoder options : \n"); fprintf(stderr, " -j ultra hdr compressed input resource, required. \n"); fprintf(stderr, @@ -1594,10 +1629,11 @@ static void usage(const char* name) { } int main(int argc, char* argv[]) { - char opt_string[] = "p:y:i:g:f:w:h:C:c:t:q:o:O:m:j:e:a:b:z:R:s:M:Q:G:x:u:D:k:K:L:P"; + char opt_string[] = "p:y:i:g:f:w:h:C:c:t:q:o:O:m:j:e:a:b:z:R:s:M:Q:G:x:X:u:D:k:K:L:P"; char *hdr_intent_raw_file = nullptr, *sdr_intent_raw_file = nullptr, *uhdr_file = nullptr, *sdr_intent_compressed_file = nullptr, *gainmap_compressed_file = nullptr, - *gainmap_metadata_cfg_file = nullptr, *output_file = nullptr, *exif_file = nullptr; + *gainmap_metadata_cfg_file = nullptr, *output_file = nullptr, *exif_file = nullptr, + *xmp_file = nullptr; int width = 0, height = 0; uhdr_color_gamut_t hdr_cg = UHDR_CG_DISPLAY_P3; uhdr_color_gamut_t sdr_cg = UHDR_CG_BT_709; @@ -1702,6 +1738,9 @@ int main(int argc, char* argv[]) { case 'x': exif_file = optarg_s; break; + case 'X': + xmp_file = optarg_s; + break; case 'u': enable_gles = atoi(optarg_s) == 1 ? true : false; break; @@ -1744,7 +1783,7 @@ int main(int argc, char* argv[]) { } UltraHdrAppInput appInput( hdr_intent_raw_file, sdr_intent_raw_file, sdr_intent_compressed_file, - gainmap_compressed_file, gainmap_metadata_cfg_file, exif_file, + gainmap_compressed_file, gainmap_metadata_cfg_file, exif_file, xmp_file, output_file ? output_file : "out.jpeg", width, height, hdr_cf, sdr_cf, hdr_cg, sdr_cg, hdr_tf, quality, out_tf, out_cf, use_full_range_color_hdr, gainmap_scale_factor, gainmap_compression_quality, use_multi_channel_gainmap, gamma, enable_gles, enc_preset, diff --git a/fuzzer/ultrahdr_dec_fuzzer.cpp b/fuzzer/ultrahdr_dec_fuzzer.cpp index e1008b47..45ef120c 100644 --- a/fuzzer/ultrahdr_dec_fuzzer.cpp +++ b/fuzzer/ultrahdr_dec_fuzzer.cpp @@ -69,6 +69,14 @@ void UltraHdrDecFuzzer::process() { (void)is_uhdr_image(buffer.data(), buffer.size()); + uhdr_mem_block_t stripped_size{nullptr, 0, 0}; + if (uhdr_strip_gain_map(&jpegImgR, &stripped_size).error_code == UHDR_CODEC_OK && + stripped_size.data_sz > 0 && stripped_size.data_sz <= buffer.size() + 65536) { + std::vector stripped_buf(stripped_size.data_sz); + uhdr_mem_block_t stripped_out{stripped_buf.data(), 0, stripped_buf.size()}; + (void)uhdr_strip_gain_map(&jpegImgR, &stripped_out); + } + uhdr_codec_private_t* dec_handle = uhdr_create_decoder(); if (dec_handle) { ON_ERR(uhdr_dec_set_image(dec_handle, &jpegImgR)) @@ -106,6 +114,7 @@ void UltraHdrDecFuzzer::process() { uhdr_dec_get_exif(dec_handle); uhdr_dec_get_icc(dec_handle); + uhdr_dec_get_xmp(dec_handle); uhdr_dec_get_base_image(dec_handle); uhdr_dec_get_gainmap_image(dec_handle); uhdr_dec_get_gainmap_metadata(dec_handle); diff --git a/fuzzer/ultrahdr_enc_fuzzer.cpp b/fuzzer/ultrahdr_enc_fuzzer.cpp index 92794682..71e3576f 100644 --- a/fuzzer/ultrahdr_enc_fuzzer.cpp +++ b/fuzzer/ultrahdr_enc_fuzzer.cpp @@ -427,6 +427,13 @@ void UltraHdrEncFuzzer::process() { if (status.error_code == UHDR_CODEC_OK) { auto output = uhdr_get_encoded_stream(enc_handle); if (output != nullptr) { + uhdr_mem_block_t stripped_size{nullptr, 0, 0}; + if (uhdr_strip_gain_map(output, &stripped_size).error_code == UHDR_CODEC_OK && + stripped_size.data_sz > 0) { + std::vector stripped_buf(stripped_size.data_sz); + uhdr_mem_block_t stripped_out{stripped_buf.data(), 0, stripped_buf.size()}; + ON_ERR(uhdr_strip_gain_map(output, &stripped_out)) + } uhdr_codec_private_t* dec_handle = uhdr_create_decoder(); if (dec_handle) { ON_ERR(uhdr_dec_set_image(dec_handle, output)) @@ -438,6 +445,7 @@ void UltraHdrEncFuzzer::process() { else ON_ERR(uhdr_dec_set_out_img_format(dec_handle, UHDR_IMG_FMT_32bppRGBA1010102)) ON_ERR(uhdr_decode(dec_handle)) + uhdr_dec_get_xmp(dec_handle); uhdr_release_decoder(dec_handle); } } diff --git a/lib/include/ultrahdr/jpegr.h b/lib/include/ultrahdr/jpegr.h index abf32933..d3771b17 100644 --- a/lib/include/ultrahdr/jpegr.h +++ b/lib/include/ultrahdr/jpegr.h @@ -22,6 +22,17 @@ namespace ultrahdr { +// Maximum length of a JPEG marker segment payload (including the 2-byte length field). +constexpr size_t kJpegSegmentMaxLength = 0xffff; +// Maximum byte footprint for a JPEG application segment including the 2-byte marker code. +constexpr size_t kJpegAppSegmentTotalMaxBytes = kJpegSegmentMaxLength + 2; + +// Adobe XMP namespaces and segment length limits +constexpr size_t kMaxStandardXmpPayload = kJpegSegmentMaxLength - 2 - 29; // 65504 bytes +// Extended XMP segment header: 2 (length) + 35 (signature) + 32 (GUID) + 4 (totalLength) + 4 (offset) = 77 bytes +constexpr size_t kExtendedXmpHeaderSize = 77; +constexpr size_t kExtendedXmpMaxChunkSize = kJpegSegmentMaxLength - kExtendedXmpHeaderSize; // 65458 bytes + /* * Holds information of jpeg image */ @@ -78,7 +89,8 @@ class JpegR : public UltraHdr { * \return uhdr_error_info_t #UHDR_CODEC_OK if operation succeeds, uhdr_codec_err_t otherwise. */ uhdr_error_info_t encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_compressed_image_t* dest, - int quality, uhdr_mem_block_t* exif); + int quality, uhdr_mem_block_t* exif, + uhdr_mem_block_t* xmp = nullptr); /*!\brief Encode API-1. * @@ -95,11 +107,14 @@ class JpegR : public UltraHdr { * \param[in] quality quality factor for sdr intent jpeg compression * \param[in] exif optional exif metadata that needs to be inserted in * compressed output + * \param[in] xmp optional xmp metadata that needs to be inserted in + * compressed output * * \return uhdr_error_info_t #UHDR_CODEC_OK if operation succeeds, uhdr_codec_err_t otherwise. */ uhdr_error_info_t encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_raw_image_t* sdr_intent, - uhdr_compressed_image_t* dest, int quality, uhdr_mem_block_t* exif); + uhdr_compressed_image_t* dest, int quality, uhdr_mem_block_t* exif, + uhdr_mem_block_t* xmp = nullptr); /*!\brief Encode API-2. * @@ -117,12 +132,15 @@ class JpegR : public UltraHdr { * \param[in] sdr_intent_compressed sdr intent compressed input image descriptor * \param[in, out] dest output image descriptor to store compressed ultrahdr * image + * \param[in] xmp optional xmp metadata that needs to be inserted in + * compressed output * * \return uhdr_error_info_t #UHDR_CODEC_OK if operation succeeds, uhdr_codec_err_t otherwise. */ uhdr_error_info_t encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_raw_image_t* sdr_intent, uhdr_compressed_image_t* sdr_intent_compressed, - uhdr_compressed_image_t* dest); + uhdr_compressed_image_t* dest, + uhdr_mem_block_t* xmp = nullptr); /*!\brief Encode API-3. * @@ -138,12 +156,15 @@ class JpegR : public UltraHdr { * \param[in] sdr_intent_compressed sdr intent compressed input image descriptor * \param[in, out] dest output image descriptor to store compressed ultrahdr * image + * \param[in] xmp optional xmp metadata that needs to be inserted in + * compressed output * * \return uhdr_error_info_t #UHDR_CODEC_OK if operation succeeds, uhdr_codec_err_t otherwise. */ uhdr_error_info_t encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_compressed_image_t* sdr_intent_compressed, - uhdr_compressed_image_t* dest); + uhdr_compressed_image_t* dest, + uhdr_mem_block_t* xmp = nullptr); /*!\brief Encode API-4. * @@ -157,13 +178,16 @@ class JpegR : public UltraHdr { * \param[in] metadata gainmap metadata descriptor * \param[in, out] dest output image descriptor to store compressed ultrahdr * image + * \param[in] xmp optional xmp metadata that needs to be inserted in + * compressed output * * \return uhdr_error_info_t #UHDR_CODEC_OK if operation succeeds, uhdr_codec_err_t otherwise. */ uhdr_error_info_t encodeJPEGR(uhdr_compressed_image_t* base_img_compressed, uhdr_compressed_image_t* gainmap_img_compressed, uhdr_gainmap_metadata_ext_t* metadata, - uhdr_compressed_image_t* dest); + uhdr_compressed_image_t* dest, + uhdr_mem_block_t* xmp = nullptr); /*!\brief Decode API. * @@ -275,6 +299,16 @@ class JpegR : public UltraHdr { */ status_t getJPEGRInfo(jr_compressed_ptr jpegr_image_ptr, jr_info_ptr jpegr_image_info_ptr); + /*!\brief Losslessly strip gain map bitstream and gain map metadata from a JPEG/R stream. + * + * \param[in] in_stream input compressed JPEG/R stream + * \param[in, out] out_stream output memory block receiving the stripped SDR JPEG + * \return uhdr_error_info_t #UHDR_CODEC_OK if operation succeeds, #UHDR_CODEC_INVALID_PARAM + * otherwise. + */ + uhdr_error_info_t stripGainMap(uhdr_compressed_image_t* in_stream, + uhdr_mem_block_t* out_stream); + private: /*!\brief compress gainmap image * @@ -333,14 +367,18 @@ class JpegR : public UltraHdr { * \param[in] metadata gainmap metadata descriptor * \param[in, out] dest output image descriptor to store compressed ultrahdr * image + * \param[in] pXmp optional primary-image XMP packet to preserve and + * merge when XMP metadata is enabled * * \return uhdr_error_info_t #UHDR_CODEC_OK if operation succeeds, uhdr_codec_err_t otherwise. + * An unsupported or unmergeable XMP packet returns #UHDR_CODEC_INVALID_PARAM in XMP mode. */ uhdr_error_info_t appendGainMap(uhdr_compressed_image_t* sdr_intent_compressed, uhdr_compressed_image_t* gainmap_compressed, uhdr_mem_block_t* pExif, void* pIcc, size_t icc_size, uhdr_gainmap_metadata_ext_t* metadata, - uhdr_compressed_image_t* dest); + uhdr_compressed_image_t* dest, + uhdr_mem_block_t* pXmp = nullptr); /* * This method will check the validity of the input arguments. diff --git a/lib/include/ultrahdr/jpegrutils.h b/lib/include/ultrahdr/jpegrutils.h index dad76240..4fc66799 100644 --- a/lib/include/ultrahdr/jpegrutils.h +++ b/lib/include/ultrahdr/jpegrutils.h @@ -86,6 +86,16 @@ uhdr_error_info_t Write(uhdr_compressed_image_t* destination, const void* source uhdr_error_info_t getMetadataFromXMP(uint8_t* xmp_data, size_t xmp_size, uint8_t* exif_data, int exif_size, uhdr_gainmap_metadata_ext_t* metadata); +/* + * Computes 128-bit MD5 digest formatted as a 32-character uppercase hexadecimal GUID. + */ +std::string computeMd5Guid(const uint8_t* data, size_t len); + +/* + * Generates a minimal standard XMP packet referencing Extended XMP via xmpNote:HasExtendedXMP. + */ +std::string generateStandardXmpWithExtendedGuid(const std::string& guid); + /* * This method generates XMP metadata for the primary image. * @@ -124,10 +134,26 @@ uhdr_error_info_t getMetadataFromXMP(uint8_t* xmp_data, size_t xmp_size, uint8_t * * * @param secondary_image_length length of secondary image - * @return XMP metadata in type of string + * @param user_xmp optional XMP packet from the primary image. Its unrelated XML is preserved while + * the encoder-owned primary-image gain-map description is replaced. + * @return XMP metadata in type of string. An empty string means that a supplied packet could not be + * safely merged. */ std::string generateXmpForPrimaryImage(size_t secondary_image_length, - uhdr_gainmap_metadata_ext_t& metadata); + uhdr_gainmap_metadata_ext_t& metadata, + uhdr_mem_block_t* user_xmp = nullptr, + const std::string& extended_xmp_guid = ""); + +/* + * Strips Ultra HDR gain-map and GContainer directory properties from a primary-image XMP packet + * while preserving all unrelated XMP metadata and packet wrappers. + * + * @param xmp_data Input XMP packet XML string (without the JPEG APP1 namespace header). + * @param stripped_xmp Output string receiving the sanitized XMP packet, or empty string if the + * packet contained only gain-map metadata and no remaining descriptions. + * @return true if the XMP packet was valid and processed, false if the XML could not be parsed. + */ +bool stripGainMapFromXmp(const std::string& xmp_data, std::string* stripped_xmp); /* * This method generates XMP metadata for the recovery map image. diff --git a/lib/include/ultrahdr/ultrahdrcommon.h b/lib/include/ultrahdr/ultrahdrcommon.h index 6063781f..16ab8c0a 100644 --- a/lib/include/ultrahdr/ultrahdrcommon.h +++ b/lib/include/ultrahdr/ultrahdrcommon.h @@ -375,6 +375,7 @@ struct uhdr_encoder_private : uhdr_codec_private { m_compressed_images; std::map m_quality; std::vector m_exif; + std::vector m_xmp; uhdr_gainmap_metadata_t m_metadata; uhdr_codec_t m_output_format; int m_gainmap_scale_factor; @@ -408,6 +409,8 @@ struct uhdr_decoder_private : uhdr_codec_private { uhdr_mem_block_t m_exif_block; std::vector m_icc; uhdr_mem_block_t m_icc_block; + std::vector m_xmp; + uhdr_mem_block_t m_xmp_block; std::vector m_base_img; uhdr_mem_block_t m_base_img_block; std::vector m_gainmap_img; diff --git a/lib/src/jpegdecoderhelper.cpp b/lib/src/jpegdecoderhelper.cpp index c23b88e2..2e306f40 100644 --- a/lib/src/jpegdecoderhelper.cpp +++ b/lib/src/jpegdecoderhelper.cpp @@ -15,6 +15,7 @@ #include #include "ultrahdr/ultrahdrcommon.h" +#include "ultrahdr/jpegrutils.h" #include "ultrahdr/jpegdecoderhelper.h" using namespace std; @@ -139,6 +140,75 @@ static void jpeg_extract_marker_payload(const j_decompress_ptr cinfo, const uint } } +static void jpeg_extract_extended_xmp(const j_decompress_ptr cinfo, + const std::string& expected_guid, + std::vector& destination) { + const uint8_t kExtNamespace[] = "http://ns.adobe.com/xmp/extension/\0"; + const size_t kExtNamespaceLen = 35; + const size_t kHeaderLen = kExtNamespaceLen + 32 + 4 + 4; // 75 bytes + + std::string active_guid = expected_guid; + uint32_t total_length = 0; + std::vector reassembled; + std::vector received_bytes; + + for (jpeg_marker_struct* marker = cinfo->marker_list; marker; marker = marker->next) { + if (marker->marker == kAPP1Marker && marker->data_length > kHeaderLen && + !memcmp(marker->data, kExtNamespace, kExtNamespaceLen)) { + const char* guid_ptr = reinterpret_cast(marker->data + kExtNamespaceLen); + std::string segment_guid(guid_ptr, 32); + + if (!active_guid.empty() && segment_guid != active_guid) { + continue; + } + + uint32_t total_len_be = 0; + memcpy(&total_len_be, marker->data + kExtNamespaceLen + 32, 4); + uint32_t seg_total_len = EndianSwap32(total_len_be); + + uint32_t offset_be = 0; + memcpy(&offset_be, marker->data + kExtNamespaceLen + 36, 4); + uint32_t seg_offset = EndianSwap32(offset_be); + + const size_t chunk_data_len = marker->data_length - kHeaderLen; + if (seg_offset + chunk_data_len > seg_total_len) { + continue; + } + + if (active_guid.empty()) { + active_guid = segment_guid; + } + + if (total_length == 0) { + total_length = seg_total_len; + reassembled.resize(total_length, 0); + received_bytes.resize(total_length, false); + } else if (seg_total_len != total_length) { + continue; + } + + memcpy(reassembled.data() + seg_offset, marker->data + kHeaderLen, chunk_data_len); + for (size_t i = 0; i < chunk_data_len; ++i) { + received_bytes[seg_offset + i] = true; + } + } + } + + if (total_length > 0) { + bool complete = true; + for (bool b : received_bytes) { + if (!b) { + complete = false; + break; + } + } + if (complete) { + destination = std::move(reassembled); + } + } +} + + static uhdr_img_fmt_t getOutputSamplingFormat(const j_decompress_ptr cinfo) { if (cinfo->num_components == 1) return UHDR_IMG_FMT_8bppYCbCr400; @@ -244,6 +314,24 @@ uhdr_error_info_t JpegDecoderHelper::decode(const void* image, size_t length, de sizeof kIsoMetadataNameSpace / sizeof kIsoMetadataNameSpace[0], mIsoMetadataBuffer, payloadOffset); + std::string expected_guid; + if (!mXMPBuffer.empty()) { + std::string std_xmp(reinterpret_cast(mXMPBuffer.data()), mXMPBuffer.size()); + const std::string kTag = "HasExtendedXMP="; + size_t pos = std_xmp.find(kTag); + if (pos != std::string::npos && pos + kTag.size() + 33 <= std_xmp.size()) { + char quote = std_xmp[pos + kTag.size()]; + if (quote == '"' || quote == '\'') { + expected_guid = std_xmp.substr(pos + kTag.size() + 1, 32); + } + } + } + std::vector extXmpBuffer; + jpeg_extract_extended_xmp(&cinfo, expected_guid, extXmpBuffer); + if (!extXmpBuffer.empty()) { + mXMPBuffer = std::move(extXmpBuffer); + } + if (cinfo.image_width < 1 || cinfo.image_height < 1) { status.error_code = UHDR_CODEC_ERROR; status.has_detail = 1; diff --git a/lib/src/jpegr.cpp b/lib/src/jpegr.cpp index 4a5ba667..fe5efa3b 100644 --- a/lib/src/jpegr.cpp +++ b/lib/src/jpegr.cpp @@ -60,6 +60,7 @@ static const bool kWriteIso21496_1Metadata = false; #endif static const string kXmpNameSpace = "http://ns.adobe.com/xap/1.0/"; +static const string kExtendedXmpNameSpace = "http://ns.adobe.com/xmp/extension/"; static const string kIsoNameSpace = "urn:iso:std:iso:ts:21496:-1"; static_assert(kWriteXmpMetadata || kWriteIso21496_1Metadata, @@ -177,7 +178,8 @@ static void copyJpegWithoutExif(uhdr_compressed_image_t* pDest, uhdr_compressed_ /* Encode API-0 */ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_compressed_image_t* dest, - int quality, uhdr_mem_block_t* exif) { + int quality, uhdr_mem_block_t* exif, + uhdr_mem_block_t* xmp) { uhdr_img_fmt_t sdr_intent_fmt; if (hdr_intent->fmt == UHDR_IMG_FMT_24bppYCbCrP010) { sdr_intent_fmt = UHDR_IMG_FMT_12bppYCbCr420; @@ -239,14 +241,15 @@ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_compress // append gain map, no ICC since JPEG encode already did it UHDR_ERR_CHECK(appendGainMap(&sdr_intent_compressed, &gainmap_compressed, exif, /* icc */ nullptr, - /* icc size */ 0, &metadata, dest)); + /* icc size */ 0, &metadata, dest, xmp)); return g_no_error; } /* Encode API-1 */ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_raw_image_t* sdr_intent, uhdr_compressed_image_t* dest, int quality, - uhdr_mem_block_t* exif) { + uhdr_mem_block_t* exif, + uhdr_mem_block_t* xmp) { // generate gain map uhdr_gainmap_metadata_ext_t metadata(kJpegrVersion); std::unique_ptr gainmap; @@ -286,14 +289,15 @@ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_raw_imag // append gain map, no ICC since JPEG encode already did it UHDR_ERR_CHECK(appendGainMap(&sdr_intent_compressed, &gainmap_compressed, exif, /* icc */ nullptr, - /* icc size */ 0, &metadata, dest)); + /* icc size */ 0, &metadata, dest, xmp)); return g_no_error; } /* Encode API-2 */ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_raw_image_t* sdr_intent, uhdr_compressed_image_t* sdr_intent_compressed, - uhdr_compressed_image_t* dest) { + uhdr_compressed_image_t* dest, + uhdr_mem_block_t* xmp) { JpegDecoderHelper jpeg_dec_obj_sdr; UHDR_ERR_CHECK(jpeg_dec_obj_sdr.decompressImage(sdr_intent_compressed->data, sdr_intent_compressed->data_sz, PARSE_STREAM)); @@ -320,13 +324,14 @@ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_raw_imag UHDR_ERR_CHECK(compressGainMap(gainmap.get(), &jpeg_enc_obj_gm)); uhdr_compressed_image_t gainmap_compressed = jpeg_enc_obj_gm.getCompressedImage(); - return encodeJPEGR(sdr_intent_compressed, &gainmap_compressed, &metadata, dest); + return encodeJPEGR(sdr_intent_compressed, &gainmap_compressed, &metadata, dest, xmp); } /* Encode API-3 */ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_raw_image_t* hdr_intent, uhdr_compressed_image_t* sdr_intent_compressed, - uhdr_compressed_image_t* dest) { + uhdr_compressed_image_t* dest, + uhdr_mem_block_t* xmp) { // decode input jpeg, gamut is going to be bt601. JpegDecoderHelper jpeg_dec_obj_sdr; UHDR_ERR_CHECK(jpeg_dec_obj_sdr.decompressImage(sdr_intent_compressed->data, @@ -365,8 +370,8 @@ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_raw_image_t* hdr_intent, status.error_code = UHDR_CODEC_INVALID_PARAM; status.has_detail = 1; snprintf(status.detail, sizeof status.detail, - "sdr intent resolution %dx%d and hdr intent resolution %dx%d do not match", - sdr_intent.w, sdr_intent.h, hdr_intent->w, hdr_intent->h); + "sdr intent resolution %dx%d and hdr intent resolution %dx%d do not match", + sdr_intent.w, sdr_intent.h, hdr_intent->w, hdr_intent->h); return status; } @@ -381,14 +386,15 @@ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_raw_image_t* hdr_intent, UHDR_ERR_CHECK(compressGainMap(gainmap.get(), &jpeg_enc_obj_gm)); uhdr_compressed_image_t gainmap_compressed = jpeg_enc_obj_gm.getCompressedImage(); - return encodeJPEGR(sdr_intent_compressed, &gainmap_compressed, &metadata, dest); + return encodeJPEGR(sdr_intent_compressed, &gainmap_compressed, &metadata, dest, xmp); } /* Encode API-4 */ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_compressed_image_t* base_img_compressed, uhdr_compressed_image_t* gainmap_img_compressed, uhdr_gainmap_metadata_ext_t* metadata, - uhdr_compressed_image_t* dest) { + uhdr_compressed_image_t* dest, + uhdr_mem_block_t* xmp) { // We just want to check if ICC is present, so don't do a full decode. Note, // this doesn't verify that the ICC is valid. JpegDecoderHelper decoder; @@ -413,7 +419,7 @@ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_compressed_image_t* base_img_compresse // Add ICC if not already present. if (decoder.getICCSize() > 0) { UHDR_ERR_CHECK(appendGainMap(base_img_compressed, gainmap_img_compressed, /* exif */ nullptr, - /* icc */ nullptr, /* icc size */ 0, metadata, dest)); + /* icc */ nullptr, /* icc size */ 0, metadata, dest, xmp)); } else { if (base_img_compressed->cg <= UHDR_CG_UNSPECIFIED || base_img_compressed->cg > UHDR_CG_BT_2100) { @@ -427,7 +433,7 @@ uhdr_error_info_t JpegR::encodeJPEGR(uhdr_compressed_image_t* base_img_compresse std::shared_ptr newIcc = IccHelper::writeIccProfile(UHDR_CT_SRGB, base_img_compressed->cg); UHDR_ERR_CHECK(appendGainMap(base_img_compressed, gainmap_img_compressed, /* exif */ nullptr, - newIcc->getData(), newIcc->getLength(), metadata, dest)); + newIcc->getData(), newIcc->getLength(), metadata, dest, xmp)); } return g_no_error; @@ -1106,7 +1112,8 @@ uhdr_error_info_t JpegR::appendGainMap(uhdr_compressed_image_t* sdr_intent_compr uhdr_compressed_image_t* gainmap_compressed, uhdr_mem_block_t* pExif, void* pIcc, size_t icc_size, uhdr_gainmap_metadata_ext_t* metadata, - uhdr_compressed_image_t* dest) { + uhdr_compressed_image_t* dest, + uhdr_mem_block_t* pXmp) { if (kWriteXmpMetadata && !metadata->use_base_cg) { uhdr_error_info_t status; status.error_code = UHDR_CODEC_UNSUPPORTED_FEATURE; @@ -1216,6 +1223,18 @@ uhdr_error_info_t JpegR::appendGainMap(uhdr_compressed_image_t* sdr_intent_compr icc_size = decoder.getICCSize(); } + // If the primary JPEG already carries XMP and none was passed in, reuse it so the + // "Write XMP" block below emits/merges it; the APP marker skip in the reorder loop would otherwise + // silently drop it from the output. + uhdr_mem_block_t xmp_from_jpg; + xmp_from_jpg.data = nullptr; + xmp_from_jpg.data_sz = 0; + if (pXmp == nullptr && decoder.getXMPSize() > 0) { + xmp_from_jpg.data = decoder.getXMPPtr(); + xmp_from_jpg.data_sz = decoder.getXMPSize(); + pXmp = &xmp_from_jpg; + } + size_t pos = 0; // Begin primary image // Write SOI @@ -1249,17 +1268,116 @@ uhdr_error_info_t JpegR::appendGainMap(uhdr_compressed_image_t* sdr_intent_compr } // Prepare and write XMP + std::string xmp_primary_str; + std::string user_xmp_str; + if (pXmp != nullptr && pXmp->data != nullptr && pXmp->data_sz > 0) { + const std::string kXmpHeader = "http://ns.adobe.com/xap/1.0/"; + if (pXmp->data_sz > kXmpHeader.size() && + memcmp(pXmp->data, kXmpHeader.c_str(), kXmpHeader.size()) == 0) { + size_t offset = kXmpHeader.size(); + if (static_cast(pXmp->data)[offset] == 0) { + offset++; + } + user_xmp_str = std::string(static_cast(pXmp->data) + offset, + pXmp->data_sz - offset); + } else { + user_xmp_str = std::string(static_cast(pXmp->data), pXmp->data_sz); + } + } + + bool spill_user_xmp_to_extended = false; if (kWriteXmpMetadata) { - const string xmp_primary = generateXmpForPrimaryImage(secondary_image_size, *metadata); - const size_t length = 2 + xmpNameSpaceLength + xmp_primary.size(); - const uint8_t lengthH = ((length >> 8) & 0xff); - const uint8_t lengthL = (length & 0xff); - UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kStart, 1, pos)); - UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kAPP1, 1, pos)); - UHDR_ERR_CHECK(Write(dest, &lengthH, 1, pos)); - UHDR_ERR_CHECK(Write(dest, &lengthL, 1, pos)); - UHDR_ERR_CHECK(Write(dest, (void*)kXmpNameSpace.c_str(), xmpNameSpaceLength, pos)); - UHDR_ERR_CHECK(Write(dest, (void*)xmp_primary.c_str(), xmp_primary.size(), pos)); + if (user_xmp_str.empty()) { + xmp_primary_str = generateXmpForPrimaryImage(secondary_image_size, *metadata, nullptr); + } else if (user_xmp_str.size() <= kMaxStandardXmpPayload) { + uhdr_mem_block_t user_block{const_cast(user_xmp_str.data()), user_xmp_str.size(), + user_xmp_str.size()}; + xmp_primary_str = generateXmpForPrimaryImage(secondary_image_size, *metadata, &user_block); + if (xmp_primary_str.empty()) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "unable to safely merge supplied XMP metadata into the primary image"); + return status; + } + if (xmp_primary_str.size() > kMaxStandardXmpPayload) { + xmp_primary_str = user_xmp_str; + spill_user_xmp_to_extended = true; + } + } else { + xmp_primary_str = user_xmp_str; + spill_user_xmp_to_extended = true; + } + } else if (!user_xmp_str.empty()) { + xmp_primary_str = user_xmp_str; + spill_user_xmp_to_extended = xmp_primary_str.size() > kMaxStandardXmpPayload; + } + + if (!xmp_primary_str.empty()) { + if (!spill_user_xmp_to_extended && xmp_primary_str.size() <= kMaxStandardXmpPayload) { + // Write single standard XMP segment + const size_t length = 2 + xmpNameSpaceLength + xmp_primary_str.size(); + const uint8_t lengthH = ((length >> 8) & 0xff); + const uint8_t lengthL = (length & 0xff); + UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kStart, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kAPP1, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &lengthH, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &lengthL, 1, pos)); + UHDR_ERR_CHECK(Write(dest, (void*)kXmpNameSpace.c_str(), xmpNameSpaceLength, pos)); + UHDR_ERR_CHECK(Write(dest, (void*)xmp_primary_str.c_str(), xmp_primary_str.size(), pos)); + } else { + // Extended XMP (Adobe XMP Specification Part 3) + const std::string guid = + computeMd5Guid(reinterpret_cast(xmp_primary_str.data()), + xmp_primary_str.size()); + + // 1. Write Standard XMP segment with xmpNote:HasExtendedXMP referencing the GUID + std::string standard_xmp; + if (kWriteXmpMetadata) { + standard_xmp = + generateXmpForPrimaryImage(secondary_image_size, *metadata, nullptr, guid); + } else { + standard_xmp = generateStandardXmpWithExtendedGuid(guid); + } + + const size_t std_length = 2 + xmpNameSpaceLength + standard_xmp.size(); + const uint8_t std_lengthH = ((std_length >> 8) & 0xff); + const uint8_t std_lengthL = (std_length & 0xff); + UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kStart, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kAPP1, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &std_lengthH, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &std_lengthL, 1, pos)); + UHDR_ERR_CHECK(Write(dest, (void*)kXmpNameSpace.c_str(), xmpNameSpaceLength, pos)); + UHDR_ERR_CHECK(Write(dest, (void*)standard_xmp.c_str(), standard_xmp.size(), pos)); + + // 2. Write Extended XMP segments + const size_t extNamespaceLength = kExtendedXmpNameSpace.size() + 1; // 35 bytes + const uint32_t totalLen = static_cast(xmp_primary_str.size()); + const uint32_t totalLenBE = EndianSwap32(totalLen); + size_t ext_offset = 0; + while (ext_offset < xmp_primary_str.size()) { + const size_t chunk_size = + (std::min)(xmp_primary_str.size() - ext_offset, kExtendedXmpMaxChunkSize); + const size_t seg_length = 2 + extNamespaceLength + 32 + 4 + 4 + chunk_size; + const uint8_t seg_lengthH = ((seg_length >> 8) & 0xff); + const uint8_t seg_lengthL = (seg_length & 0xff); + const uint32_t offsetBE = EndianSwap32(static_cast(ext_offset)); + + UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kStart, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &photos_editing_formats::image_io::JpegMarker::kAPP1, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &seg_lengthH, 1, pos)); + UHDR_ERR_CHECK(Write(dest, &seg_lengthL, 1, pos)); + UHDR_ERR_CHECK(Write(dest, (void*)kExtendedXmpNameSpace.c_str(), extNamespaceLength, pos)); + UHDR_ERR_CHECK(Write(dest, (void*)guid.c_str(), 32, pos)); + UHDR_ERR_CHECK(Write(dest, &totalLenBE, 4, pos)); + UHDR_ERR_CHECK(Write(dest, &offsetBE, 4, pos)); + UHDR_ERR_CHECK( + Write(dest, (void*)(xmp_primary_str.data() + ext_offset), chunk_size, pos)); + + ext_offset += chunk_size; + } + } } // Write ICC @@ -2900,4 +3018,260 @@ status_t JpegR::decodeJPEGR(jr_compressed_ptr jpegr_image_ptr, jr_uncompressed_p return result.error_code == UHDR_CODEC_OK ? JPEGR_NO_ERROR : JPEGR_UNKNOWN_ERROR; } +uhdr_error_info_t JpegR::stripGainMap(uhdr_compressed_image_t* in_stream, + uhdr_mem_block_t* out_stream) { + if (in_stream == nullptr) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, "received nullptr for input stream"); + return status; + } + if (in_stream->data == nullptr || in_stream->data_sz < 4) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "invalid input stream buffer (data=%p, data_sz=%zu)", + in_stream->data, in_stream->data_sz); + return status; + } + if (out_stream == nullptr) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, "received nullptr for output stream"); + return status; + } + if (out_stream->data == nullptr && out_stream->capacity != 0) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "received nullptr for output stream data buffer with non-zero capacity (%zu)", + out_stream->capacity); + return status; + } + + const uint8_t* data = static_cast(in_stream->data); + const size_t size = in_stream->data_sz; + if (data[0] != JpegMarker::kStart || data[1] != JpegMarker::kSOI) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "input stream does not start with JPEG SOI marker"); + return status; + } + + std::vector out_buf; + out_buf.reserve(size); + out_buf.push_back(JpegMarker::kStart); + out_buf.push_back(JpegMarker::kSOI); + + const size_t xmp_ns_len = kXmpNameSpace.size() + 1; + const size_t iso_ns_len = kIsoNameSpace.size() + 1; + const size_t mpf_sig_len = sizeof(kMpfSig); + + size_t pos = 2; + bool seen_sos = false; + bool seen_eoi = false; + + while (pos < size) { + const size_t marker_start = pos; + if (data[pos] != JpegMarker::kStart) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "expected JPEG marker prefix 0xFF at offset %zu", pos); + return status; + } + while (pos < size && data[pos] == JpegMarker::kStart) { + ++pos; + } + if (pos >= size) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, "truncated JPEG marker at end of stream"); + return status; + } + + const uint8_t marker = data[pos++]; + if (marker == 0x00 || marker == JpegMarker::kSOI) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "unexpected JPEG marker 0xFF%02X at offset %zu", marker, marker_start); + return status; + } + + if (marker == JpegMarker::kEOI) { + if (!seen_sos) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "encountered EOI before SOS in primary JPEG stream"); + return status; + } + out_buf.push_back(JpegMarker::kStart); + out_buf.push_back(JpegMarker::kEOI); + seen_eoi = true; + break; + } + + if (marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7)) { + out_buf.insert(out_buf.end(), data + marker_start, data + pos); + continue; + } + + if (pos + 2 > size) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "truncated segment length for marker 0xFF%02X at offset %zu", marker, marker_start); + return status; + } + const size_t seg_len = (static_cast(data[pos]) << 8) | data[pos + 1]; + if (seg_len < 2 || pos + seg_len > size) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "invalid segment length %zu for marker 0xFF%02X at offset %zu", + seg_len, marker, marker_start); + return status; + } + + const uint8_t* payload = data + pos + 2; + const size_t payload_len = seg_len - 2; + const size_t next_pos = pos + seg_len; + + if (marker == JpegMarker::kAPP2) { + const bool is_mpf = + payload_len >= mpf_sig_len && memcmp(payload, kMpfSig, mpf_sig_len) == 0; + const bool is_iso = + payload_len >= iso_ns_len && memcmp(payload, kIsoNameSpace.c_str(), iso_ns_len) == 0; + if (!is_mpf && !is_iso) { + out_buf.insert(out_buf.end(), data + marker_start, data + next_pos); + } + pos = next_pos; + continue; + } + + if (marker == JpegMarker::kAPP1) { + const bool is_standard_xmp = + payload_len >= xmp_ns_len && memcmp(payload, kXmpNameSpace.c_str(), xmp_ns_len) == 0; + if (is_standard_xmp) { + const std::string xmp_xml(reinterpret_cast(payload + xmp_ns_len), + payload_len - xmp_ns_len); + std::string stripped_xmp; + if (!stripGainMapFromXmp(xmp_xml, &stripped_xmp)) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "unable to parse or strip gain map metadata from primary XMP packet"); + return status; + } + if (!stripped_xmp.empty()) { + const size_t new_seg_len = 2 + xmp_ns_len + stripped_xmp.size(); + if (new_seg_len > 0xFFFF) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "stripped XMP segment exceeds maximum APP1 segment size"); + return status; + } + out_buf.push_back(JpegMarker::kStart); + out_buf.push_back(JpegMarker::kAPP1); + out_buf.push_back(static_cast((new_seg_len >> 8) & 0xFF)); + out_buf.push_back(static_cast(new_seg_len & 0xFF)); + out_buf.insert(out_buf.end(), + reinterpret_cast(kXmpNameSpace.c_str()), + reinterpret_cast(kXmpNameSpace.c_str()) + xmp_ns_len); + out_buf.insert(out_buf.end(), + reinterpret_cast(stripped_xmp.data()), + reinterpret_cast(stripped_xmp.data()) + + stripped_xmp.size()); + } + } else { + out_buf.insert(out_buf.end(), data + marker_start, data + next_pos); + } + pos = next_pos; + continue; + } + + out_buf.insert(out_buf.end(), data + marker_start, data + next_pos); + pos = next_pos; + + if (marker == JpegMarker::kSOS) { + seen_sos = true; + const size_t scan_data_start = pos; + bool found_post_scan_marker = false; + while (pos < size) { + if (data[pos] != JpegMarker::kStart) { + ++pos; + continue; + } + const size_t ff_start = pos; + while (pos < size && data[pos] == JpegMarker::kStart) { + ++pos; + } + if (pos >= size) { + break; + } + const uint8_t next_marker = data[pos]; + if (next_marker == 0x00 || (next_marker >= 0xD0 && next_marker <= 0xD7)) { + ++pos; + continue; + } + out_buf.insert(out_buf.end(), data + scan_data_start, data + ff_start); + pos = ff_start; + found_post_scan_marker = true; + break; + } + if (!found_post_scan_marker) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "truncated entropy-coded scan data before EOI in primary JPEG stream"); + return status; + } + } + } + + if (!seen_sos || !seen_eoi) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "incomplete primary JPEG stream (missing SOS or EOI marker)"); + return status; + } + + out_stream->data_sz = out_buf.size(); + if (out_stream->data == nullptr && out_stream->capacity == 0) { + return g_no_error; + } + if (out_stream->capacity < out_buf.size()) { + uhdr_error_info_t status; + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "output buffer capacity (%zu) is smaller than required size (%zu)", + out_stream->capacity, out_buf.size()); + return status; + } + + memcpy(out_stream->data, out_buf.data(), out_buf.size()); + return g_no_error; +} + } // namespace ultrahdr diff --git a/lib/src/jpegrutils.cpp b/lib/src/jpegrutils.cpp index 3f064ade..2115043c 100644 --- a/lib/src/jpegrutils.cpp +++ b/lib/src/jpegrutils.cpp @@ -10,6 +10,8 @@ #include #include +#include +#include #include "ultrahdr/ultrahdrcommon.h" #include "ultrahdr/jpegr.h" @@ -424,6 +426,8 @@ class XMPXmlHandler : public XmlHandler { }; // GContainer XMP constants - URI and namespace prefix +const string kRdfUri = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +const string kAppleGainMapUri = "http://ns.apple.com/HDRGainMap/1.0/"; const string kContainerUri = "http://ns.google.com/photos/1.0/container/"; const string kContainerPrefix = "Container"; @@ -873,11 +877,801 @@ uhdr_error_info_t getMetadataFromXMP(uint8_t* xmp_data, size_t xmp_size, uint8_t return uhdr_validate_gainmap_metadata_descriptor(metadata); } + +/* + * Computes 128-bit MD5 digest formatted as a 32-character uppercase hexadecimal GUID. + * + * Specification References: + * 1. Adobe XMP Specification Part 3 (Storage in Files - ExtendedXMP in JPEG): + * https://developer.adobe.com/xmp/docs/XMPSpecifications/ + * Mandates linking Extended XMP segments to Standard XMP using a 128-bit GUID computed + * as the MD5 message digest of the serialized Extended XMP payload. + * + * 2. IETF RFC 1321 (The MD5 Message-Digest Algorithm): + * https://www.ietf.org/rfc/rfc1321.txt + */ +std::string computeMd5Guid(const uint8_t* data, size_t len) { + // Initial 128-bit chaining state registers (RFC 1321, Section 3.3) + uint32_t h0 = 0x67452301; + uint32_t h1 = 0xEFCDAB89; + uint32_t h2 = 0x98BADCFE; + uint32_t h3 = 0x10325476; + + // Additive constant table T[1..64] computed as floor(2^32 * abs(sin(i + 1))) (RFC 1321, Section 3.4 / Appendix A.3) + static const uint32_t k[64] = { + // Round 1 + 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501, + 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821, + // Round 2 + 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8, + 0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, + // Round 3 + 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70, + 0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, + // Round 4 + 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, + 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391}; + + // Per-round bitwise left rotation shift amounts S[1..64] (RFC 1321, Section 3.4) + static const uint32_t r[64] = { + // Round 1 shifts: 7, 12, 17, 22 + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, + // Round 2 shifts: 5, 9, 14, 20 + 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, + // Round 3 shifts: 4, 11, 16, 23 + 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, + // Round 4 shifts: 6, 10, 15, 21 + 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21}; + + uint64_t bit_len = static_cast(len) * 8; + size_t padded_len = ((len + 8) / 64 + 1) * 64; + std::vector msg(padded_len, 0); + if (len > 0) { + memcpy(msg.data(), data, len); + } + msg[len] = 0x80; + for (int i = 0; i < 8; ++i) { + msg[padded_len - 8 + i] = static_cast((bit_len >> (i * 8)) & 0xFF); + } + + for (size_t chunk = 0; chunk < padded_len; chunk += 64) { + uint32_t w[16]; + for (int i = 0; i < 16; ++i) { + w[i] = static_cast(msg[chunk + i * 4]) | + (static_cast(msg[chunk + i * 4 + 1]) << 8) | + (static_cast(msg[chunk + i * 4 + 2]) << 16) | + (static_cast(msg[chunk + i * 4 + 3]) << 24); + } + + uint32_t a = h0; + uint32_t b = h1; + uint32_t c = h2; + uint32_t d = h3; + + for (int i = 0; i < 64; ++i) { + uint32_t f, g; + if (i < 16) { + f = (b & c) | ((~b) & d); + g = i; + } else if (i < 32) { + f = (d & b) | ((~d) & c); + g = (5 * i + 1) % 16; + } else if (i < 48) { + f = b ^ c ^ d; + g = (3 * i + 5) % 16; + } else { + f = c ^ (b | (~d)); + g = (7 * i) % 16; + } + uint32_t temp = d; + d = c; + c = b; + uint32_t sum = a + f + k[i] + w[g]; + uint32_t rot = (sum << r[i]) | (sum >> (32 - r[i])); + b = b + rot; + a = temp; + } + + h0 += a; + h1 += b; + h2 += c; + h3 += d; + } + + uint8_t digest[16]; + for (int i = 0; i < 4; ++i) { + digest[i] = (h0 >> (i * 8)) & 0xFF; + digest[4 + i] = (h1 >> (i * 8)) & 0xFF; + digest[8 + i] = (h2 >> (i * 8)) & 0xFF; + digest[12 + i] = (h3 >> (i * 8)) & 0xFF; + } + + char hex[33]; + for (int i = 0; i < 16; ++i) { + snprintf(hex + i * 2, 3, "%02X", digest[i]); + } + return std::string(hex, 32); +} + +std::string generateStandardXmpWithExtendedGuid(const std::string& guid) { + std::stringstream ss; + photos_editing_formats::image_io::XmlWriter writer(ss); + writer.StartWritingElement("x:xmpmeta"); + writer.WriteXmlns("x", "adobe:ns:meta/"); + writer.WriteAttributeNameAndValue("x:xmptk", "Adobe XMP Core 5.1.2"); + writer.StartWritingElement("rdf:RDF"); + writer.WriteXmlns("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"); + writer.StartWritingElement("rdf:Description"); + writer.WriteXmlns("xmpNote", "http://ns.adobe.com/xmp/note/"); + writer.WriteAttributeNameAndValue("xmpNote:HasExtendedXMP", guid); + writer.FinishWriting(); + return ss.str(); +} +namespace { + +constexpr size_t kNoXmpElement = (std::numeric_limits::max)(); + +bool IsXmlWhitespace(char c) { + return c == ' ' || c == '\t' || c == '\r' || c == '\n'; +} + +bool FindXmlMarkupEnd(const string& xml, size_t begin, size_t* end) { + char quote = 0; + for (size_t pos = begin; pos < xml.size(); ++pos) { + const char c = xml[pos]; + if (quote != 0) { + if (c == quote) quote = 0; + } else if (c == '\'' || c == '"') { + quote = c; + } else if (c == '>') { + *end = pos + 1; + return true; + } + } + return false; +} + +struct XmpAttributeSpan { + string qualified_name; + string value; + string uri; + string local_name; + size_t name_begin = 0; + size_t value_end = 0; + bool namespace_declaration = false; +}; + +struct XmpNamespaceDeclaration { + string prefix; + string uri; +}; + +struct XmpElementSpan { + string qualified_name; + string uri; + string local_name; + vector namespace_declarations; + vector attributes; + size_t parent = kNoXmpElement; + size_t start_begin = 0; + size_t end_begin = 0; + size_t end_end = 0; + bool self_closing = false; +}; + +class XmpMergeXmlHandler : public XmlHandler { + public: + explicit XmpMergeXmlHandler(const string& xml) : xml_(xml) {} + + DataMatchResult StartElement(const XmlTokenContext& context) override { + if (!FinalizePendingElement()) valid_ = false; + pending_name_.clear(); + pending_attributes_.clear(); + pending_attribute_index_ = kNoXmpElement; + pending_name_begin_ = context.GetTokenRange().GetBegin(); + if (!context.BuildTokenValue(&pending_name_)) valid_ = false; + pending_element_ = true; + return context.GetResult(); + } + + DataMatchResult AttributeName(const XmlTokenContext& context) override { + if (!pending_element_ || pending_attribute_index_ != kNoXmpElement) { + valid_ = false; + return context.GetResult(); + } + XmpAttributeSpan attribute; + attribute.name_begin = context.GetTokenRange().GetBegin(); + if (!context.BuildTokenValue(&attribute.qualified_name)) valid_ = false; + pending_attributes_.push_back(std::move(attribute)); + pending_attribute_index_ = pending_attributes_.size() - 1; + return context.GetResult(); + } + + DataMatchResult AttributeValue(const XmlTokenContext& context) override { + if (!pending_element_ || pending_attribute_index_ == kNoXmpElement) { + valid_ = false; + return context.GetResult(); + } + XmpAttributeSpan& attribute = pending_attributes_[pending_attribute_index_]; + if (!context.BuildTokenValue(&attribute.value, true)) valid_ = false; + attribute.value_end = context.GetTokenRange().GetEnd(); + pending_attribute_index_ = kNoXmpElement; + return context.GetResult(); + } + + DataMatchResult FinishElement(const XmlTokenContext& context) override { + const bool had_pending_element = pending_element_; + if (!FinalizePendingElement()) valid_ = false; + if (!context.GetTokenRange().IsValid()) { + if (!had_pending_element || !last_element_was_self_closing_) valid_ = false; + last_element_was_self_closing_ = false; + return context.GetResult(); + } + + string closing_name; + if (!context.BuildTokenValue(&closing_name)) valid_ = false; + if (stack_.empty()) { + valid_ = false; + return context.GetResult(); + } + XmpElementSpan& element = elements_[stack_.back()]; + const size_t close_name_begin = context.GetTokenRange().GetBegin(); + const size_t close_begin = xml_.rfind('<', close_name_begin); + size_t close_end = 0; + if (close_begin == string::npos || !FindXmlMarkupEnd(xml_, close_begin, &close_end) || + closing_name != element.qualified_name) { + valid_ = false; + } else { + element.end_begin = close_begin; + element.end_end = close_end; + stack_.pop_back(); + if (stack_.empty()) root_closed_ = true; + } + return context.GetResult(); + } + + DataMatchResult ElementContent(const XmlTokenContext& context) override { + if (!FinalizePendingElement()) valid_ = false; + if (!stack_.empty() && stack_.back() == root_index_) { + string value; + if (!context.BuildTokenValue(&value) || !IsOuterWhitespace(value)) valid_ = false; + } + return context.GetResult(); + } + + DataMatchResult Comment(const XmlTokenContext& context) override { + if (!FinalizePendingElement()) valid_ = false; + return context.GetResult(); + } + + DataMatchResult Cdata(const XmlTokenContext& context) override { + if (!FinalizePendingElement()) valid_ = false; + if (!stack_.empty() && stack_.back() == root_index_) valid_ = false; + return context.GetResult(); + } + + DataMatchResult Pi(const XmlTokenContext& context) override { + if (!FinalizePendingElement()) valid_ = false; + return context.GetResult(); + } + + bool IsComplete() const { + return valid_ && !pending_element_ && stack_.empty() && root_closed_ && + root_index_ != kNoXmpElement; + } + + const vector& elements() const { return elements_; } + size_t root_index() const { return root_index_; } + + private: + bool LookupNamespace(size_t parent, const vector& local, + const string& prefix, string* uri) const { + for (auto it = local.rbegin(); it != local.rend(); ++it) { + if (it->prefix == prefix) { + *uri = it->uri; + return true; + } + } + for (size_t index = parent; index != kNoXmpElement; index = elements_[index].parent) { + const vector& declarations = elements_[index].namespace_declarations; + for (auto it = declarations.rbegin(); it != declarations.rend(); ++it) { + if (it->prefix == prefix) { + *uri = it->uri; + return true; + } + } + } + if (prefix == "xml") { + *uri = "http://www.w3.org/XML/1998/namespace"; + return true; + } + if (prefix == "xmlns") { + *uri = "http://www.w3.org/2000/xmlns/"; + return true; + } + return false; + } + + bool IsOuterWhitespace(const string& value) const { + size_t begin = 0; + if (value.compare(0, 3, "\xef\xbb\xbf") == 0) begin = 3; + for (; begin < value.size(); ++begin) { + if (!IsXmlWhitespace(value[begin])) return false; + } + return true; + } + + bool ResolveName(const string& qualified_name, size_t parent, + const vector& local, bool attribute, + string* uri, string* local_name) const { + const size_t colon = qualified_name.find(':'); + if (colon == string::npos) { + *local_name = qualified_name; + if (attribute) { + uri->clear(); + return true; + } + if (!LookupNamespace(parent, local, "", uri)) uri->clear(); + return true; + } + if (colon == 0 || colon + 1 >= qualified_name.size() || + qualified_name.find(':', colon + 1) != string::npos) { + return false; + } + const string prefix = qualified_name.substr(0, colon); + if (!LookupNamespace(parent, local, prefix, uri)) return false; + *local_name = qualified_name.substr(colon + 1); + return true; + } + + bool FinalizePendingElement() { + if (!pending_element_) return true; + if (pending_name_.empty() || pending_attribute_index_ != kNoXmpElement) { + pending_element_ = false; + return false; + } + const size_t start_begin = xml_.rfind('<', pending_name_begin_); + size_t start_end = 0; + if (start_begin == string::npos || !FindXmlMarkupEnd(xml_, start_begin, &start_end)) { + pending_element_ = false; + return false; + } + const size_t parent = stack_.empty() ? kNoXmpElement : stack_.back(); + vector namespace_declarations; + for (auto& attribute : pending_attributes_) { + string prefix; + if (attribute.qualified_name == "xmlns") { + attribute.namespace_declaration = true; + } else if (attribute.qualified_name.compare(0, 6, "xmlns:") == 0) { + prefix = attribute.qualified_name.substr(6); + if (prefix.empty() || prefix.find(':') != string::npos) { + pending_element_ = false; + return false; + } + attribute.namespace_declaration = true; + } else { + continue; + } + if (attribute.value.find('&') != string::npos) { + pending_element_ = false; + return false; + } + for (const XmpNamespaceDeclaration& declaration : namespace_declarations) { + if (declaration.prefix == prefix) { + pending_element_ = false; + return false; + } + } + XmpNamespaceDeclaration declaration; + declaration.prefix = prefix; + declaration.uri = attribute.value; + namespace_declarations.push_back(std::move(declaration)); + } + + XmpElementSpan element; + element.qualified_name = pending_name_; + element.parent = parent; + element.start_begin = start_begin; + element.namespace_declarations = namespace_declarations; + if (!ResolveName(element.qualified_name, parent, namespace_declarations, false, &element.uri, + &element.local_name)) { + pending_element_ = false; + return false; + } + for (auto& attribute : pending_attributes_) { + if (!attribute.namespace_declaration && + !ResolveName(attribute.qualified_name, parent, namespace_declarations, true, + &attribute.uri, &attribute.local_name)) { + pending_element_ = false; + return false; + } + element.attributes.push_back(std::move(attribute)); + } + size_t last = start_end - 1; + while (last > start_begin && IsXmlWhitespace(xml_[last - 1])) --last; + element.self_closing = last > start_begin && xml_[last - 1] == '/'; + element.end_begin = element.self_closing ? start_begin : 0; + element.end_end = element.self_closing ? start_end : 0; + elements_.push_back(std::move(element)); + const size_t index = elements_.size() - 1; + if (parent == kNoXmpElement) { + if (root_index_ != kNoXmpElement) { + pending_element_ = false; + return false; + } + root_index_ = index; + } + last_element_was_self_closing_ = elements_[index].self_closing; + if (!elements_[index].self_closing) stack_.push_back(index); + else if (stack_.empty()) root_closed_ = true; + pending_element_ = false; + return true; + } + + const string& xml_; + vector elements_; + vector stack_; + size_t root_index_ = kNoXmpElement; + bool root_closed_ = false; + bool valid_ = true; + bool pending_element_ = false; + string pending_name_; + size_t pending_name_begin_ = 0; + vector pending_attributes_; + size_t pending_attribute_index_ = kNoXmpElement; + bool last_element_was_self_closing_ = false; +}; + +bool IsGainMapPropertyUri(const string& uri) { + return uri == kContainerUri || uri == kItemUri || uri == kGainMapUri || uri == kAppleGainMapUri; +} + +string GeneratePrimaryDescription(size_t secondary_image_length, + uhdr_gainmap_metadata_ext_t& metadata, + const string& extended_xmp_guid) { + const vector con_dir_seq({kConDirectory, string("rdf:Seq")}); + stringstream ss; + photos_editing_formats::image_io::XmlWriter writer(ss); + writer.StartWritingElement("rdf:Description"); + writer.WriteXmlns("rdf", kRdfUri); + writer.WriteXmlns(kContainerPrefix, kContainerUri); + writer.WriteXmlns(kItemPrefix, kItemUri); + writer.WriteXmlns(kGainMapPrefix, kGainMapUri); + if (!extended_xmp_guid.empty()) { + writer.WriteXmlns("xmpNote", "http://ns.adobe.com/xmp/note/"); + writer.WriteAttributeNameAndValue("xmpNote:HasExtendedXMP", extended_xmp_guid); + } + writer.WriteAttributeNameAndValue(kMapVersion, metadata.version); + writer.StartWritingElements(con_dir_seq); + + const size_t item_depth = writer.StartWritingElement("rdf:li"); + writer.WriteAttributeNameAndValue("rdf:parseType", "Resource"); + writer.StartWritingElement(kConItem); + writer.WriteAttributeNameAndValue(kItemSemantic, kSemanticPrimary); + writer.WriteAttributeNameAndValue(kItemMime, kMimeImageJpeg); + writer.FinishWritingElementsToDepth(item_depth); + + writer.StartWritingElement("rdf:li"); + writer.WriteAttributeNameAndValue("rdf:parseType", "Resource"); + writer.StartWritingElement(kConItem); + writer.WriteAttributeNameAndValue(kItemSemantic, kSemanticGainMap); + writer.WriteAttributeNameAndValue(kItemMime, kMimeImageJpeg); + writer.WriteAttributeNameAndValue(kItemLength, secondary_image_length); + writer.FinishWriting(); + return ss.str(); +} + +bool ParseRdfDescriptions(const string& parse_xml, XmpMergeXmlHandler* handler, size_t* rdf_root_out, + vector* all_descriptions_out, + vector* primary_descriptions_out) { + MessageHandler message_handler; + unique_ptr rule(new XmlElementRule); + XmlReader reader(handler, &message_handler); + if (!reader.StartParse(std::move(rule)) || !reader.Parse(parse_xml) || !reader.FinishParse() || + reader.HasErrors() || !handler->IsComplete()) { + return false; + } + + const vector& elements = handler->elements(); + const size_t synthetic_root = handler->root_index(); + size_t packet_root = kNoXmpElement; + for (size_t index = 0; index < elements.size(); ++index) { + if (elements[index].parent == synthetic_root) { + if (packet_root != kNoXmpElement) return false; + packet_root = index; + } + } + if (packet_root == kNoXmpElement) return false; + + size_t rdf_root = kNoXmpElement; + if (elements[packet_root].uri == kRdfUri && elements[packet_root].local_name == "RDF") { + rdf_root = packet_root; + } else if (elements[packet_root].local_name == "xmpmeta" && + elements[packet_root].uri == "adobe:ns:meta/") { + for (size_t index = 0; index < elements.size(); ++index) { + if (elements[index].parent == packet_root && elements[index].uri == kRdfUri && + elements[index].local_name == "RDF") { + if (rdf_root != kNoXmpElement) return false; + rdf_root = index; + } + } + } else { + return false; + } + if (rdf_root == kNoXmpElement) return false; + + all_descriptions_out->clear(); + primary_descriptions_out->clear(); + for (size_t index = 0; index < elements.size(); ++index) { + const XmpElementSpan& element = elements[index]; + if (element.parent != rdf_root || element.uri != kRdfUri || + element.local_name != "Description") { + continue; + } + all_descriptions_out->push_back(index); + bool has_about = false; + bool about_empty = true; + bool has_non_primary_id = false; + for (const XmpAttributeSpan& attribute : element.attributes) { + if (attribute.uri == kRdfUri && attribute.local_name == "about") { + has_about = true; + about_empty = attribute.value.empty(); + } else if (attribute.uri == kRdfUri && + (attribute.local_name == "ID" || attribute.local_name == "nodeID")) { + has_non_primary_id = true; + } + } + if (!has_non_primary_id && (!has_about || about_empty)) { + primary_descriptions_out->push_back(index); + } + } + *rdf_root_out = rdf_root; + return true; +} + +string MergePrimaryXmp(const string& existing_xmp, size_t secondary_image_length, + uhdr_gainmap_metadata_ext_t& metadata, + const string& extended_xmp_guid) { + const string synthetic_open = ""; + const string synthetic_close = ""; + const size_t offset = synthetic_open.size(); + const string parse_xml = synthetic_open + existing_xmp + synthetic_close; + + XmpMergeXmlHandler handler(parse_xml); + size_t rdf_root = kNoXmpElement; + vector all_descriptions; + vector primary_descriptions; + if (!ParseRdfDescriptions(parse_xml, &handler, &rdf_root, &all_descriptions, + &primary_descriptions)) { + return string(); + } + const vector& elements = handler.elements(); + if (elements[rdf_root].self_closing || + elements[rdf_root].end_begin <= elements[rdf_root].start_begin) { + return string(); + } + + vector> removals; + for (const size_t description : primary_descriptions) { + const XmpElementSpan& primary = elements[description]; + for (const XmpAttributeSpan& attribute : primary.attributes) { + if ((attribute.uri == kContainerUri && attribute.local_name == "Directory") || + (attribute.uri == kGainMapUri && attribute.local_name == "Version")) { + removals.emplace_back(attribute.name_begin, attribute.value_end); + } + } + for (const XmpElementSpan& child : elements) { + if (child.parent == description && + ((child.uri == kContainerUri && child.local_name == "Directory") || + (child.uri == kGainMapUri && child.local_name == "Version"))) { + removals.emplace_back(child.start_begin, child.end_end); + } + } + } + + if (elements[rdf_root].end_begin < offset || + elements[rdf_root].end_begin - offset > existing_xmp.size()) { + return string(); + } + const size_t insertion = elements[rdf_root].end_begin - offset; + for (auto& removal : removals) { + if (removal.first < offset || removal.second < removal.first) return string(); + removal.first -= offset; + removal.second -= offset; + if (removal.second > insertion) return string(); + } + sort(removals.begin(), removals.end()); + for (size_t index = 1; index < removals.size(); ++index) { + if (removals[index].first < removals[index - 1].second) return string(); + } + + const string generated_description = + GeneratePrimaryDescription(secondary_image_length, metadata, extended_xmp_guid); + string merged; + size_t cursor = 0; + for (const auto& [begin, end] : removals) { + if (begin < cursor || end > insertion || end > existing_xmp.size()) return string(); + merged.append(existing_xmp, cursor, begin - cursor); + cursor = end; + } + merged.append(existing_xmp, cursor, insertion - cursor); + merged.append(generated_description); + merged.push_back('\n'); + merged.append(existing_xmp, insertion, existing_xmp.size() - insertion); + return merged; +} + +} // namespace + +bool stripGainMapFromXmp(const std::string& xmp_data, std::string* stripped_xmp) { + if (stripped_xmp == nullptr) return false; + size_t trimmed_size = xmp_data.size(); + while (trimmed_size > 0 && xmp_data[trimmed_size - 1] == '\0') { + --trimmed_size; + } + const string clean_xmp = xmp_data.substr(0, trimmed_size); + if (clean_xmp.empty()) { + stripped_xmp->clear(); + return true; + } + + const string synthetic_open = ""; + const string synthetic_close = ""; + const size_t offset = synthetic_open.size(); + const string parse_xml = synthetic_open + clean_xmp + synthetic_close; + + XmpMergeXmlHandler handler(parse_xml); + size_t rdf_root = kNoXmpElement; + vector all_descriptions; + vector primary_descriptions; + if (!ParseRdfDescriptions(parse_xml, &handler, &rdf_root, &all_descriptions, + &primary_descriptions)) { + return false; + } + + const vector& elements = handler.elements(); + vector> removals; + size_t removed_descriptions = 0; + + for (const size_t description : primary_descriptions) { + const XmpElementSpan& primary = elements[description]; + bool has_removable = false; + bool has_user_attribute = false; + bool has_user_child = false; + + for (const XmpAttributeSpan& attribute : primary.attributes) { + if (attribute.namespace_declaration) continue; + if (attribute.uri == kRdfUri && attribute.local_name == "about" && + attribute.value.empty()) { + continue; + } + if (IsGainMapPropertyUri(attribute.uri)) { + has_removable = true; + } else { + has_user_attribute = true; + } + } + + for (const XmpElementSpan& child : elements) { + if (child.parent != description) continue; + if (IsGainMapPropertyUri(child.uri)) { + has_removable = true; + } else { + has_user_child = true; + } + } + + if (!has_removable) continue; + + if (!has_user_attribute && !has_user_child) { + ++removed_descriptions; + size_t remove_end = primary.end_end; + while (remove_end < parse_xml.size() && + (parse_xml[remove_end] == '\n' || parse_xml[remove_end] == '\r')) { + ++remove_end; + } + removals.emplace_back(primary.start_begin, remove_end); + continue; + } + + for (const XmpAttributeSpan& attribute : primary.attributes) { + bool should_remove = false; + if (!attribute.namespace_declaration && IsGainMapPropertyUri(attribute.uri)) { + should_remove = true; + } else if (attribute.namespace_declaration && IsGainMapPropertyUri(attribute.value)) { + should_remove = true; + } + if (should_remove) { + size_t attr_begin = attribute.name_begin; + while (attr_begin > primary.start_begin && IsXmlWhitespace(parse_xml[attr_begin - 1])) { + --attr_begin; + } + removals.emplace_back(attr_begin, attribute.value_end); + } + } + + for (const XmpElementSpan& child : elements) { + if (child.parent == description && IsGainMapPropertyUri(child.uri)) { + size_t child_begin = child.start_begin; + while (child_begin > primary.start_begin && + (parse_xml[child_begin - 1] == ' ' || parse_xml[child_begin - 1] == '\t')) { + --child_begin; + } + size_t child_end = child.end_end; + if (child_end + 1 < parse_xml.size() && parse_xml[child_end] == '\r' && + parse_xml[child_end + 1] == '\n') { + child_end += 2; + } else if (child_end < parse_xml.size() && parse_xml[child_end] == '\n') { + child_end += 1; + } + removals.emplace_back(child_begin, child_end); + } + } + } + + if (!all_descriptions.empty() && removed_descriptions == all_descriptions.size()) { + stripped_xmp->clear(); + return true; + } + + if (removals.empty()) { + *stripped_xmp = clean_xmp; + return true; + } + + for (auto& removal : removals) { + if (removal.first < offset || removal.second < removal.first) return false; + removal.first -= offset; + removal.second -= offset; + if (removal.second > clean_xmp.size()) return false; + } + sort(removals.begin(), removals.end()); + vector> coalesced; + for (const auto& [begin, end] : removals) { + if (!coalesced.empty() && begin <= coalesced.back().second) { + coalesced.back().second = (std::max)(coalesced.back().second, end); + } else { + coalesced.emplace_back(begin, end); + } + } + + string result; + result.reserve(clean_xmp.size()); + size_t cursor = 0; + for (const auto& [begin, end] : coalesced) { + if (begin < cursor || end > clean_xmp.size()) return false; + result.append(clean_xmp, cursor, begin - cursor); + cursor = end; + } + result.append(clean_xmp, cursor, clean_xmp.size() - cursor); + *stripped_xmp = std::move(result); + return true; +} + string generateXmpForPrimaryImage(size_t secondary_image_length, - uhdr_gainmap_metadata_ext_t& metadata) { + uhdr_gainmap_metadata_ext_t& metadata, + uhdr_mem_block_t* user_xmp, + const std::string& extended_xmp_guid) { const vector kConDirSeq({kConDirectory, string("rdf:Seq")}); const vector kLiItem({string("rdf:li"), kConItem}); + if (user_xmp != nullptr && user_xmp->data != nullptr && user_xmp->data_sz > 0) { + const std::string kXmpHeader = "http://ns.adobe.com/xap/1.0/"; + std::string existing_xmp; + if (user_xmp->data_sz > kXmpHeader.size() && + memcmp(user_xmp->data, kXmpHeader.c_str(), kXmpHeader.size()) == 0) { + size_t offset = kXmpHeader.size(); + if (static_cast(user_xmp->data)[offset] == '\0') { + offset++; + } + existing_xmp = std::string(static_cast(user_xmp->data) + offset, + user_xmp->data_sz - offset); + } else { + existing_xmp = std::string(static_cast(user_xmp->data), user_xmp->data_sz); + } + return MergePrimaryXmp(existing_xmp, secondary_image_length, metadata, extended_xmp_guid); + } + std::stringstream ss; photos_editing_formats::image_io::XmlWriter writer(ss); writer.StartWritingElement("x:xmpmeta"); @@ -889,6 +1683,10 @@ string generateXmpForPrimaryImage(size_t secondary_image_length, writer.WriteXmlns(kContainerPrefix, kContainerUri); writer.WriteXmlns(kItemPrefix, kItemUri); writer.WriteXmlns(kGainMapPrefix, kGainMapUri); + if (!extended_xmp_guid.empty()) { + writer.WriteXmlns("xmpNote", "http://ns.adobe.com/xmp/note/"); + writer.WriteAttributeNameAndValue("xmpNote:HasExtendedXMP", extended_xmp_guid); + } writer.WriteAttributeNameAndValue(kMapVersion, metadata.version); writer.StartWritingElements(kConDirSeq); diff --git a/lib/src/ultrahdr_api.cpp b/lib/src/ultrahdr_api.cpp index 74420083..fd5e9e12 100644 --- a/lib/src/ultrahdr_api.cpp +++ b/lib/src/ultrahdr_api.cpp @@ -10,6 +10,7 @@ #include #include +#include #include "ultrahdr_api.h" #include "ultrahdr/ultrahdrcommon.h" @@ -17,13 +18,6 @@ #include "ultrahdr/editorhelper.h" #include "ultrahdr/jpegr.h" -#ifdef UHDR_ENABLE_HEIF -#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" @@ -1156,6 +1150,45 @@ uhdr_error_info_t uhdr_enc_set_exif_data(uhdr_codec_private_t* enc, uhdr_mem_blo return status; } +uhdr_error_info_t uhdr_enc_set_xmp_data(uhdr_codec_private_t* enc, uhdr_mem_block_t* xmp) { + uhdr_error_info_t status = g_no_error; + if (dynamic_cast(enc) == nullptr) { + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, "received nullptr for uhdr codec instance"); + } else if (xmp == nullptr) { + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, "received nullptr for xmp image handle"); + } else if (xmp->data == nullptr) { + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, "received nullptr for xmp->data field"); + } else if (xmp->capacity < xmp->data_sz) { + status.error_code = UHDR_CODEC_INVALID_PARAM; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "xmp->capacity %zd is less than xmp->data_sz %zd", xmp->capacity, xmp->data_sz); + } + if (status.error_code != UHDR_CODEC_OK) return status; + + uhdr_encoder_private* handle = dynamic_cast(enc); + if (handle->m_sailed) { + status.error_code = UHDR_CODEC_INVALID_OPERATION; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, + "An earlier call to uhdr_encode() has switched the context from configurable state to " + "end state. The context is no longer configurable. To reuse, call reset()"); + return status; + } + + uint8_t* data = static_cast(xmp->data); + std::vector entry(data, data + xmp->data_sz); + handle->m_xmp = std::move(entry); + + return status; +} + uhdr_error_info_t uhdr_enc_set_output_format(uhdr_codec_private_t* enc, uhdr_codec_t media_type) { uhdr_error_info_t status = g_no_error; @@ -1269,7 +1302,31 @@ uhdr_error_info_t uhdr_encode(uhdr_codec_private_t* enc) { exif.capacity = exif.data_sz = handle->m_exif.size(); } + uhdr_mem_block_t xmp{}; + if (handle->m_xmp.size() > 0) { + xmp.data = handle->m_xmp.data(); + xmp.capacity = xmp.data_sz = handle->m_xmp.size(); + } + if (handle->m_output_format == UHDR_CODEC_JPG) { + const bool mayWriteXmp = !handle->m_xmp.empty() || !handle->m_compressed_images.empty(); + size_t xmpAllowance = 0; + if (mayWriteXmp) { + const size_t xmp_sz = handle->m_xmp.size(); + const size_t num_chunks = + (xmp_sz + ultrahdr::kExtendedXmpMaxChunkSize - 1) / ultrahdr::kExtendedXmpMaxChunkSize; + xmpAllowance = ultrahdr::kJpegAppSegmentTotalMaxBytes + xmp_sz + num_chunks * 79; + } + auto addXmpAllowance = [xmpAllowance](size_t base_size, size_t* output_size) { + if (xmpAllowance > (std::numeric_limits::max)() - base_size) return false; + *output_size = base_size + xmpAllowance; + return true; + }; + auto reportOutputSizeOverflow = [&status]() { + status.error_code = UHDR_CODEC_ERROR; + status.has_detail = 1; + snprintf(status.detail, sizeof status.detail, "compressed output size calculation overflowed"); + }; ultrahdr::JpegR jpegr(nullptr, handle->m_gainmap_scale_factor, handle->m_quality.find(UHDR_GAIN_MAP_IMG)->second, handle->m_use_multi_channel_gainmap, handle->m_gamma, @@ -1282,6 +1339,10 @@ uhdr_error_info_t uhdr_encode(uhdr_codec_private_t* enc) { size_t size = (std::max)(((size_t)64 * 1024), 2 * (base_entry->data_sz + gainmap_entry->data_sz)); + if (!addXmpAllowance(size, &size)) { + reportOutputSizeOverflow(); + return status; + } handle->m_compressed_output_buffer = std::make_unique( UHDR_CG_UNSPECIFIED, UHDR_CT_UNSPECIFIED, UHDR_CR_UNSPECIFIED, size); @@ -1289,11 +1350,16 @@ uhdr_error_info_t uhdr_encode(uhdr_codec_private_t* enc) { // api - 4 status = jpegr.encodeJPEGR(base_entry.get(), gainmap_entry.get(), &metadata, - handle->m_compressed_output_buffer.get()); + handle->m_compressed_output_buffer.get(), + handle->m_xmp.size() > 0 ? &xmp : nullptr); } else if (handle->m_raw_images.find(UHDR_HDR_IMG) != handle->m_raw_images.end()) { auto& hdr_raw_entry = handle->m_raw_images.find(UHDR_HDR_IMG)->second; size_t size = (std::max)((64u * 1024), hdr_raw_entry->w * hdr_raw_entry->h * 3 * 2); + if (!addXmpAllowance(size, &size)) { + reportOutputSizeOverflow(); + return status; + } handle->m_compressed_output_buffer = std::make_unique( UHDR_CG_UNSPECIFIED, UHDR_CT_UNSPECIFIED, UHDR_CR_UNSPECIFIED, size); @@ -1302,14 +1368,16 @@ uhdr_error_info_t uhdr_encode(uhdr_codec_private_t* enc) { // api - 0 status = jpegr.encodeJPEGR(hdr_raw_entry.get(), handle->m_compressed_output_buffer.get(), handle->m_quality.find(UHDR_BASE_IMG)->second, - handle->m_exif.size() > 0 ? &exif : nullptr); + handle->m_exif.size() > 0 ? &exif : nullptr, + handle->m_xmp.size() > 0 ? &xmp : nullptr); } else if (handle->m_compressed_images.find(UHDR_SDR_IMG) != handle->m_compressed_images.end() && handle->m_raw_images.find(UHDR_SDR_IMG) == handle->m_raw_images.end()) { auto& sdr_compressed_entry = handle->m_compressed_images.find(UHDR_SDR_IMG)->second; // api - 3 status = jpegr.encodeJPEGR(hdr_raw_entry.get(), sdr_compressed_entry.get(), - handle->m_compressed_output_buffer.get()); + handle->m_compressed_output_buffer.get(), + handle->m_xmp.size() > 0 ? &xmp : nullptr); } else if (handle->m_raw_images.find(UHDR_SDR_IMG) != handle->m_raw_images.end()) { auto& sdr_raw_entry = handle->m_raw_images.find(UHDR_SDR_IMG)->second; @@ -1318,13 +1386,15 @@ uhdr_error_info_t uhdr_encode(uhdr_codec_private_t* enc) { status = jpegr.encodeJPEGR(hdr_raw_entry.get(), sdr_raw_entry.get(), handle->m_compressed_output_buffer.get(), handle->m_quality.find(UHDR_BASE_IMG)->second, - handle->m_exif.size() > 0 ? &exif : nullptr); + handle->m_exif.size() > 0 ? &exif : nullptr, + handle->m_xmp.size() > 0 ? &xmp : nullptr); } else { auto& sdr_compressed_entry = handle->m_compressed_images.find(UHDR_SDR_IMG)->second; // api - 2 status = jpegr.encodeJPEGR(hdr_raw_entry.get(), sdr_raw_entry.get(), sdr_compressed_entry.get(), - handle->m_compressed_output_buffer.get()); + handle->m_compressed_output_buffer.get(), + handle->m_xmp.size() > 0 ? &xmp : nullptr); } } } else { @@ -1471,6 +1541,7 @@ void uhdr_reset_encoder(uhdr_codec_private_t* enc) { handle->m_quality.emplace(UHDR_BASE_IMG, ultrahdr::kBaseCompressQualityDefault); handle->m_quality.emplace(UHDR_GAIN_MAP_IMG, ultrahdr::kMapCompressQualityDefault); handle->m_exif.clear(); + handle->m_xmp.clear(); handle->m_output_format = UHDR_CODEC_JPG; handle->m_gainmap_scale_factor = ultrahdr::kMapDimensionScaleFactorDefault; handle->m_use_multi_channel_gainmap = ultrahdr::kUseMultiChannelGainMapDefault; @@ -1802,6 +1873,9 @@ uhdr_error_info_t uhdr_dec_probe(uhdr_codec_private_t* dec) { handle->m_icc = std::move(primary_image.iccData); handle->m_icc_block.data = handle->m_icc.data(); handle->m_icc_block.data_sz = handle->m_icc_block.capacity = handle->m_icc.size(); + handle->m_xmp = std::move(primary_image.xmpData); + handle->m_xmp_block.data = handle->m_xmp.data(); + handle->m_xmp_block.data_sz = handle->m_xmp_block.capacity = handle->m_xmp.size(); handle->m_base_img = std::move(primary_image.imgData); handle->m_base_img_block.data = handle->m_base_img.data(); handle->m_base_img_block.data_sz = handle->m_base_img_block.capacity = @@ -1893,6 +1967,19 @@ uhdr_mem_block_t* uhdr_dec_get_icc(uhdr_codec_private_t* dec) { return &handle->m_icc_block; } +uhdr_mem_block_t* uhdr_dec_get_xmp(uhdr_codec_private_t* dec) { + if (dynamic_cast(dec) == nullptr) { + return nullptr; + } + + uhdr_decoder_private* handle = dynamic_cast(dec); + if (!handle->m_probed || handle->m_probe_call_status.error_code != UHDR_CODEC_OK) { + return nullptr; + } + + return handle->m_xmp.size() > 0 ? &handle->m_xmp_block : nullptr; +} + uhdr_mem_block_t* uhdr_dec_get_base_image(uhdr_codec_private_t* dec) { if (dynamic_cast(dec) == nullptr) { return nullptr; @@ -2091,6 +2178,8 @@ void uhdr_reset_decoder(uhdr_codec_private_t* dec) { memset(&handle->m_exif_block, 0, sizeof handle->m_exif_block); handle->m_icc.clear(); memset(&handle->m_icc_block, 0, sizeof handle->m_icc_block); + handle->m_xmp.clear(); + memset(&handle->m_xmp_block, 0, sizeof handle->m_xmp_block); handle->m_base_img.clear(); memset(&handle->m_base_img_block, 0, sizeof handle->m_base_img_block); handle->m_gainmap_img.clear(); @@ -2247,3 +2336,9 @@ uhdr_error_info_t uhdr_add_effect_resize(uhdr_codec_private_t* codec, int width, return status; } + +uhdr_error_info_t uhdr_strip_gain_map(uhdr_compressed_image_t* in_stream, + uhdr_mem_block_t* out_stream) { + ultrahdr::JpegR jpegr; + return jpegr.stripGainMap(in_stream, out_stream); +} diff --git a/tests/ultrahdr_api_test.cpp b/tests/ultrahdr_api_test.cpp index b9b2f005..8c9f3006 100644 --- a/tests/ultrahdr_api_test.cpp +++ b/tests/ultrahdr_api_test.cpp @@ -7,19 +7,30 @@ #else #include #endif +#include #include +#include #include #include #include +#include +#include +#include #include #include -#include #include "ultrahdr_api.h" #include "ultrahdr/ultrahdrcommon.h" #include "ultrahdr/jpegr.h" +#include "ultrahdr/jpegrutils.h" +#include "ultrahdr/jpegdecoderhelper.h" +#include "ultrahdr/jpegencoderhelper.h" #include "ultrahdr/heifultrahdr.h" #include "ultrahdr/avifultrahdr.h" +#include "image_io/base/message_handler.h" +#include "image_io/xml/xml_element_rules.h" +#include "image_io/xml/xml_handler.h" +#include "image_io/xml/xml_reader.h" namespace ultrahdr { @@ -29,6 +40,273 @@ static const char* kSdrJpgFileName = "jpeg_image.jpg"; static const size_t kImageWidth = 1280; static const size_t kImageHeight = 720; +static std::string makeLargeXmp(size_t size) { + const std::string prefix = + ""; + EXPECT_GE(size, prefix.size() + suffix.size()); + return prefix + std::string(size - prefix.size() - suffix.size(), 'X') + suffix; +} + +static std::vector makeSmallHdrData() { + const uint16_t pixel[] = {0x3c00, 0x4000, 0x3800, 0x3c00}; + std::vector data(8 * 8 * sizeof(pixel)); + for (size_t i = 0; i < 8 * 8; ++i) { + memcpy(data.data() + i * sizeof(pixel), pixel, sizeof(pixel)); + } + return data; +} + +static std::string getXmpPacket(const uhdr_mem_block_t* xmp) { + constexpr std::string_view kXmpNamespace = "http://ns.adobe.com/xap/1.0/"; + if (xmp == nullptr || xmp->data == nullptr) return {}; + const auto* bytes = static_cast(xmp->data); + size_t offset = 0; + if (xmp->data_sz >= kXmpNamespace.size() && + std::string_view(bytes, kXmpNamespace.size()) == kXmpNamespace) { + offset = kXmpNamespace.size(); + if (offset < xmp->data_sz && bytes[offset] == '\0') ++offset; + } + return std::string(bytes + offset, xmp->data_sz - offset); +} + +static uhdr_gainmap_metadata_t makeTestGainmapMetadata() { + uhdr_gainmap_metadata_t metadata{}; + for (int channel = 0; channel < 3; ++channel) { + metadata.max_content_boost[channel] = 2.0f; + metadata.min_content_boost[channel] = 1.0f; + metadata.gamma[channel] = 1.0f; + } + metadata.hdr_capacity_min = 1.0f; + metadata.hdr_capacity_max = 2.0f; + metadata.use_base_cg = 1; + return metadata; +} + +using EncoderPtr = std::unique_ptr; +using DecoderPtr = std::unique_ptr; + +static EncoderPtr makeEncoder() { + return EncoderPtr(uhdr_create_encoder(), &uhdr_release_encoder); +} + +static DecoderPtr makeDecoder() { + return DecoderPtr(uhdr_create_decoder(), &uhdr_release_decoder); +} + +static uhdr_error_info_t configureJpegGainmapEncoder(uhdr_codec_private_t* encoder, + uhdr_compressed_image_t* base_image, + uhdr_compressed_image_t* gainmap_image, + uhdr_gainmap_metadata_t* metadata, + uhdr_mem_block_t* xmp) { + uhdr_error_info_t status = + uhdr_enc_set_compressed_image(encoder, base_image, UHDR_BASE_IMG); + if (status.error_code != UHDR_CODEC_OK) return status; + status = uhdr_enc_set_gainmap_image(encoder, gainmap_image, metadata); + if (status.error_code != UHDR_CODEC_OK) return status; + if (xmp != nullptr) { + status = uhdr_enc_set_xmp_data(encoder, xmp); + if (status.error_code != UHDR_CODEC_OK) return status; + } + return uhdr_enc_set_output_format(encoder, UHDR_CODEC_JPG); +} + +struct JpegXmpRoundTrip { + EncoderPtr encoder; + DecoderPtr decoder; + + JpegXmpRoundTrip() + : encoder(nullptr, &uhdr_release_encoder), decoder(nullptr, &uhdr_release_decoder) {} +}; + +static testing::AssertionResult encodeAndProbeJpegXmp( + uhdr_compressed_image_t* base_image, uhdr_compressed_image_t* gainmap_image, + uhdr_gainmap_metadata_t* metadata, uhdr_mem_block_t* xmp, JpegXmpRoundTrip& round_trip) { + round_trip.encoder = makeEncoder(); + if (round_trip.encoder == nullptr) { + return testing::AssertionFailure() << "uhdr_create_encoder returned nullptr"; + } + + const uhdr_error_info_t setup_status = configureJpegGainmapEncoder( + round_trip.encoder.get(), base_image, gainmap_image, metadata, xmp); + if (setup_status.error_code != UHDR_CODEC_OK) { + return testing::AssertionFailure() << "JPEG XMP encoder setup failed: " + << setup_status.detail; + } + + const uhdr_error_info_t encode_status = uhdr_encode(round_trip.encoder.get()); + if (encode_status.error_code != UHDR_CODEC_OK) { + return testing::AssertionFailure() << "JPEG XMP encode failed: " << encode_status.detail; + } + uhdr_compressed_image_t* output = uhdr_get_encoded_stream(round_trip.encoder.get()); + if (output == nullptr) { + return testing::AssertionFailure() << "uhdr_get_encoded_stream returned nullptr"; + } + + round_trip.decoder = makeDecoder(); + if (round_trip.decoder == nullptr) { + return testing::AssertionFailure() << "uhdr_create_decoder returned nullptr"; + } + uhdr_error_info_t probe_status = uhdr_dec_set_image(round_trip.decoder.get(), output); + if (probe_status.error_code != UHDR_CODEC_OK) { + return testing::AssertionFailure() << "JPEG XMP decoder setup failed: " + << probe_status.detail; + } + probe_status = uhdr_dec_probe(round_trip.decoder.get()); + if (probe_status.error_code != UHDR_CODEC_OK) { + return testing::AssertionFailure() << "JPEG XMP probe failed: " << probe_status.detail; + } + return testing::AssertionSuccess(); +} + +static std::vector extractPrimaryScanBytes(const void* stream_data, size_t stream_size) { + const auto* data = static_cast(stream_data); + if (data == nullptr || stream_size < 4 || data[0] != 0xFF || data[1] != 0xD8) return {}; + size_t pos = 2; + while (pos + 4 <= stream_size) { + if (data[pos] != 0xFF) return {}; + while (pos < stream_size && data[pos] == 0xFF) ++pos; + if (pos >= stream_size) return {}; + uint8_t marker = data[pos++]; + if (marker == 0xD9) return {}; + if (marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7)) continue; + if (pos + 2 > stream_size) return {}; + size_t seg_len = (static_cast(data[pos]) << 8) | data[pos + 1]; + if (seg_len < 2 || pos + seg_len > stream_size) return {}; + size_t marker_begin = pos - 2; + pos += seg_len; + if (marker == 0xDA) { + size_t scan_end = pos; + while (scan_end < stream_size) { + if (data[scan_end] != 0xFF) { + ++scan_end; + continue; + } + size_t ff_pos = scan_end; + while (scan_end < stream_size && data[scan_end] == 0xFF) ++scan_end; + if (scan_end >= stream_size) return {}; + uint8_t next_marker = data[scan_end]; + if (next_marker == 0x00 || (next_marker >= 0xD0 && next_marker <= 0xD7)) { + ++scan_end; + continue; + } + return std::vector(data + marker_begin, data + ff_pos); + } + return {}; + } + } + return {}; +} + +#if defined(UHDR_WRITE_XMP) +struct ContainerDirectoryInfo { + bool parsed = false; + size_t directory_count = 0; + std::vector gainmap_lengths; +}; + +class ContainerDirectoryHandler : public photos_editing_formats::image_io::XmlHandler { + public: + photos_editing_formats::image_io::DataMatchResult StartElement( + const photos_editing_formats::image_io::XmlTokenContext& context) override { + std::string name; + if (context.BuildTokenValue(&name)) { + element_stack_.push_back(name); + current_attribute_.clear(); + if (name == "Container:Directory") ++directory_count_; + if (name == "Container:Item") { + current_item_is_gainmap_ = false; + current_item_length_.reset(); + } + } + return context.GetResult(); + } + + photos_editing_formats::image_io::DataMatchResult AttributeName( + const photos_editing_formats::image_io::XmlTokenContext& context) override { + context.BuildTokenValue(¤t_attribute_); + return context.GetResult(); + } + + photos_editing_formats::image_io::DataMatchResult AttributeValue( + const photos_editing_formats::image_io::XmlTokenContext& context) override { + std::string value; + if (context.BuildTokenValue(&value, true) && !element_stack_.empty() && + element_stack_.back() == "Container:Item") { + if (current_attribute_ == "Item:Semantic") { + current_item_is_gainmap_ = value == "GainMap"; + } else if (current_attribute_ == "Item:Length") { + size_t parsed = 0; + const auto parse_result = + std::from_chars(value.data(), value.data() + value.size(), parsed); + if (parse_result.ec == std::errc() && parse_result.ptr == value.data() + value.size()) { + current_item_length_ = parsed; + } else { + current_item_length_.reset(); + } + } + } + return context.GetResult(); + } + + photos_editing_formats::image_io::DataMatchResult FinishElement( + const photos_editing_formats::image_io::XmlTokenContext& context) override { + if (!element_stack_.empty()) { + if (element_stack_.back() == "Container:Item" && current_item_is_gainmap_ && + current_item_length_.has_value()) { + gainmap_lengths_.push_back(*current_item_length_); + } + element_stack_.pop_back(); + } + current_attribute_.clear(); + return context.GetResult(); + } + + size_t directoryCount() const { return directory_count_; } + const std::vector& gainmapLengths() const { return gainmap_lengths_; } + + private: + std::vector element_stack_; + std::string current_attribute_; + bool current_item_is_gainmap_ = false; + std::optional current_item_length_; + size_t directory_count_ = 0; + std::vector gainmap_lengths_; +}; + +static ContainerDirectoryInfo getContainerDirectoryInfo(std::string_view xmp) { + ContainerDirectoryInfo info; + ContainerDirectoryHandler handler; + photos_editing_formats::image_io::MessageHandler messages; + photos_editing_formats::image_io::XmlReader reader(&handler, &messages); + if (!reader.StartParse(std::make_unique()) || + !reader.Parse(std::string("") + std::string(xmp) + "") || + !reader.FinishParse() || reader.HasErrors()) { + return info; + } + info.parsed = true; + info.directory_count = handler.directoryCount(); + info.gainmap_lengths = handler.gainmapLengths(); + return info; +} +#endif + +static void expectEncodedXmpDecodes(uhdr_compressed_image_t* output, size_t min_xmp_size) { + ASSERT_NE(output, nullptr); + uhdr_codec_private_t* dec = uhdr_create_decoder(); + ASSERT_NE(dec, nullptr); + ASSERT_EQ(uhdr_dec_set_image(dec, output).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_dec_probe(dec).error_code, UHDR_CODEC_OK); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec); + ASSERT_NE(decoded_xmp, nullptr); + ASSERT_NE(decoded_xmp->data, nullptr); + EXPECT_GE(decoded_xmp->data_sz, min_xmp_size); + ASSERT_EQ(uhdr_decode(dec).error_code, UHDR_CODEC_OK); + uhdr_release_decoder(dec); +} + static bool loadFile(const char* filename, std::vector& buffer) { std::vector candidates = { filename, @@ -284,6 +562,946 @@ TEST_F(UltraHdrApiTest, JpegEncodeApi2AndDecode) { uhdr_release_encoder(enc); } +#if defined(UHDR_WRITE_XMP) +TEST_F(UltraHdrApiTest, JpegApi4ExplicitXmpPreservesRdfNamespacePrefixes) { + struct PrefixControl { + const char* name; + const char* packet; + }; + const PrefixControl controls[] = { + {"conventional", + "semantic-probe" + "keep-unrelated" + ""}, + {"alias", + "semantic-probe" + "keep-unrelated" + ""}, + {"default-rdf", + "semantic-probe" + "keep-unrelated" + ""}, + }; + + for (const PrefixControl& control : controls) { + SCOPED_TRACE(control.name); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + uhdr_mem_block_t xmp_block{const_cast(control.packet), strlen(control.packet), + strlen(control.packet)}; + JpegXmpRoundTrip round_trip; + ASSERT_TRUE(encodeAndProbeJpegXmp(&mSdrCompressed, &mSdrCompressed, &metadata, &xmp_block, + round_trip)); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(round_trip.decoder.get()); + ASSERT_NE(decoded_xmp, nullptr); + ASSERT_NE(decoded_xmp->data, nullptr); + const std::string decoded_packet = getXmpPacket(decoded_xmp); + const ContainerDirectoryInfo directory_info = getContainerDirectoryInfo(decoded_packet); + ASSERT_TRUE(directory_info.parsed); + EXPECT_EQ(directory_info.directory_count, 1u); + const size_t generated_description = decoded_packet.rfind("', generated_description); + ASSERT_NE(opening_end, std::string::npos); + const std::string generated_opening = + decoded_packet.substr(generated_description, opening_end - generated_description); + EXPECT_NE(generated_opening.find( + "xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\""), + std::string::npos); + EXPECT_NE(decoded_packet.find("semantic-probe"), std::string::npos); + EXPECT_NE(decoded_packet.find("keep-unrelated"), std::string::npos); + } +} + +TEST_F(UltraHdrApiTest, JpegApi4XmpMergeReplacesVersionAttributeAndElement) { + struct VersionForm { + const char* name; + const char* packet; + const char* stale_value; + }; + const VersionForm forms[] = { + {"attribute", + "", + "old-attribute"}, + {"element", + "" + "old-element" + "", + "old-element"}, + }; + + for (const VersionForm& form : forms) { + SCOPED_TRACE(form.name); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + uhdr_mem_block_t xmp_block{const_cast(form.packet), strlen(form.packet), + strlen(form.packet)}; + JpegXmpRoundTrip round_trip; + ASSERT_TRUE(encodeAndProbeJpegXmp(&mSdrCompressed, &mSdrCompressed, &metadata, &xmp_block, + round_trip)); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(round_trip.decoder.get()); + ASSERT_NE(decoded_xmp, nullptr); + ASSERT_NE(decoded_xmp->data, nullptr); + const std::string decoded_packet = getXmpPacket(decoded_xmp); + const ContainerDirectoryInfo directory_info = getContainerDirectoryInfo(decoded_packet); + ASSERT_TRUE(directory_info.parsed); + EXPECT_EQ(directory_info.directory_count, 1u); + EXPECT_EQ(decoded_packet.find(form.stale_value), std::string::npos); + EXPECT_NE(decoded_packet.find("hdrgm:Version=\"1.0\""), std::string::npos); + } +} + +TEST_F(UltraHdrApiTest, JpegApi4GetterSetterReencodeUsesSingleCurrentGainmapDirectory) { + const std::string descriptive_seed = + "" + "retained-description" + "" + "stale-container-element" + "stale-hdrgm-element" + ""; + uhdr_mem_block_t descriptive_seed_block{const_cast(descriptive_seed.data()), + descriptive_seed.size(), descriptive_seed.size()}; + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + JpegXmpRoundTrip first_round_trip; + ASSERT_TRUE(encodeAndProbeJpegXmp(&mSdrCompressed, &mSdrCompressed, &metadata, + &descriptive_seed_block, first_round_trip)); + uhdr_mem_block_t* first_xmp = uhdr_dec_get_xmp(first_round_trip.decoder.get()); + ASSERT_NE(first_xmp, nullptr); + ASSERT_NE(first_xmp->data, nullptr); + uhdr_mem_block_t* first_base = uhdr_dec_get_base_image(first_round_trip.decoder.get()); + ASSERT_NE(first_base, nullptr); + uhdr_mem_block_t* first_gainmap = uhdr_dec_get_gainmap_image(first_round_trip.decoder.get()); + ASSERT_NE(first_gainmap, nullptr); + ASSERT_NE(first_gainmap->data, nullptr); + const std::string first_packet = getXmpPacket(first_xmp); + ASSERT_FALSE(first_packet.empty()); + const char* stale_sentinels[] = { + "stale-container-attribute", "stale-container-element", "stale-hdrgm-attribute", + "stale-hdrgm-element", + }; + + auto checkOutput = [&](JpegXmpRoundTrip& round_trip, bool expect_changed_gainmap, + const char* expected_description, const char* absent_description, + std::string* packet_out) { + uhdr_mem_block_t* output_gainmap = uhdr_dec_get_gainmap_image(round_trip.decoder.get()); + ASSERT_NE(output_gainmap, nullptr); + ASSERT_NE(output_gainmap->data, nullptr); + if (expect_changed_gainmap) { + EXPECT_NE(output_gainmap->data_sz, first_gainmap->data_sz); + } + + uhdr_mem_block_t* output_xmp = uhdr_dec_get_xmp(round_trip.decoder.get()); + ASSERT_NE(output_xmp, nullptr); + ASSERT_NE(output_xmp->data, nullptr); + const std::string output_packet = getXmpPacket(output_xmp); + ASSERT_FALSE(output_packet.empty()); + EXPECT_NE(output_packet.find(expected_description), std::string::npos); + if (absent_description != nullptr) { + EXPECT_EQ(output_packet.find(absent_description), std::string::npos); + } + for (const char* stale : stale_sentinels) { + EXPECT_EQ(output_packet.find(stale), std::string::npos) << stale; + } + + const ContainerDirectoryInfo directory_info = getContainerDirectoryInfo(output_packet); + ASSERT_TRUE(directory_info.parsed); + EXPECT_EQ(directory_info.directory_count, 1u); + ASSERT_EQ(directory_info.gainmap_lengths.size(), 1u); + EXPECT_EQ(directory_info.gainmap_lengths.front(), output_gainmap->data_sz); + ASSERT_EQ(uhdr_decode(round_trip.decoder.get()).error_code, UHDR_CODEC_OK); + if (packet_out != nullptr) *packet_out = output_packet; + }; + + uhdr_gainmap_metadata_t* decoded_metadata = + uhdr_dec_get_gainmap_metadata(first_round_trip.decoder.get()); + ASSERT_NE(decoded_metadata, nullptr); + checkOutput(first_round_trip, false, "retained-description", nullptr, nullptr); + + JpegEncoderHelper changed_gainmap_encoder; + ASSERT_EQ(changed_gainmap_encoder.compressImage(&mSdrRaw, 50, nullptr, 0).error_code, + UHDR_CODEC_OK); + uhdr_compressed_image_t changed_gainmap = changed_gainmap_encoder.getCompressedImage(); + ASSERT_NE(changed_gainmap.data, nullptr); + ASSERT_NE(changed_gainmap.data_sz, first_gainmap->data_sz); + + uhdr_compressed_image_t base_input{ + first_base->data, first_base->data_sz, first_base->capacity, UHDR_CG_BT_709, UHDR_CT_SRGB, + UHDR_CR_FULL_RANGE}; + uhdr_compressed_image_t gainmap_input = changed_gainmap; + + const std::string xpacket_wrapped = + "\n" + first_packet + + "\n"; + const std::string raw_getter(static_cast(first_xmp->data), first_xmp->data_sz); + const std::string replacement_xmp = + "" + "replacement-description"; + struct ReencodeControl { + const char* name; + const std::string* xmp; + const char* expected_description; + const char* absent_description; + bool expect_xpacket_wrapper; + }; + const ReencodeControl controls[] = { + {"raw getter bytes", &raw_getter, "retained-description", nullptr, false}, + {"xpacket wrapped XML", &xpacket_wrapped, "retained-description", nullptr, true}, + {"no XMP setter", nullptr, "retained-description", nullptr, false}, + {"explicit replacement", &replacement_xmp, "replacement-description", + "retained-description", false}, + }; + for (const ReencodeControl& control : controls) { + SCOPED_TRACE(control.name); + uhdr_mem_block_t xmp_input{}; + if (control.xmp != nullptr) { + xmp_input = {const_cast(control.xmp->data()), control.xmp->size(), + control.xmp->size()}; + } + JpegXmpRoundTrip second_round_trip; + ASSERT_TRUE(encodeAndProbeJpegXmp(&base_input, &gainmap_input, decoded_metadata, + control.xmp != nullptr ? &xmp_input : nullptr, + second_round_trip)); + std::string second_packet; + checkOutput(second_round_trip, true, control.expected_description, + control.absent_description, &second_packet); + if (control.expect_xpacket_wrapper) { + EXPECT_NE(second_packet.find( + ""), + std::string::npos); + EXPECT_NE(second_packet.find(""), std::string::npos); + } + } +} + +TEST_F(UltraHdrApiTest, JpegApi4XmpMergeIgnoresFakeRdfClosersInCommentsAndCdata) { + const std::string user_xmp = + "semantic-probe" + "]]>" + "" + ""; + + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + uhdr_mem_block_t xmp_block{const_cast(user_xmp.data()), user_xmp.size(), user_xmp.size()}; + JpegXmpRoundTrip round_trip; + ASSERT_TRUE(encodeAndProbeJpegXmp(&mSdrCompressed, &mSdrCompressed, &metadata, &xmp_block, + round_trip)); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(round_trip.decoder.get()); + ASSERT_NE(decoded_xmp, nullptr); + ASSERT_NE(decoded_xmp->data, nullptr); + const std::string decoded_packet = getXmpPacket(decoded_xmp); + const ContainerDirectoryInfo directory_info = getContainerDirectoryInfo(decoded_packet); + ASSERT_TRUE(directory_info.parsed); + EXPECT_EQ(directory_info.directory_count, 1u); + EXPECT_NE(decoded_packet.find("semantic-probe"), std::string::npos); + EXPECT_NE(decoded_packet.find("]]>"), std::string::npos); + EXPECT_NE(decoded_packet.find(""), std::string::npos); + ASSERT_EQ(directory_info.gainmap_lengths.size(), 1u); + uhdr_mem_block_t* decoded_gainmap = uhdr_dec_get_gainmap_image(round_trip.decoder.get()); + ASSERT_NE(decoded_gainmap, nullptr); + ASSERT_NE(decoded_gainmap->data, nullptr); + EXPECT_EQ(directory_info.gainmap_lengths.front(), decoded_gainmap->data_sz); +} + +TEST_F(UltraHdrApiTest, JpegApi4XmpMergeKeepsOwnedPropertiesOnNodeIdDescription) { + const std::string user_xmp = + "" + "keep-id-directory" + "" + "keep-node-directory" + "" + "primary-probe"; + + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + uhdr_mem_block_t xmp_block{const_cast(user_xmp.data()), user_xmp.size(), user_xmp.size()}; + JpegXmpRoundTrip round_trip; + ASSERT_TRUE(encodeAndProbeJpegXmp(&mSdrCompressed, &mSdrCompressed, &metadata, &xmp_block, + round_trip)); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(round_trip.decoder.get()); + ASSERT_NE(decoded_xmp, nullptr); + ASSERT_NE(decoded_xmp->data, nullptr); + const std::string decoded_packet = getXmpPacket(decoded_xmp); + EXPECT_NE(decoded_packet.find("keep-id-directory"), std::string::npos); + EXPECT_NE(decoded_packet.find("keep-node-directory"), std::string::npos); + EXPECT_NE(decoded_packet.find("primary-probe"), std::string::npos); +} + +TEST_F(UltraHdrApiTest, JpegApi4RejectsMalformedOrUnmergeableXmp) { + const char* packets[] = { + "", + "" + "", + "" + "unmergeable", + }; + + for (const char* packet : packets) { + SCOPED_TRACE(packet); + EncoderPtr enc = makeEncoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + uhdr_mem_block_t xmp_block{const_cast(packet), strlen(packet), strlen(packet)}; + const uhdr_error_info_t setup_status = configureJpegGainmapEncoder( + enc.get(), &mSdrCompressed, &mSdrCompressed, &metadata, &xmp_block); + ASSERT_EQ(setup_status.error_code, UHDR_CODEC_OK) << setup_status.detail; + const uhdr_error_info_t status = uhdr_encode(enc.get()); + EXPECT_EQ(status.error_code, UHDR_CODEC_INVALID_PARAM) << status.detail; + } +} +#endif + +#if !defined(UHDR_WRITE_XMP) && defined(UHDR_WRITE_ISO) +TEST_F(UltraHdrApiTest, JpegApi4IsoOnlyPassesThroughXmpExactly) { + const std::string user_xmp = + "iso-passthrough" + ""; + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + uhdr_mem_block_t xmp_block{const_cast(user_xmp.data()), user_xmp.size(), user_xmp.size()}; + JpegXmpRoundTrip round_trip; + ASSERT_TRUE(encodeAndProbeJpegXmp(&mSdrCompressed, &mSdrCompressed, &metadata, &xmp_block, + round_trip)); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(round_trip.decoder.get()); + ASSERT_NE(decoded_xmp, nullptr); + ASSERT_NE(decoded_xmp->data, nullptr); + EXPECT_EQ(getXmpPacket(decoded_xmp), user_xmp); +} +#endif + +TEST_F(UltraHdrApiTest, StripGainMapPreservesPrimaryScanExifIccAndUserXmp) { + EncoderPtr enc = makeEncoder(); + ASSERT_NE(enc, nullptr); + + const std::string kExifPayload = "Exif\0\0II*\0\x08\0\0\0\0\0"; + uhdr_mem_block_t exif_block{const_cast(kExifPayload.data()), kExifPayload.size(), + kExifPayload.size()}; + + const std::string kUserXmp = + "\n" + "\n" + " \n" + " \n" + " Photographer\n" + " \n" + " \n" + " Lossless SDR export test\n" + " \n" + " \n" + " \n" + " \n" + "\n" + ""; + uhdr_mem_block_t xmp_block{const_cast(kUserXmp.data()), kUserXmp.size(), kUserXmp.size()}; + + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(configureJpegGainmapEncoder(enc.get(), &mSdrCompressed, &mSdrCompressed, &metadata, + &xmp_block) + .error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_exif_data(enc.get(), &exif_block).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_encode(enc.get()).error_code, UHDR_CODEC_OK); + + uhdr_compressed_image_t* uhdr_stream = uhdr_get_encoded_stream(enc.get()); + ASSERT_NE(uhdr_stream, nullptr); + ASSERT_EQ(is_uhdr_image(uhdr_stream->data, static_cast(uhdr_stream->data_sz)), 1); + + // 1. Query required output size via {nullptr, 0, 0}. + uhdr_mem_block_t size_query{nullptr, 0, 0}; + ASSERT_EQ(uhdr_strip_gain_map(uhdr_stream, &size_query).error_code, UHDR_CODEC_OK); + ASSERT_GT(size_query.data_sz, 0u); + ASSERT_LT(size_query.data_sz, uhdr_stream->data_sz); + + // 2. Strip gain map into caller-owned buffer. + std::vector stripped_buf(size_query.data_sz); + uhdr_mem_block_t stripped_block{stripped_buf.data(), 0, stripped_buf.size()}; + ASSERT_EQ(uhdr_strip_gain_map(uhdr_stream, &stripped_block).error_code, UHDR_CODEC_OK); + EXPECT_EQ(stripped_block.data_sz, size_query.data_sz); + + // 3. Stripped output is a clean standard SDR JPEG, not recognized as Ultra HDR. + EXPECT_EQ(is_uhdr_image(stripped_block.data, static_cast(stripped_block.data_sz)), 0); + + // 4. Primary entropy-coded scan data is preserved bit-for-bit (no DCT re-encoding). + const std::vector orig_scan = + extractPrimaryScanBytes(uhdr_stream->data, uhdr_stream->data_sz); + const std::vector stripped_scan = + extractPrimaryScanBytes(stripped_block.data, stripped_block.data_sz); + ASSERT_FALSE(orig_scan.empty()); + EXPECT_EQ(orig_scan, stripped_scan); + + // 5. Verify Exif, ICC, ISO metadata removal, and user XMP via JpegDecoderHelper. + JpegDecoderHelper orig_decoder; + ASSERT_EQ(orig_decoder.parseImage(uhdr_stream->data, uhdr_stream->data_sz).error_code, UHDR_CODEC_OK); + JpegDecoderHelper stripped_decoder; + ASSERT_EQ(stripped_decoder.parseImage(stripped_block.data, stripped_block.data_sz).error_code, UHDR_CODEC_OK); + + EXPECT_EQ(stripped_decoder.getIsoMetadataSize(), 0u); + ASSERT_EQ(stripped_decoder.getEXIFSize(), orig_decoder.getEXIFSize()); + EXPECT_EQ(memcmp(stripped_decoder.getEXIFPtr(), orig_decoder.getEXIFPtr(), + orig_decoder.getEXIFSize()), + 0); + ASSERT_EQ(stripped_decoder.getICCSize(), orig_decoder.getICCSize()); + if (orig_decoder.getICCSize() > 0) { + EXPECT_EQ( + memcmp(stripped_decoder.getICCPtr(), orig_decoder.getICCPtr(), orig_decoder.getICCSize()), + 0); + } + + ASSERT_NE(stripped_decoder.getXMPPtr(), nullptr); + uhdr_mem_block_t stripped_xmp_mem{stripped_decoder.getXMPPtr(), stripped_decoder.getXMPSize(), + stripped_decoder.getXMPSize()}; + const std::string stripped_xmp_str = getXmpPacket(&stripped_xmp_mem); + EXPECT_EQ(stripped_xmp_str, kUserXmp); + EXPECT_EQ(stripped_xmp_str.find("Container:Directory"), std::string::npos); + EXPECT_EQ(stripped_xmp_str.find("hdrgm:"), std::string::npos); + + // 6. Verify idempotency when calling uhdr_strip_gain_map again on the stripped SDR JPEG. + uhdr_compressed_image_t stripped_input{stripped_block.data, stripped_block.data_sz, + stripped_block.capacity, UHDR_CG_BT_709, UHDR_CT_SRGB, + UHDR_CR_FULL_RANGE}; + std::vector second_buf(stripped_block.data_sz); + uhdr_mem_block_t second_block{second_buf.data(), 0, second_buf.size()}; + ASSERT_EQ(uhdr_strip_gain_map(&stripped_input, &second_block).error_code, UHDR_CODEC_OK); + ASSERT_EQ(second_block.data_sz, stripped_block.data_sz); + EXPECT_EQ(memcmp(second_block.data, stripped_block.data, stripped_block.data_sz), 0); +} + +TEST_F(UltraHdrApiTest, StripGainMapDropsGeneratedOnlyXmpSegment) { + EncoderPtr enc = makeEncoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(configureJpegGainmapEncoder(enc.get(), &mSdrCompressed, &mSdrCompressed, &metadata, + nullptr) + .error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_encode(enc.get()).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* uhdr_stream = uhdr_get_encoded_stream(enc.get()); + ASSERT_NE(uhdr_stream, nullptr); + + std::vector stripped_buf(uhdr_stream->data_sz); + uhdr_mem_block_t stripped_block{stripped_buf.data(), 0, stripped_buf.size()}; + ASSERT_EQ(uhdr_strip_gain_map(uhdr_stream, &stripped_block).error_code, UHDR_CODEC_OK); + EXPECT_EQ(is_uhdr_image(stripped_block.data, static_cast(stripped_block.data_sz)), 0); + + JpegDecoderHelper stripped_decoder; + ASSERT_EQ(stripped_decoder.parseImage(stripped_block.data, stripped_block.data_sz).error_code, UHDR_CODEC_OK); + EXPECT_EQ(stripped_decoder.getIsoMetadataSize(), 0u); + EXPECT_EQ(stripped_decoder.getXMPSize(), 0u); +} + +TEST_F(UltraHdrApiTest, StripGainMapPreservesSharedDescriptionAndExtendedXmp) { + // Part 1: Shared containing both user properties and gain-map properties. + const std::string shared_xmp = + "" + "Shared description photo" + "" + "" + "" + ""; + std::string stripped_shared; + ASSERT_TRUE(stripGainMapFromXmp(shared_xmp, &stripped_shared)); + EXPECT_NE(stripped_shared.find("dc:format=\"image/jpeg\""), std::string::npos); + EXPECT_NE(stripped_shared.find("Shared description photo"), + std::string::npos); + EXPECT_EQ(stripped_shared.find("hdrgm:"), std::string::npos); + EXPECT_EQ(stripped_shared.find("Container:"), std::string::npos); + EXPECT_EQ(stripped_shared.find("xmlns:hdrgm"), std::string::npos); + EXPECT_EQ(stripped_shared.find("xmlns:Container"), std::string::npos); + EXPECT_EQ(stripped_shared.find("xmlns:Item"), std::string::npos); + + // Part 2: Multi-segment Extended XMP (> 64 KB) preserved through uhdr_strip_gain_map. + const std::string ext_xmp = makeLargeXmp(140000); + uhdr_mem_block_t ext_xmp_block{const_cast(ext_xmp.data()), ext_xmp.size(), ext_xmp.size()}; + EncoderPtr enc = makeEncoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(configureJpegGainmapEncoder(enc.get(), &mSdrCompressed, &mSdrCompressed, &metadata, + &ext_xmp_block) + .error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_encode(enc.get()).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* uhdr_stream = uhdr_get_encoded_stream(enc.get()); + ASSERT_NE(uhdr_stream, nullptr); + + std::vector stripped_buf(uhdr_stream->data_sz); + uhdr_mem_block_t stripped_block{stripped_buf.data(), 0, stripped_buf.size()}; + ASSERT_EQ(uhdr_strip_gain_map(uhdr_stream, &stripped_block).error_code, UHDR_CODEC_OK); + EXPECT_EQ(is_uhdr_image(stripped_block.data, static_cast(stripped_block.data_sz)), 0); + + JpegDecoderHelper stripped_decoder; + ASSERT_EQ(stripped_decoder.parseImage(stripped_block.data, stripped_block.data_sz).error_code, UHDR_CODEC_OK); + ASSERT_EQ(stripped_decoder.getXMPSize(), ext_xmp.size()); + EXPECT_EQ(memcmp(stripped_decoder.getXMPPtr(), ext_xmp.data(), ext_xmp.size()), 0); +} + +TEST_F(UltraHdrApiTest, StripGainMapStripsTrailingBinaryTrailerAfterPrimaryEoi) { + EncoderPtr enc = makeEncoder(); + ASSERT_NE(enc, nullptr); + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + ASSERT_EQ(configureJpegGainmapEncoder(enc.get(), &mSdrCompressed, &mSdrCompressed, &metadata, + nullptr) + .error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_encode(enc.get()).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* uhdr_stream = uhdr_get_encoded_stream(enc.get()); + ASSERT_NE(uhdr_stream, nullptr); + + // Append a synthetic binary trailer containing arbitrary 0xFF bytes (e.g. Samsung SEF trailer). + std::vector with_trailer( + static_cast(uhdr_stream->data), + static_cast(uhdr_stream->data) + uhdr_stream->data_sz); + const uint8_t sef_trailer[] = {0x53, 0x45, 0x46, 0x54, 0xFF, 0xE1, 0xFF, 0xD8, 0xFF, 0xFE, 0x00}; + with_trailer.insert(with_trailer.end(), std::begin(sef_trailer), std::end(sef_trailer)); + + uhdr_compressed_image_t trailer_input{with_trailer.data(), with_trailer.size(), + with_trailer.size(), UHDR_CG_BT_709, UHDR_CT_SRGB, + UHDR_CR_FULL_RANGE}; + std::vector stripped_buf(with_trailer.size()); + uhdr_mem_block_t stripped_block{stripped_buf.data(), 0, stripped_buf.size()}; + ASSERT_EQ(uhdr_strip_gain_map(&trailer_input, &stripped_block).error_code, UHDR_CODEC_OK); + ASSERT_GE(stripped_block.data_sz, 4u); + EXPECT_EQ(stripped_buf[stripped_block.data_sz - 2], 0xFF); + EXPECT_EQ(stripped_buf[stripped_block.data_sz - 1], 0xD9); + EXPECT_EQ(is_uhdr_image(stripped_block.data, static_cast(stripped_block.data_sz)), 0); +} + +TEST_F(UltraHdrApiTest, StripGainMapInvalidParamsAndCorruptInput) { + uhdr_mem_block_t out{nullptr, 0, 0}; + EXPECT_EQ(uhdr_strip_gain_map(nullptr, &out).error_code, UHDR_CODEC_INVALID_PARAM); + EXPECT_EQ(uhdr_strip_gain_map(&mSdrCompressed, nullptr).error_code, UHDR_CODEC_INVALID_PARAM); + + uhdr_mem_block_t bad_out{nullptr, 0, 16}; + EXPECT_EQ(uhdr_strip_gain_map(&mSdrCompressed, &bad_out).error_code, UHDR_CODEC_INVALID_PARAM); + + uint8_t tiny_buf[4]{}; + uhdr_mem_block_t tiny_out{tiny_buf, 0, sizeof(tiny_buf)}; + EXPECT_EQ(uhdr_strip_gain_map(&mSdrCompressed, &tiny_out).error_code, UHDR_CODEC_INVALID_PARAM); + EXPECT_GT(tiny_out.data_sz, sizeof(tiny_buf)); + + uint8_t non_jpeg[] = {0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; + uhdr_compressed_image_t non_jpeg_in{non_jpeg, sizeof(non_jpeg), sizeof(non_jpeg), + UHDR_CG_UNSPECIFIED, UHDR_CT_UNSPECIFIED, + UHDR_CR_UNSPECIFIED}; + EXPECT_EQ(uhdr_strip_gain_map(&non_jpeg_in, &out).error_code, UHDR_CODEC_INVALID_PARAM); + + // Truncated JPEG (SOI + EOI without SOS) + uint8_t no_sos_jpeg[] = {0xFF, 0xD8, 0xFF, 0xD9}; + uhdr_compressed_image_t no_sos_in{no_sos_jpeg, sizeof(no_sos_jpeg), sizeof(no_sos_jpeg), + UHDR_CG_UNSPECIFIED, UHDR_CT_UNSPECIFIED, + UHDR_CR_UNSPECIFIED}; + EXPECT_EQ(uhdr_strip_gain_map(&no_sos_in, &out).error_code, UHDR_CODEC_INVALID_PARAM); +} + +TEST_F(UltraHdrApiTest, StripGainMapOnAppleGainMapFixtures) { + for (const char* filename : {"apple_gainmap_new.jpg", "apple_gainmap_old.jpg"}) { + SCOPED_TRACE(filename); + std::vector apple_data; + ASSERT_TRUE(loadFile(filename, apple_data)); + ASSERT_EQ(is_uhdr_image(apple_data.data(), static_cast(apple_data.size())), 1); + + uhdr_compressed_image_t apple_in{apple_data.data(), apple_data.size(), apple_data.size(), + UHDR_CG_DISPLAY_P3, UHDR_CT_SRGB, UHDR_CR_FULL_RANGE}; + uhdr_mem_block_t size_query{nullptr, 0, 0}; + ASSERT_EQ(uhdr_strip_gain_map(&apple_in, &size_query).error_code, UHDR_CODEC_OK); + ASSERT_GT(size_query.data_sz, 0u); + ASSERT_LT(size_query.data_sz, apple_data.size()); + + std::vector stripped_buf(size_query.data_sz); + uhdr_mem_block_t stripped_block{stripped_buf.data(), 0, stripped_buf.size()}; + ASSERT_EQ(uhdr_strip_gain_map(&apple_in, &stripped_block).error_code, UHDR_CODEC_OK); + EXPECT_EQ(stripped_block.data_sz, size_query.data_sz); + EXPECT_EQ(is_uhdr_image(stripped_block.data, static_cast(stripped_block.data_sz)), 0); + + // Primary entropy-coded scan (including DRI and RST0..RST7 markers) must be bit-identical. + const std::vector orig_scan = + extractPrimaryScanBytes(apple_data.data(), apple_data.size()); + const std::vector stripped_scan = + extractPrimaryScanBytes(stripped_block.data, stripped_block.data_sz); + ASSERT_FALSE(orig_scan.empty()); + EXPECT_EQ(orig_scan, stripped_scan); + + // Verify EXIF and ICC profile are preserved and stripped JPEG decodes cleanly. + JpegDecoderHelper orig_decoder; + ASSERT_EQ(orig_decoder.parseImage(apple_data.data(), apple_data.size()).error_code, + UHDR_CODEC_OK); + JpegDecoderHelper stripped_decoder; + ASSERT_EQ(stripped_decoder + .decompressImage(stripped_block.data, stripped_block.data_sz, DECODE_TO_RGB_CS) + .error_code, + UHDR_CODEC_OK); + ASSERT_EQ(stripped_decoder.getEXIFSize(), orig_decoder.getEXIFSize()); + EXPECT_EQ(memcmp(stripped_decoder.getEXIFPtr(), orig_decoder.getEXIFPtr(), + orig_decoder.getEXIFSize()), + 0); + ASSERT_EQ(stripped_decoder.getICCSize(), orig_decoder.getICCSize()); + EXPECT_EQ(memcmp(stripped_decoder.getICCPtr(), orig_decoder.getICCPtr(), + orig_decoder.getICCSize()), + 0); + } +} + +TEST_F(UltraHdrApiTest, NearLimitStandardXmpSpillsCleanlyToExtendedXmp) { + // 65,200 bytes is <= kMaxStandardXmpPayload (65,503 B), but when UHDR_WRITE_XMP is enabled, + // merging the ~600-byte Ultra HDR container directory pushes it over 65,503 B into Extended XMP. + constexpr size_t kNearLimitSize = 65200; + const std::string near_limit_xmp = makeLargeXmp(kNearLimitSize); + uhdr_mem_block_t xmp_block{const_cast(near_limit_xmp.data()), near_limit_xmp.size(), + near_limit_xmp.size()}; + uhdr_gainmap_metadata_t metadata = makeTestGainmapMetadata(); + + EncoderPtr enc = makeEncoder(); + ASSERT_NE(enc, nullptr); + ASSERT_EQ(configureJpegGainmapEncoder(enc.get(), &mSdrCompressed, &mSdrCompressed, &metadata, + &xmp_block) + .error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_encode(enc.get()).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* uhdr_stream = uhdr_get_encoded_stream(enc.get()); + ASSERT_NE(uhdr_stream, nullptr); + + // Verify uhdr_dec_get_xmp recovers the exact 65,200-byte user XMP packet. + DecoderPtr dec = makeDecoder(); + ASSERT_NE(dec, nullptr); + ASSERT_EQ(uhdr_dec_set_image(dec.get(), uhdr_stream).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_dec_probe(dec.get()).error_code, UHDR_CODEC_OK); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec.get()); + ASSERT_NE(decoded_xmp, nullptr); + EXPECT_EQ(getXmpPacket(decoded_xmp), near_limit_xmp); + + // Verify uhdr_strip_gain_map also preserves the 65,200-byte user XMP packet cleanly. + std::vector stripped_buf(uhdr_stream->data_sz); + uhdr_mem_block_t stripped_block{stripped_buf.data(), 0, stripped_buf.size()}; + ASSERT_EQ(uhdr_strip_gain_map(uhdr_stream, &stripped_block).error_code, UHDR_CODEC_OK); + EXPECT_EQ(is_uhdr_image(stripped_block.data, static_cast(stripped_block.data_sz)), 0); + + JpegDecoderHelper stripped_decoder; + ASSERT_EQ(stripped_decoder.parseImage(stripped_block.data, stripped_block.data_sz).error_code, + UHDR_CODEC_OK); + uhdr_mem_block_t stripped_xmp_mem{stripped_decoder.getXMPPtr(), stripped_decoder.getXMPSize(), + stripped_decoder.getXMPSize()}; + EXPECT_EQ(getXmpPacket(&stripped_xmp_mem), near_limit_xmp); +} + +TEST_F(UltraHdrApiTest, JpegEncodeWithXmpAndDecode) { + uhdr_codec_private_t* enc = uhdr_create_encoder(); + ASSERT_NE(enc, nullptr); + + EXPECT_EQ(uhdr_enc_set_raw_image(enc, &mHdrRaw, UHDR_HDR_IMG).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_enc_set_quality(enc, 85, UHDR_BASE_IMG).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_enc_set_quality(enc, 85, UHDR_GAIN_MAP_IMG).error_code, UHDR_CODEC_OK); + + const std::string kUserXmp = + "\n" + " \n" + " \n" + " \n" + " \n" + " Greg Benz\n" + " \n" + " \n" + " \n" + " \n" + " Copyright 2026 Greg Benz\n" + " \n" + " \n" + " \n" + " \n" + "\n"; + + uhdr_mem_block_t xmp_block{}; + xmp_block.data = const_cast(kUserXmp.data()); + xmp_block.data_sz = xmp_block.capacity = kUserXmp.size(); + + EXPECT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); + + ASSERT_EQ(uhdr_encode(enc).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); + ASSERT_NE(output, nullptr); + ASSERT_GT(output->data_sz, 0u); + + // Decode and verify XMP preservation + uhdr_codec_private_t* dec = uhdr_create_decoder(); + ASSERT_NE(dec, nullptr); + EXPECT_EQ(uhdr_dec_set_image(dec, output).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_dec_probe(dec).error_code, UHDR_CODEC_OK); + + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec); + ASSERT_NE(decoded_xmp, nullptr); + ASSERT_NE(decoded_xmp->data, nullptr); + ASSERT_GT(decoded_xmp->data_sz, 0u); + + std::string decoded_xmp_str(static_cast(decoded_xmp->data), decoded_xmp->data_sz); + + // Verify that user custom metadata was preserved + EXPECT_NE(decoded_xmp_str.find("Greg Benz"), std::string::npos); + EXPECT_NE(decoded_xmp_str.find("Copyright 2026 Greg Benz"), std::string::npos); + + // Verify gain map metadata was decoded and valid + uhdr_gainmap_metadata_t* gm_meta = uhdr_dec_get_gainmap_metadata(dec); + ASSERT_NE(gm_meta, nullptr); + + EXPECT_EQ(uhdr_decode(dec).error_code, UHDR_CODEC_OK); + + uhdr_release_decoder(dec); + uhdr_release_encoder(enc); +} + +TEST_F(UltraHdrApiTest, JpegEncodeApi2WithXmpAndDecode) { + // First, produce a base JPEG that carries XMP + uhdr_codec_private_t* enc1 = uhdr_create_encoder(); + ASSERT_NE(enc1, nullptr); + EXPECT_EQ(uhdr_enc_set_raw_image(enc1, &mHdrRaw, UHDR_HDR_IMG).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_enc_set_output_format(enc1, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + + const std::string kUserXmp = + "\n" + " \n" + " \n" + " Base Image With XMP\n" + " \n" + " \n" + "\n"; + + uhdr_mem_block_t xmp_block{}; + xmp_block.data = const_cast(kUserXmp.data()); + xmp_block.data_sz = xmp_block.capacity = kUserXmp.size(); + EXPECT_EQ(uhdr_enc_set_xmp_data(enc1, &xmp_block).error_code, UHDR_CODEC_OK); + + ASSERT_EQ(uhdr_encode(enc1).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* enc1_out = uhdr_get_encoded_stream(enc1); + ASSERT_NE(enc1_out, nullptr); + + // Probe and extract the base image with its embedded XMP + uhdr_codec_private_t* dec1 = uhdr_create_decoder(); + ASSERT_NE(dec1, nullptr); + EXPECT_EQ(uhdr_dec_set_image(dec1, enc1_out).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_dec_probe(dec1).error_code, UHDR_CODEC_OK); + uhdr_mem_block_t* base_img = uhdr_dec_get_base_image(dec1); + ASSERT_NE(base_img, nullptr); + + uhdr_compressed_image_t sdr_compressed_with_xmp{}; + sdr_compressed_with_xmp.data = base_img->data; + sdr_compressed_with_xmp.data_sz = base_img->data_sz; + sdr_compressed_with_xmp.capacity = base_img->capacity; + sdr_compressed_with_xmp.cg = UHDR_CG_DISPLAY_P3; + sdr_compressed_with_xmp.ct = UHDR_CT_SRGB; + sdr_compressed_with_xmp.range = UHDR_CR_FULL_RANGE; + + // Now use API-2 with this compressed SDR image carrying XMP, WITHOUT explicitly setting XMP + uhdr_codec_private_t* enc2 = uhdr_create_encoder(); + ASSERT_NE(enc2, nullptr); + EXPECT_EQ(uhdr_enc_set_raw_image(enc2, &mHdrRaw, UHDR_HDR_IMG).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_enc_set_compressed_image(enc2, &sdr_compressed_with_xmp, UHDR_SDR_IMG).error_code, + UHDR_CODEC_OK); + EXPECT_EQ(uhdr_enc_set_output_format(enc2, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + + ASSERT_EQ(uhdr_encode(enc2).error_code, UHDR_CODEC_OK); + uhdr_compressed_image_t* enc2_out = uhdr_get_encoded_stream(enc2); + ASSERT_NE(enc2_out, nullptr); + + // Decode and verify that the base image's XMP was preserved automatically + uhdr_codec_private_t* dec2 = uhdr_create_decoder(); + ASSERT_NE(dec2, nullptr); + EXPECT_EQ(uhdr_dec_set_image(dec2, enc2_out).error_code, UHDR_CODEC_OK); + EXPECT_EQ(uhdr_dec_probe(dec2).error_code, UHDR_CODEC_OK); + + uhdr_mem_block_t* dec2_xmp = uhdr_dec_get_xmp(dec2); + ASSERT_NE(dec2_xmp, nullptr); + std::string dec2_xmp_str(static_cast(dec2_xmp->data), dec2_xmp->data_sz); + EXPECT_NE(dec2_xmp_str.find("Base Image With XMP"), std::string::npos); + + uhdr_release_decoder(dec2); + uhdr_release_encoder(enc2); + uhdr_release_decoder(dec1); + uhdr_release_encoder(enc1); +} + +TEST_F(UltraHdrApiTest, LargeXmpFitsSmallRawApiOutput) { +#ifdef UHDR_WRITE_XMP + // The dual writer adds its container directory to the supplied packet. + constexpr size_t kXmpSize = 64000; +#else + constexpr size_t kXmpSize = 65504; +#endif + const std::vector hdr_data = makeSmallHdrData(); + uhdr_raw_image_t hdr{}; + hdr.fmt = UHDR_IMG_FMT_64bppRGBAHalfFloat; + hdr.cg = UHDR_CG_DISPLAY_P3; + hdr.ct = UHDR_CT_LINEAR; + hdr.range = UHDR_CR_FULL_RANGE; + hdr.w = hdr.h = 8; + hdr.planes[UHDR_PLANE_PACKED] = const_cast(hdr_data.data()); + hdr.stride[UHDR_PLANE_PACKED] = 8; + + uhdr_codec_private_t* enc = uhdr_create_encoder(); + ASSERT_NE(enc, nullptr); + ASSERT_EQ(uhdr_enc_set_raw_image(enc, &hdr, UHDR_HDR_IMG).error_code, UHDR_CODEC_OK); + const std::string xmp = makeLargeXmp(kXmpSize); + uhdr_mem_block_t xmp_block{const_cast(xmp.data()), xmp.size(), xmp.size()}; + ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + + const uhdr_error_info_t status = uhdr_encode(enc); + ASSERT_EQ(status.error_code, UHDR_CODEC_OK) << status.detail; + uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); + ASSERT_NE(output, nullptr); + EXPECT_GT(output->data_sz, static_cast(64 * 1024)); + expectEncodedXmpDecodes(output, kXmpSize); + uhdr_release_encoder(enc); +} + +TEST_F(UltraHdrApiTest, LargeXmpFitsCompressedBaseGainmapApiOutput) { +#ifdef UHDR_WRITE_XMP + constexpr size_t kXmpSize = 64000; +#else + constexpr size_t kXmpSize = 65504; +#endif + uhdr_gainmap_metadata_t metadata{}; + for (int channel = 0; channel < 3; ++channel) { + metadata.max_content_boost[channel] = 2.0f; + metadata.min_content_boost[channel] = 1.0f; + metadata.gamma[channel] = 1.0f; + } + metadata.hdr_capacity_min = 1.0f; + metadata.hdr_capacity_max = 2.0f; + metadata.use_base_cg = 1; + + uhdr_codec_private_t* enc = uhdr_create_encoder(); + ASSERT_NE(enc, nullptr); + ASSERT_EQ(uhdr_enc_set_compressed_image(enc, &mSdrCompressed, UHDR_BASE_IMG).error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_gainmap_image(enc, &mSdrCompressed, &metadata).error_code, + UHDR_CODEC_OK); + const std::string xmp = makeLargeXmp(kXmpSize); + uhdr_mem_block_t xmp_block{const_cast(xmp.data()), xmp.size(), xmp.size()}; + ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + + const uhdr_error_info_t status = uhdr_encode(enc); + ASSERT_EQ(status.error_code, UHDR_CODEC_OK) << status.detail; + uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); + ASSERT_NE(output, nullptr); + EXPECT_GT(output->data_sz, static_cast(64 * 1024)); + expectEncodedXmpDecodes(output, kXmpSize); + uhdr_release_encoder(enc); +} + +TEST_F(UltraHdrApiTest, ExtendedXmpMultiSegmentRawApiOutput) { + // 150 KB XMP metadata spans across 3 Extended XMP APP1 segments in JPEG + constexpr size_t kLargeXmpSize = 150 * 1024; + const std::vector hdr_data = makeSmallHdrData(); + uhdr_raw_image_t hdr{}; + hdr.fmt = UHDR_IMG_FMT_64bppRGBAHalfFloat; + hdr.cg = UHDR_CG_DISPLAY_P3; + hdr.ct = UHDR_CT_LINEAR; + hdr.range = UHDR_CR_FULL_RANGE; + hdr.w = hdr.h = 8; + hdr.planes[UHDR_PLANE_PACKED] = const_cast(hdr_data.data()); + hdr.stride[UHDR_PLANE_PACKED] = 8; + + uhdr_codec_private_t* enc = uhdr_create_encoder(); + ASSERT_NE(enc, nullptr); + ASSERT_EQ(uhdr_enc_set_raw_image(enc, &hdr, UHDR_HDR_IMG).error_code, UHDR_CODEC_OK); + const std::string xmp = makeLargeXmp(kLargeXmpSize); + uhdr_mem_block_t xmp_block{const_cast(xmp.data()), xmp.size(), xmp.size()}; + ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + + const uhdr_error_info_t status = uhdr_encode(enc); + ASSERT_EQ(status.error_code, UHDR_CODEC_OK) << status.detail; + uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); + ASSERT_NE(output, nullptr); + EXPECT_GT(output->data_sz, kLargeXmpSize); + expectEncodedXmpDecodes(output, kLargeXmpSize); + + // Validate exact bit-for-bit decoded XMP content + uhdr_codec_private_t* dec = uhdr_create_decoder(); + ASSERT_NE(dec, nullptr); + ASSERT_EQ(uhdr_dec_set_image(dec, output).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_dec_probe(dec).error_code, UHDR_CODEC_OK); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec); + ASSERT_NE(decoded_xmp, nullptr); + ASSERT_NE(decoded_xmp->data, nullptr); + EXPECT_EQ(decoded_xmp->data_sz, kLargeXmpSize); + EXPECT_EQ(memcmp(decoded_xmp->data, xmp.data(), kLargeXmpSize), 0); + uhdr_release_decoder(dec); + uhdr_release_encoder(enc); +} + +TEST_F(UltraHdrApiTest, ExtendedXmpMultiSegmentCompressedBaseGainmapApiOutput) { + constexpr size_t kLargeXmpSize = 200 * 1024; + uhdr_gainmap_metadata_t metadata{}; + for (int channel = 0; channel < 3; ++channel) { + metadata.max_content_boost[channel] = 2.0f; + metadata.min_content_boost[channel] = 1.0f; + metadata.gamma[channel] = 1.0f; + } + metadata.hdr_capacity_min = 1.0f; + metadata.hdr_capacity_max = 2.0f; + metadata.use_base_cg = 1; + + uhdr_codec_private_t* enc = uhdr_create_encoder(); + ASSERT_NE(enc, nullptr); + ASSERT_EQ(uhdr_enc_set_compressed_image(enc, &mSdrCompressed, UHDR_BASE_IMG).error_code, + UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_gainmap_image(enc, &mSdrCompressed, &metadata).error_code, + UHDR_CODEC_OK); + const std::string xmp = makeLargeXmp(kLargeXmpSize); + uhdr_mem_block_t xmp_block{const_cast(xmp.data()), xmp.size(), xmp.size()}; + ASSERT_EQ(uhdr_enc_set_xmp_data(enc, &xmp_block).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_enc_set_output_format(enc, UHDR_CODEC_JPG).error_code, UHDR_CODEC_OK); + + const uhdr_error_info_t status = uhdr_encode(enc); + ASSERT_EQ(status.error_code, UHDR_CODEC_OK) << status.detail; + uhdr_compressed_image_t* output = uhdr_get_encoded_stream(enc); + ASSERT_NE(output, nullptr); + EXPECT_GT(output->data_sz, kLargeXmpSize); + expectEncodedXmpDecodes(output, kLargeXmpSize); + + // Validate exact bit-for-bit decoded XMP content + uhdr_codec_private_t* dec = uhdr_create_decoder(); + ASSERT_NE(dec, nullptr); + ASSERT_EQ(uhdr_dec_set_image(dec, output).error_code, UHDR_CODEC_OK); + ASSERT_EQ(uhdr_dec_probe(dec).error_code, UHDR_CODEC_OK); + uhdr_mem_block_t* decoded_xmp = uhdr_dec_get_xmp(dec); + ASSERT_NE(decoded_xmp, nullptr); + ASSERT_NE(decoded_xmp->data, nullptr); + EXPECT_EQ(decoded_xmp->data_sz, kLargeXmpSize); + EXPECT_EQ(memcmp(decoded_xmp->data, xmp.data(), kLargeXmpSize), 0); + uhdr_release_decoder(dec); + uhdr_release_encoder(enc); +} + // ============================================================================ // HEIF / HEIC Tests (API-0, API-1, and Unsupported APIs) // ============================================================================ diff --git a/ultrahdr_api.h b/ultrahdr_api.h index 39e529f5..2bfa9725 100644 --- a/ultrahdr_api.h +++ b/ultrahdr_api.h @@ -404,6 +404,19 @@ UHDR_EXTERN uhdr_error_info_t uhdr_enc_set_quality(uhdr_codec_private_t* enc, in UHDR_EXTERN uhdr_error_info_t uhdr_enc_set_exif_data(uhdr_codec_private_t* enc, uhdr_mem_block_t* exif); +/*!\brief Set XMP data that needs to be inserted in the output compressed stream. This function + * does not generate or validate xmp data on its own. It merely copies the supplied information + * into the bitstream (or merges with gain map metadata in xmp mode). + * + * \param[in] enc encoder instance + * \param[in] xmp xmp data memory block. + * + * \return uhdr_error_info_t #UHDR_CODEC_OK if operation succeeds, + * #UHDR_CODEC_INVALID_PARAM otherwise. + */ +UHDR_EXTERN uhdr_error_info_t uhdr_enc_set_xmp_data(uhdr_codec_private_t* enc, + uhdr_mem_block_t* xmp); + /*!\brief Enable/Disable multi-channel gainmap. By default multi-channel gainmap is enabled. * * \param[in] enc encoder instance. @@ -746,6 +759,14 @@ UHDR_EXTERN uhdr_mem_block_t* uhdr_dec_get_exif(uhdr_codec_private_t* dec); */ UHDR_EXTERN uhdr_mem_block_t* uhdr_dec_get_icc(uhdr_codec_private_t* dec); +/*!\brief Get xmp information + * + * \param[in] dec decoder instance. + * + * \return nullptr if probe call is unsuccessful, memory block with xmp data otherwise + */ +UHDR_EXTERN uhdr_mem_block_t* uhdr_dec_get_xmp(uhdr_codec_private_t* dec); + /*!\brief Get base image (compressed) * * \param[in] dec decoder instance. @@ -901,4 +922,20 @@ UHDR_EXTERN uhdr_error_info_t uhdr_add_effect_crop(uhdr_codec_private_t* codec, UHDR_EXTERN uhdr_error_info_t uhdr_add_effect_resize(uhdr_codec_private_t* codec, int width, int height); +/*!\brief Losslessly strip gain map bitstream and gain map metadata from an Ultra HDR image. + * + * Produces a clean standard SDR compressed stream: + * - Strips secondary gain map JPEG image data and MPF pointers. + * - Strips ISO 21496-1 gain map metadata from APP2 (urn:iso:std:iso:ts:21496:-1). + * - Strips legacy Ultra HDR tags (Container:Directory, hdrgm:*) from APP1 XMP. + * - Preserves primary image scan data bit-for-bit (lossless, no DCT re-encoding). + * - Preserves Exif, ICC profile, and all user/application XMP metadata intact. + * + * \param[in] in_stream Input compressed Ultra HDR stream. + * \param[out] out_stream Output descriptor receiving the clean SDR compressed stream. + * \return uhdr_error_info_t #UHDR_CODEC_OK on success, #UHDR_CODEC_INVALID_PARAM otherwise. + */ +UHDR_EXTERN uhdr_error_info_t uhdr_strip_gain_map(uhdr_compressed_image_t* in_stream, + uhdr_mem_block_t* out_stream); + #endif // ULTRAHDR_API_H