diff --git a/CHANGELOG.md b/CHANGELOG.md index bb8aac5619..0c4cddb94b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 in the future. Files created now might not decode in a future version. This feature is off by default and must be enabled with the AVIF_ENABLE_EXPERIMENTAL_GAIN_MAP compilation flag. +* Add experimental support for converting jpeg files with gain maps to AVIF + files with gain maps. Requires libxml2, and the AVIF_ENABLE_EXPERIMENTAL_GAIN_MAP + compilation flag. * Add the headerFormat member of new type avifHeaderFormat to avifEncoder. * Add experimental API for reading and writing "avir"-branded AVIF files behind the compilation flag AVIF_ENABLE_EXPERIMENTAL_AVIR. diff --git a/CMakeLists.txt b/CMakeLists.txt index 8f3d5cd29e..3559321d29 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -128,6 +128,7 @@ if(AVIF_LOCAL_ZLIBPNG) set(ZLIB_LIBRARY zlibstatic) endif() + option(AVIF_LOCAL_JPEG "Build jpeg by providing your own copy inside the ext subdir." OFF) if(AVIF_LOCAL_JPEG) add_subdirectory(ext/libjpeg) @@ -139,6 +140,7 @@ if(AVIF_LOCAL_JPEG) set(JPEG_LIBRARY jpeg PARENT_SCOPE) endif() endif() + option(AVIF_LOCAL_LIBYUV "Build libyuv by providing your own copy inside the ext subdir." OFF) if(AVIF_LOCAL_LIBYUV) set(LIB_FILENAME "${CMAKE_CURRENT_SOURCE_DIR}/ext/libyuv/build/${AVIF_LIBRARY_PREFIX}yuv${AVIF_LIBRARY_SUFFIX}") @@ -188,6 +190,7 @@ if(libyuv_FOUND) set(AVIF_PLATFORM_INCLUDES ${AVIF_PLATFORM_INCLUDES} ${LIBYUV_INCLUDE_DIR}) set(AVIF_PLATFORM_LIBRARIES ${AVIF_PLATFORM_LIBRARIES} ${LIBYUV_LIBRARY}) endif(libyuv_FOUND) + option(AVIF_LOCAL_LIBSHARPYUV "Build libsharpyuv by providing your own copy inside the ext subdir." OFF) if(AVIF_LOCAL_LIBSHARPYUV) set(LIB_FILENAME "${CMAKE_CURRENT_SOURCE_DIR}/ext/libwebp/build/libsharpyuv${AVIF_LIBRARY_SUFFIX}") @@ -213,6 +216,28 @@ if(libsharpyuv_FOUND) else(libsharpyuv_FOUND) message(STATUS "libavif: libsharpyuv not found") endif(libsharpyuv_FOUND) + +option(AVIF_LOCAL_LIBXML2 "Build libxml2 by providing your own copy inside the ext subdir. \ +libxml2 is used when AVIF_ENABLE_EXPERIMENTAL_GAIN_MAP is ON" OFF +) +if(AVIF_LOCAL_LIBXML2) + set(LIB_FILENAME + "${CMAKE_CURRENT_SOURCE_DIR}/ext/libxml2/install.libavif/lib/${AVIF_LIBRARY_PREFIX}xml2${AVIF_LIBRARY_SUFFIX}" + ) + if(NOT EXISTS "${LIB_FILENAME}") + message(FATAL_ERROR "libavif: ${LIB_FILENAME} is missing, bailing out") + endif() + if("${CMAKE_SOURCE_DIR}" STREQUAL "${CMAKE_CURRENT_SOURCE_DIR}") + set(LIBXML2_INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ext/libxml2/install.libavif/include/libxml2") + set(LIBXML2_LIBRARY ${LIB_FILENAME}) + else() + set(LIBXML2_INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ext/libxml2/install.libavif/include/libxml2" PARENT_SCOPE) + set(LIBXML2_LIBRARY ${LIB_FILENAME} PARENT_SCOPE) + endif() + set(LIBXML2_FOUND TRUE) +else() + find_package(LibXml2 QUIET) # not required +endif() # --------------------------------------------------------------------------------------- # Enable all warnings @@ -637,6 +662,17 @@ if(AVIF_BUILD_APPS OR (AVIF_BUILD_TESTS AND AVIF_ENABLE_GTEST)) avif_apps PRIVATE $ ${PNG_PNG_INCLUDE_DIR} ${JPEG_INCLUDE_DIR} INTERFACE apps/shared ) + + if(AVIF_ENABLE_EXPERIMENTAL_GAIN_MAP) + if(LIBXML2_FOUND) + set(AVIF_ENABLE_EXPERIMENTAL_JPEG_GAIN_MAP_CONVERSION TRUE) + add_compile_definitions(AVIF_ENABLE_EXPERIMENTAL_JPEG_GAIN_MAP_CONVERSION) + target_link_libraries(avif_apps ${LIBXML2_LIBRARY}) + target_include_directories(avif_apps PRIVATE ${LIBXML2_INCLUDE_DIR}) + else() + message(STATUS "libavif: libxml2 not found; avifenc will ignore any gain map in jpeg files") + endif() + endif() endif() if(AVIF_BUILD_APPS) @@ -780,6 +816,9 @@ if(WIN32) if(AVIF_LOCAL_JPEG) avif_set_folder_safe(jpeg "ext/libjpeg") endif() + if(AVIF_LOCAL_LIBXML2) + avif_set_folder_safe(xml2 "ext/libxml2") + endif() endif() add_subdirectory(contrib) diff --git a/apps/avifenc.c b/apps/avifenc.c index 034a3dcb74..8a786dc555 100644 --- a/apps/avifenc.c +++ b/apps/avifenc.c @@ -54,6 +54,7 @@ typedef struct avifBool ignoreExif; avifBool ignoreXMP; avifBool ignoreColorProfile; + avifBool ignoreGainMap; // only relevant when compiled with AVIF_ENABLE_EXPERIMENTAL_JPEG_GAIN_MAP_CONVERSION // This holds the output timing for image sequences. The timescale member in this struct will // become the timescale set on avifEncoder, and the duration member will be the default duration @@ -209,6 +210,10 @@ static void syntaxLong(void) printf(" --ignore-exif : If the input file contains embedded Exif metadata, ignore it (no-op if absent)\n"); printf(" --ignore-xmp : If the input file contains embedded XMP metadata, ignore it (no-op if absent)\n"); printf(" --ignore-profile,--ignore-icc : If the input file contains an embedded color profile, ignore it (no-op if absent)\n"); +#if defined(AVIF_ENABLE_EXPERIMENTAL_JPEG_GAIN_MAP_CONVERSION) + printf(" --ignore-gain-map : If the input file contains an embedded gain map, ignore it (no-op if absent)\n"); + // TODO(maryla): add quality setting for the gain map. +#endif printf(" --pasp H,V : Add pasp property (aspect ratio). H=horizontal spacing, V=vertical spacing\n"); printf(" --crop CROPX,CROPY,CROPW,CROPH : Add clap property (clean aperture), but calculated from a crop rectangle\n"); printf(" --clap WN,WD,HN,HD,HON,HOD,VON,VOD: Add clap property (clean aperture). Width, Height, HOffset, VOffset (in num/denom pairs)\n"); @@ -482,6 +487,7 @@ static avifBool avifInputReadImage(avifInput * input, avifBool ignoreExif, avifBool ignoreXMP, avifBool allowChangingCicp, + avifBool ignoreGainMap, avifImage * image, const avifInputFileSettings ** settings, uint32_t * outDepth, @@ -571,6 +577,7 @@ static avifBool avifInputReadImage(avifInput * input, ignoreExif, ignoreXMP, allowChangingCicp, + ignoreGainMap, dstImage, dstDepth, dstSourceTiming, @@ -601,6 +608,7 @@ static avifBool avifInputReadImage(avifInput * input, ignoreExif, ignoreXMP, allowChangingCicp, + ignoreGainMap, image, settings, outDepth, @@ -895,12 +903,14 @@ static avifBool avifEncodeRestOfImageSequence(avifEncoder * encoder, // Ignore ICC, Exif and XMP because only the metadata of the first frame is taken into // account by the libavif API. + // Ignore gain map as it's not supported for sequences. if (!avifInputReadImage(input, imageIndex, /*ignoreColorProfile=*/AVIF_TRUE, /*ignoreExif=*/AVIF_TRUE, /*ignoreXMP=*/AVIF_TRUE, /*allowChangingCicp=*/AVIF_FALSE, + /*ignoreGainMap=*/AVIF_TRUE, nextImage, &nextSettings, /*outDepth=*/NULL, @@ -1279,6 +1289,7 @@ int main(int argc, char * argv[]) settings.ignoreExif = AVIF_FALSE; settings.ignoreXMP = AVIF_FALSE; settings.ignoreColorProfile = AVIF_FALSE; + settings.ignoreGainMap = AVIF_FALSE; settings.cicpExplicitlySet = AVIF_FALSE; avifInputFileSettings pendingSettings; @@ -1684,6 +1695,10 @@ int main(int argc, char * argv[]) settings.ignoreXMP = AVIF_TRUE; } else if (!strcmp(arg, "--ignore-profile") || !strcmp(arg, "--ignore-icc")) { settings.ignoreColorProfile = AVIF_TRUE; +#if defined(AVIF_ENABLE_EXPERIMENTAL_JPEG_GAIN_MAP_CONVERSION) + } else if (!strcmp(arg, "--ignore-gain-map")) { + settings.ignoreGainMap = AVIF_TRUE; +#endif } else if (!strcmp(arg, "--pasp")) { NEXTARG(); settings.paspCount = parseU32List(settings.paspValues, arg); @@ -2031,12 +2046,16 @@ int main(int argc, char * argv[]) uint32_t sourceDepth = 0; avifBool sourceWasRGB = AVIF_FALSE; avifAppSourceTiming firstSourceTiming; + const avifBool isImageSequence = (settings.gridDimsCount == 0) && (input.filesCount > 1); + // Gain maps are not supported for animations or layered images. + const avifBool ignoreGainMap = settings.ignoreGainMap || isImageSequence || settings.progressive; if (!avifInputReadImage(&input, /*imageIndex=*/0, settings.ignoreColorProfile, settings.ignoreExif, settings.ignoreXMP, /*allowChangingCicp=*/!settings.cicpExplicitlySet, + ignoreGainMap, image, /*settings=*/NULL, // Must use the setting for first input &sourceDepth, @@ -2276,6 +2295,7 @@ int main(int argc, char * argv[]) /*ignoreExif=*/AVIF_TRUE, /*ignoreXMP=*/AVIF_TRUE, /*allowChangingCicp=*/AVIF_FALSE, + settings.ignoreGainMap, cellImage, /*settings=*/NULL, /*outDepth=*/NULL, @@ -2327,7 +2347,6 @@ int main(int argc, char * argv[]) printf("Encoded successfully.\n"); printf(" * Color AV1 total size: %" AVIF_FMT_ZU " bytes\n", ioStats.colorOBUSize); printf(" * Alpha AV1 total size: %" AVIF_FMT_ZU " bytes\n", ioStats.alphaOBUSize); - const avifBool isImageSequence = (settings.gridDimsCount == 0) && (input.filesCount > 1); if (isImageSequence) { if (settings.repetitionCount == AVIF_REPETITION_COUNT_INFINITE) { printf(" * Repetition Count: Infinite\n"); diff --git a/apps/shared/avifjpeg.c b/apps/shared/avifjpeg.c index de079a8197..d8526000f5 100644 --- a/apps/shared/avifjpeg.c +++ b/apps/shared/avifjpeg.c @@ -6,6 +6,8 @@ #include "avifutil.h" #include +#include +#include #include #include #include @@ -16,6 +18,11 @@ #include "iccjpeg.h" +#if defined(AVIF_ENABLE_EXPERIMENTAL_JPEG_GAIN_MAP_CONVERSION) +#include "avif/gainmap.h" +#include +#endif + #define AVIF_MIN(a, b) (((a) < (b)) ? (a) : (b)) #define AVIF_MAX(a, b) (((a) > (b)) ? (a) : (b)) @@ -70,6 +77,7 @@ static avifBool avifJPEGCopyPixels(avifImage * avif, struct jpeg_decompress_stru readLines = AVIF_MAX(readLines, linesPerCall[i]); } + avifImageFreePlanes(avif, AVIF_PLANES_ALL); // Free planes in case they were already allocated. if (avifImageAllocatePlanes(avif, AVIF_PLANES_YUV) != AVIF_RESULT_OK) { return AVIF_FALSE; } @@ -225,7 +233,7 @@ static avifBool avifJPEGReadCopy(avifImage * avif, struct jpeg_decompress_struct return AVIF_FALSE; } -// Reads 4-byte unsigned integer in big-endian format from the raw bitstream src. +// Reads a 4-byte unsigned integer in big-endian format from the raw bitstream src. static uint32_t avifJPEGReadUint32BigEndian(const uint8_t * src) { return ((uint32_t)src[0] << 24) | ((uint32_t)src[1] << 16) | ((uint32_t)src[2] << 8) | ((uint32_t)src[3] << 0); @@ -254,6 +262,10 @@ static const uint8_t * avifJPEGFindSubstr(const uint8_t * str, size_t strLength, #define AVIF_JPEG_EXTENDED_XMP_TAG "http://ns.adobe.com/xmp/extension/\0" #define AVIF_JPEG_EXTENDED_XMP_TAG_LENGTH 35 +// MPF tag (Multi-Picture Format) +#define AVIF_JPEG_MPF_HEADER "MPF\0" +#define AVIF_JPEG_MPF_HEADER_LENGTH 4 + // One way of storing the Extended XMP GUID (generated by a camera for example). #define AVIF_JPEG_XMP_NOTE_TAG "xmpNote:HasExtendedXMP=\"" #define AVIF_JPEG_XMP_NOTE_TAG_LENGTH 24 @@ -266,6 +278,578 @@ static const uint8_t * avifJPEGFindSubstr(const uint8_t * str, size_t strLength, // Offset in APP1 segment (skip tag + guid + size + offset). #define AVIF_JPEG_OFFSET_TILL_EXTENDED_XMP (AVIF_JPEG_EXTENDED_XMP_TAG_LENGTH + AVIF_JPEG_EXTENDED_XMP_GUID_LENGTH + 4 + 4) +#define AVIF_CHECK(A) \ + do { \ + if (!(A)) \ + return AVIF_FALSE; \ + } while (0) + +#if defined(AVIF_ENABLE_EXPERIMENTAL_JPEG_GAIN_MAP_CONVERSION) + +// Reads a 4-byte unsigned integer in little-endian format from the raw bitstream src. +static uint32_t avifJPEGReadUint32LittleEndian(const uint8_t * src) +{ + return ((uint32_t)src[0] << 0) | ((uint32_t)src[1] << 8) | ((uint32_t)src[2] << 16) | ((uint32_t)src[3] << 24); +} + +// Reads a 2-byte unsigned integer in big-endian format from the raw bitstream src. +static uint16_t avifJPEGReadUint16BigEndian(const uint8_t * src) +{ + return ((uint32_t)src[0] << 8) | ((uint32_t)src[1] << 0); +} + +// Reads a 2-byte unsigned integer in little-endian format from the raw bitstream src. +static uint16_t avifJPEGReadUint16LittleEndian(const uint8_t * src) +{ + return ((uint32_t)src[0] << 0) | ((uint32_t)src[1] << 8); +} + +// Reads 'numBytes' at 'offset', stores them in 'bytes' and increases 'offset'. +static avifBool avifJPEGReadBytes(const avifROData * data, uint8_t * bytes, uint32_t * offset, uint32_t numBytes) +{ + if (data->size < (*offset + numBytes)) { + return AVIF_FALSE; + } + memcpy(bytes, &data->data[*offset], numBytes); + *offset += numBytes; + return AVIF_TRUE; +} + +static avifBool avifJPEGReadU32(const avifROData * data, uint32_t * v, uint32_t * offset, avifBool isBigEndian) +{ + uint8_t bytes[4]; + AVIF_CHECK(avifJPEGReadBytes(data, bytes, offset, 4)); + *v = isBigEndian ? avifJPEGReadUint32BigEndian(bytes) : avifJPEGReadUint32LittleEndian(bytes); + return AVIF_TRUE; +} + +static avifBool avifJPEGReadU16(const avifROData * data, uint16_t * v, uint32_t * offset, avifBool isBigEndian) +{ + uint8_t bytes[2]; + AVIF_CHECK(avifJPEGReadBytes(data, bytes, offset, 2)); + *v = isBigEndian ? avifJPEGReadUint16BigEndian(bytes) : avifJPEGReadUint16LittleEndian(bytes); + return AVIF_TRUE; +} + +static avifBool avifJPEGReadInternal(FILE * f, + const char * inputFilename, + avifImage * avif, + avifPixelFormat requestedFormat, + uint32_t requestedDepth, + avifChromaDownsampling chromaDownsampling, + avifBool ignoreColorProfile, + avifBool ignoreExif, + avifBool ignoreXMP, + avifBool ignoreGainMap); + +// Arbitrary max number of jpeg segments to parse before giving up. +#define MAX_JPEG_SEGMENTS 100 + +// Finds the offset of the first MPF segment. Returns AVIF_TRUE if it was found. +static avifBool avifJPEGFindMpfSegmentOffset(FILE * f, uint32_t * mpfOffset) +{ + const long oldOffset = ftell(f); + + uint32_t offset = 2; // Skip the 2 byte SOI (Start Of Image) marker. + if (fseek(f, offset, SEEK_SET) != 0) { + return AVIF_FALSE; + } + + uint8_t buffer[4]; + int numSegments = 0; + while (numSegments < MAX_JPEG_SEGMENTS) { + ++numSegments; + // Read the APP segment marker (2 bytes) and the segment size (2 bytes). + if (fread(buffer, 1, 4, f) != 4) { + fseek(f, oldOffset, SEEK_SET); + return AVIF_FALSE; // End of the file reached. + } + offset += 4; + + // Total APP segment byte count, including the byte count value (2 bytes), but excluding the 2 byte APP marker itself. + const uint16_t segmentLength = avifJPEGReadUint16BigEndian(&buffer[2]); + if (segmentLength < 2) { + fseek(f, oldOffset, SEEK_SET); + return AVIF_FALSE; // Invalid length. + } else if (segmentLength < 2 + AVIF_JPEG_MPF_HEADER_LENGTH) { + // Cannot be an MPF segment, skip to the next segment. + offset += segmentLength - 2; + if (fseek(f, offset, SEEK_SET) != 0) { + fseek(f, oldOffset, SEEK_SET); + return AVIF_FALSE; + } + continue; + } + + uint8_t identifier[AVIF_JPEG_MPF_HEADER_LENGTH]; + if (fread(identifier, 1, AVIF_JPEG_MPF_HEADER_LENGTH, f) != AVIF_JPEG_MPF_HEADER_LENGTH) { + fseek(f, oldOffset, SEEK_SET); + return AVIF_FALSE; // End of the file reached. + } + offset += AVIF_JPEG_MPF_HEADER_LENGTH; + + if (buffer[1] == (JPEG_APP0 + 2) && !memcmp(identifier, AVIF_JPEG_MPF_HEADER, AVIF_JPEG_MPF_HEADER_LENGTH)) { + // MPF segment found. + *mpfOffset = offset; + fseek(f, oldOffset, SEEK_SET); + return AVIF_TRUE; + } + + // Skip to the next segment. + offset += segmentLength - 2 - AVIF_JPEG_MPF_HEADER_LENGTH; + if (fseek(f, offset, SEEK_SET) != 0) { + fseek(f, oldOffset, SEEK_SET); + return AVIF_FALSE; + } + } + return AVIF_FALSE; +} + +// Searches for a node called 'nameSpace:nodeName' in the children (or descendants if 'recursive' is set) of 'parentNode'. +// Returns the first such node found (in depth first search). Returns NULL if no such node is found. +static const xmlNode * avifJPEGFindXMLNodeByName(const xmlNode * parentNode, const char * nameSpace, const char * nodeName, avifBool recursive) +{ + if (parentNode == NULL) { + return NULL; + } + for (const xmlNode * node = parentNode->children; node != NULL; node = node->next) { + if (node->ns != NULL && !xmlStrcmp(node->ns->href, (const xmlChar *)nameSpace) && + !xmlStrcmp(node->name, (const xmlChar *)nodeName)) { + return node; + } else if (recursive) { + const xmlNode * descendantNode = avifJPEGFindXMLNodeByName(node, nameSpace, nodeName, recursive); + if (descendantNode != NULL) { + return descendantNode; + } + } + } + return NULL; +} + +#define XML_NAME_SPACE_GAIN_MAP "http://ns.adobe.com/hdr-gain-map/1.0/" +#define XML_NAME_SPACE_RDF "http://www.w3.org/1999/02/22-rdf-syntax-ns#" + +// Finds an 'rdf:Description' node containing a gain map version attribute (hdrgm:Version="1.0"). +// Returns NULL if not found. +static const xmlNode * avifJPEGFindGainMapXMPNode(const xmlNode * rootNode) +{ + // See XMP specification https://github.com/adobe/XMP-Toolkit-SDK/blob/main/docs/XMPSpecificationPart1.pdf + // ISO 16684-1:2011 7.1 "For this serialization, a single XMP packet shall be serialized using a single rdf:RDF XML element." + // 7.3 "Other XML elements may appear around the rdf:RDF element." + const xmlNode * rdfNode = avifJPEGFindXMLNodeByName(rootNode, XML_NAME_SPACE_RDF, "RDF", /*recursive=*/AVIF_TRUE); + if (rdfNode == NULL) { + return NULL; + } + for (const xmlNode * node = rdfNode->children; node != NULL; node = node->next) { + // Loop through rdf:Description children. + // 7.4 "A single XMP packet shall be serialized using a single rdf:RDF XML element. The rdf:RDF element content + // shall consist of only zero or more rdf:Description elements." + if (node->ns && !xmlStrcmp(node->ns->href, (const xmlChar *)XML_NAME_SPACE_RDF) && + !xmlStrcmp(node->name, (const xmlChar *)"Description")) { + // Look for the gain map version attribute: hdrgm:Version="1.0" + for (xmlAttr * prop = node->properties; prop != NULL; prop = prop->next) { + if (prop->ns && !xmlStrcmp(prop->ns->href, (const xmlChar *)XML_NAME_SPACE_GAIN_MAP) && + !xmlStrcmp(prop->name, (const xmlChar *)"Version") && prop->children != NULL && + !xmlStrcmp(prop->children->content, (const xmlChar *)"1.0")) { + return node; + } + } + } + } + return NULL; +} + +// Use XML_PARSE_RECOVER and XML_PARSE_NOERROR to avoid failing/printing errors for invalid XML. +// In particular, if the jpeg files contains extended XMP, avifJPEGReadInternal simply concatenates it to +// standard XMP, which is not a valid XML tree. +// TODO(maryla): better handle extended XMP. If the gain map metadata is in the extended part, +// the current code probably won't detect it. +#define LIBXML2_XML_PARSING_FLAGS (XML_PARSE_RECOVER | XML_PARSE_NOERROR) + +// Returns true if there is an 'rdf:Description' node containing a gain map version attribute (hdrgm:Version="1.0"). +// On the main image, this signals that the file also contains a gain map. +// On a subsequent image, this signals that it is a gain map. +static avifBool avifJPEGHasGainMapXMPNode(const uint8_t * xmpData, size_t xmpSize) +{ + xmlDoc * document = xmlReadMemory((const char *)xmpData, (int)xmpSize, NULL, NULL, LIBXML2_XML_PARSING_FLAGS); + if (document == NULL) { + return AVIF_FALSE; // Out of memory? + } + const xmlNode * rootNode = xmlDocGetRootElement(document); + const xmlNode * node = avifJPEGFindGainMapXMPNode(rootNode); + const avifBool found = node != NULL; + xmlFreeDoc(document); + return found; +} + +// Finds the value of a gain map metadata property, that can be either stored as an attribute of 'descriptionNode' +// (which should point to a node) or as a child node. +// 'maxValues' is the maximum number of expected values, and the size of the 'values' array. 'numValues' is set to the number +// of values actually found (which may be smaller or larger, but only up to 'maxValues' are stored in 'values'). +// Returns AVIF_TRUE if the property was found. +static avifBool avifJPEGFindGainMapProperty(const xmlNode * descriptionNode, + const char * propertyName, + uint32_t maxValues, + const char * values[], + uint32_t * numValues) +{ + *numValues = 0; + + // Search attributes. + for (xmlAttr * prop = descriptionNode->properties; prop != NULL; prop = prop->next) { + if (prop->ns && !xmlStrcmp(prop->ns->href, (const xmlChar *)XML_NAME_SPACE_GAIN_MAP) && + !xmlStrcmp(prop->name, (const xmlChar *)propertyName) && prop->children != NULL && prop->children->content != NULL) { + // Properties should have just one child containing the property's value + // (in fact the 'children' field is documented as "the value of the property"). + values[0] = (const char *)prop->children->content; + *numValues = 1; + return AVIF_TRUE; + } + } + + // Search child nodes. + for (const xmlNode * node = descriptionNode->children; node != NULL; node = node->next) { + if (node->ns && !xmlStrcmp(node->ns->href, (const xmlChar *)XML_NAME_SPACE_GAIN_MAP) && + !xmlStrcmp(node->name, (const xmlChar *)propertyName) && node->children) { + // Multiple values can be specified with a Seq tag: value1value2... + const xmlNode * seq = avifJPEGFindXMLNodeByName(node, XML_NAME_SPACE_RDF, "Seq", /*recursive=*/AVIF_FALSE); + if (seq) { + for (xmlNode * seqChild = seq->children; seqChild; seqChild = seqChild->next) { + if (!xmlStrcmp(seqChild->name, (const xmlChar *)"li") && seqChild->children != NULL && + seqChild->children->content != NULL) { + if (*numValues < maxValues) { + values[*numValues] = (const char *)seqChild->children->content; + } + ++(*numValues); + } + } + return *numValues > 0 ? AVIF_TRUE : AVIF_FALSE; + } else if (node->children->next == NULL && node->children->type == XML_TEXT_NODE) { // Only one child and it's text. + values[0] = (const char *)node->children->content; + *numValues = 1; + return AVIF_TRUE; + } + // We found a tag for this property but no valid content. + return AVIF_FALSE; + } + } + + return AVIF_FALSE; // Property not found. +} + +// Up to 3 values per property (one for each RGB channel). +#define GAIN_MAP_PROPERTY_MAX_VALUES 3 + +// Looks for a given gain map property's double value(s), and if found, stores them in 'values'. +// The 'values' array should have size at least 'numDoubles', and should be initialized with default +// values for this property, since the array will be left untouched if the property is not found. +// Returns AVIF_TRUE if the property was successfully parsed, or if it was not found, since all properties +// are optional. Returns AVIF_FALSE in case of error (invalid metadata XMP). +static avifBool avifJPEGFindGainMapPropertyDoubles(const xmlNode * descriptionNode, + const char * propertyName, + avifBool log2Encoded, + double * values, + uint32_t numDoubles) +{ + assert(numDoubles <= GAIN_MAP_PROPERTY_MAX_VALUES); + const char * textValues[GAIN_MAP_PROPERTY_MAX_VALUES]; + uint32_t numValues; + if (!avifJPEGFindGainMapProperty(descriptionNode, propertyName, /*maxValues=*/numDoubles, &textValues[0], &numValues)) { + return AVIF_TRUE; // Property was not found, but it's not an error since they're optional. + } + if (numValues != 1 && numValues != numDoubles) { + return AVIF_FALSE; // Invalid, we expect either 1 or exactly numDoubles values. + } + for (uint32_t i = 0; i < numDoubles; ++i) { + if (i >= numValues) { + // If there is only 1 value, it's copied into the rest of the array. + values[i] = values[i - 1]; + } else { + double valueD; + int charsRead; + if (sscanf(textValues[i], "%lf%n", &valueD, &charsRead) < 1) { + return AVIF_FALSE; // Was not able to parse the full string value as a double. + } + // Make sure that remaining characters (if any) are only whitespace. + const int len = (int)strlen(textValues[i]); + while (charsRead < len) { + if (!isspace(textValues[i][charsRead])) { + return AVIF_FALSE; // Invalid character. + } + ++charsRead; + } + if (log2Encoded) { + valueD = exp2(valueD); + } + values[i] = valueD; + } + } + + return AVIF_TRUE; +} + +// Parses gain map metadata from XMP. +// See https://helpx.adobe.com/camera-raw/using/gain-map.html +// Returns AVIF_TRUE if the gain map metadata was successfully read. +static avifBool avifJPEGParseGainMapXMPProperties(const xmlNode * rootNode, avifGainMapMetadata * metadata) +{ + const xmlNode * descNode = avifJPEGFindGainMapXMPNode(rootNode); + if (descNode == NULL) { + return AVIF_FALSE; + } + + avifGainMapMetadataDouble metadataDouble; + // Set default values from Adobe's spec. + metadataDouble.baseRenditionIsHDR = AVIF_FALSE; + metadataDouble.hdrCapacityMin = 1.0; // exp2(0) = 1 + metadataDouble.hdrCapacityMax = exp2(1); + for (int i = 0; i < 3; ++i) { + metadataDouble.gainMapMin[i] = 1.0; // exp2(0) = 1 + metadataDouble.gainMapMax[i] = exp2(1.0); + metadataDouble.offsetSdr[i] = 1.0 / 64.0; + metadataDouble.offsetHdr[i] = 1.0 / 64.0; + metadataDouble.gainMapGamma[i] = 1.0; + } + + uint32_t numValues; + const char * baseRenditionIsHDR; + if (avifJPEGFindGainMapProperty(descNode, "BaseRenditionIsHDR", /*maxValues=*/1, &baseRenditionIsHDR, &numValues)) { + if (!strcmp(baseRenditionIsHDR, "True")) { + metadata->baseRenditionIsHDR = AVIF_TRUE; + } else if (!strcmp(baseRenditionIsHDR, "False")) { + metadata->baseRenditionIsHDR = AVIF_FALSE; + } else { + return AVIF_FALSE; // Unexpected value. + } + } + + AVIF_CHECK(avifJPEGFindGainMapPropertyDoubles(descNode, + "HDRCapacityMin", + /*log2Encoded=*/AVIF_TRUE, + &metadataDouble.hdrCapacityMin, + /*numDoubles=*/1)); + AVIF_CHECK(avifJPEGFindGainMapPropertyDoubles(descNode, + "HDRCapacityMax", + /*log2Encoded=*/AVIF_TRUE, + &metadataDouble.hdrCapacityMax, + /*numDoubles=*/1)); + AVIF_CHECK(avifJPEGFindGainMapPropertyDoubles(descNode, "OffsetSDR", /*log2Encoded=*/AVIF_FALSE, metadataDouble.offsetSdr, /*numDoubles=*/3)); + AVIF_CHECK(avifJPEGFindGainMapPropertyDoubles(descNode, "OffsetHDR", /*log2Encoded=*/AVIF_FALSE, metadataDouble.offsetHdr, /*numDoubles=*/3)); + AVIF_CHECK(avifJPEGFindGainMapPropertyDoubles(descNode, + "GainMapMin", + /*log2Encoded=*/AVIF_TRUE, + metadataDouble.gainMapMin, + /*numDoubles=*/3)); + AVIF_CHECK(avifJPEGFindGainMapPropertyDoubles(descNode, + "GainMapMax", + /*log2Encoded=*/AVIF_TRUE, + metadataDouble.gainMapMax, + /*numDoubles=*/3)); + AVIF_CHECK(avifJPEGFindGainMapPropertyDoubles(descNode, + "Gamma", + /*log2Encoded=*/AVIF_FALSE, + metadataDouble.gainMapGamma, + /*numDoubles=*/3)); + // Change gammma to 1.0/gamma (we are using a different convention from the metadata XMP). + for (int i = 0; i < 3; ++i) { + if (metadataDouble.gainMapGamma[i] == 0) { + return AVIF_FALSE; // Invalid value, the spec says that gamma should be > 0 + } + metadataDouble.gainMapGamma[i] = 1.0 / metadataDouble.gainMapGamma[i]; + } + // Should have HDRCapacityMax > HDRCapacityMin. + if (metadataDouble.hdrCapacityMax <= (double)metadataDouble.hdrCapacityMin) { + return AVIF_FALSE; + } + // Should have GainMapMax >= GainMapMin. + for (int i = 0; i < 3; ++i) { + if (metadataDouble.gainMapMax[i] < (double)metadataDouble.gainMapMin[i]) { + return AVIF_FALSE; + } + } + + AVIF_CHECK(avifGainMapMetadataDoubleToFractions(metadata, &metadataDouble)); + + return AVIF_TRUE; +} + +// Parses gain map metadata from an XMP payload. +// Returns AVIF_TRUE if the gain map metadata was successfully read. +static avifBool avifJPEGParseGainMapXMP(const uint8_t * xmpData, size_t xmpSize, avifGainMapMetadata * metadata) +{ + xmlDoc * document = xmlReadMemory((const char *)xmpData, (int)xmpSize, NULL, NULL, LIBXML2_XML_PARSING_FLAGS); + xmlNode * rootNode = xmlDocGetRootElement(document); + const avifBool res = avifJPEGParseGainMapXMPProperties(rootNode, metadata); + xmlFreeDoc(document); + return res; +} + +// Parses an MPF (Multi-Picture File) JPEG metadata segment to find the location of other +// images, and decodes the gain map image (as determined by having gain map XMP metadata) into 'avif'. +// See CIPA DC-007-Translation-2021 Multi-Picture Format at https://www.cipa.jp/e/std/std-sec.html +// and https://helpx.adobe.com/camera-raw/using/gain-map.html in particular Figures 1 to 6. +// Returns AVIF_FALSE if no gain map was found. +static avifBool avifJPEGExtractGainMapImageFromMpf(FILE * f, + const char * inputFilename, + const avifROData * segmentData, + avifImage * avif, + avifPixelFormat requestedFormat, + uint32_t requestedDepth, + avifChromaDownsampling chromaDownsampling) +{ + uint32_t offset = 0; + + const uint8_t littleEndian[4] = { 0x49, 0x49, 0x2A, 0x00 }; // "II*\0" + const uint8_t bigEndian[4] = { 0x4D, 0x4D, 0x00, 0x2A }; // "MM\0*" + + uint8_t endiannessTag[4]; + AVIF_CHECK(avifJPEGReadBytes(segmentData, endiannessTag, &offset, 4)); + + avifBool isBigEndian; + if (!memcmp(endiannessTag, bigEndian, 4)) { + isBigEndian = AVIF_TRUE; + } else if (!memcmp(endiannessTag, littleEndian, 4)) { + isBigEndian = AVIF_FALSE; + } else { + return AVIF_FALSE; // Invalid endianness tag. + } + + uint32_t offsetToFirstIfd; + AVIF_CHECK(avifJPEGReadU32(segmentData, &offsetToFirstIfd, &offset, isBigEndian)); + if (offsetToFirstIfd < offset) { + return AVIF_FALSE; + } + offset = offsetToFirstIfd; + + // Read MP (Multi-Picture) tags. + uint16_t mpTagCount; + AVIF_CHECK(avifJPEGReadU16(segmentData, &mpTagCount, &offset, isBigEndian)); + + // See also https://www.media.mit.edu/pia/Research/deepview/exif.html + uint32_t numImages = 0; + uint32_t mpEntryOffset = 0; + for (int mpTagIdx = 0; mpTagIdx < mpTagCount; ++mpTagIdx) { + uint16_t tagId; + AVIF_CHECK(avifJPEGReadU16(segmentData, &tagId, &offset, isBigEndian)); + offset += 2; // Skip data format. + offset += 4; // Skip num components. + uint8_t valueBytes[4]; + AVIF_CHECK(avifJPEGReadBytes(segmentData, valueBytes, &offset, 4)); + const uint32_t value = isBigEndian ? avifJPEGReadUint32BigEndian(valueBytes) : avifJPEGReadUint32LittleEndian(valueBytes); + + switch (tagId) { // MPFVersion + case 45056: // MPFVersion + if (memcmp(valueBytes, "0100", 4)) { + // Unexpected version. + return AVIF_FALSE; + } + break; + case 45057: // NumberOfImages + numImages = value; + break; + case 45058: // MPEntry + mpEntryOffset = value; + break; + case 45059: // ImageUIDList, unused + case 45060: // TotalFrames, unused + default: + break; + } + } + if (numImages < 2 || mpEntryOffset < offset) { + return AVIF_FALSE; + } + offset = mpEntryOffset; + + uint32_t mpfSegmentOffset; + AVIF_CHECK(avifJPEGFindMpfSegmentOffset(f, &mpfSegmentOffset)); + + for (uint32_t imageIdx = 0; imageIdx < numImages; ++imageIdx) { + offset += 4; // Skip "Individual Image Attribute" + uint32_t imageSize; + AVIF_CHECK(avifJPEGReadU32(segmentData, &imageSize, &offset, isBigEndian)); + uint32_t imageDataOffset; + AVIF_CHECK(avifJPEGReadU32(segmentData, &imageDataOffset, &offset, isBigEndian)); + + offset += 4; // Skip "Dependent image Entry Number" (2 + 2 bytes) + if (imageDataOffset == 0) { + // 0 is a special value which indicates the first image. + // Assume the first image cannot be the gain map and skip it. + continue; + } + + // Offsets are relative to the start of the MPF segment. Make them absolute. + imageDataOffset += mpfSegmentOffset; + if (fseek(f, imageDataOffset, SEEK_SET) != 0) { + return AVIF_FALSE; + } + // Read the image and check its XMP to see if it's a gain map. + // NOTE we decode all additional images until a gain map is found, even if some might not + // be gain maps. This could be fixed by having a helper function to get just the XMP without + // decoding the whole image. + if (!avifJPEGReadInternal(f, + inputFilename, + avif, + requestedFormat, + requestedDepth, + chromaDownsampling, + /*ignoreColorProfile=*/AVIF_TRUE, + /*ignoreExif=*/AVIF_TRUE, + /*ignoreXMP=*/AVIF_FALSE, + /*ignoreGainMap=*/AVIF_TRUE)) { + continue; + } + if (avifJPEGHasGainMapXMPNode(avif->xmp.data, avif->xmp.size)) { + return AVIF_TRUE; + } + } + + return AVIF_FALSE; +} + +// Tries to find and decode a gain map image and its metadata. +// Looks for an MPF (Multi-Picture Format) segment then loops through the linked images to see +// if one of them has gain map XMP metadata. +// See CIPA DC-007-Translation-2021 Multi-Picture Format at https://www.cipa.jp/e/std/std-sec.html +// and https://helpx.adobe.com/camera-raw/using/gain-map.html +// Returns AVIF_TRUE if a gain map was found. +static avifBool avifJPEGExtractGainMapImage(FILE * f, + const char * inputFilename, + struct jpeg_decompress_struct * cinfo, + avifGainMap * gainMap, + avifPixelFormat requestedFormat, + uint32_t requestedDepth, + avifChromaDownsampling chromaDownsampling) +{ + const avifROData tagMpf = { (const uint8_t *)AVIF_JPEG_MPF_HEADER, AVIF_JPEG_MPF_HEADER_LENGTH }; + for (jpeg_saved_marker_ptr marker = cinfo->marker_list; marker != NULL; marker = marker->next) { + // Note we assume there is only one MPF segment and only look at the first one. + // Otherwise avifJPEGFindMpfSegmentOffset() would have to be modified to take the index of the + // MPF segment whose offset to return. + if ((marker->marker == (JPEG_APP0 + 2)) && (marker->data_length > tagMpf.size) && + !memcmp(marker->data, tagMpf.data, tagMpf.size)) { + avifImage * image = avifImageCreateEmpty(); + + const avifROData mpfData = { (const uint8_t *)marker->data + tagMpf.size, marker->data_length - tagMpf.size }; + if (!avifJPEGExtractGainMapImageFromMpf(f, inputFilename, &mpfData, image, requestedFormat, requestedDepth, chromaDownsampling)) { + fprintf(stderr, "Note: XMP metadata indicated the presence of a gain map, but it could not be found or decoded\n"); + avifImageDestroy(image); + return AVIF_FALSE; + } + if (!avifJPEGParseGainMapXMP(image->xmp.data, image->xmp.size, &gainMap->metadata)) { + fprintf(stderr, "Warning: failed to parse gain map metadata\n"); + avifImageDestroy(image); + return AVIF_FALSE; + } + + gainMap->image = image; + return AVIF_TRUE; + } + } + return AVIF_FALSE; +} +#endif // AVIF_ENABLE_EXPERIMENTAL_JPEG_GAIN_MAP_CONVERSION + // Note on setjmp() and volatile variables: // // K & R, The C Programming Language 2nd Ed, p. 254 says: @@ -280,14 +864,16 @@ static const uint8_t * avifJPEGFindSubstr(const uint8_t * str, size_t strLength, // longjmp. But GCC's -Wclobbered warning may have trouble figuring that out, so // we preemptively declare it as volatile. -avifBool avifJPEGRead(const char * inputFilename, - avifImage * avif, - avifPixelFormat requestedFormat, - uint32_t requestedDepth, - avifChromaDownsampling chromaDownsampling, - avifBool ignoreColorProfile, - avifBool ignoreExif, - avifBool ignoreXMP) +static avifBool avifJPEGReadInternal(FILE * f, + const char * inputFilename, + avifImage * avif, + avifPixelFormat requestedFormat, + uint32_t requestedDepth, + avifChromaDownsampling chromaDownsampling, + avifBool ignoreColorProfile, + avifBool ignoreExif, + avifBool ignoreXMP, + avifBool ignoreGainMap) { volatile avifBool ret = AVIF_FALSE; uint8_t * volatile iccData = NULL; @@ -300,12 +886,6 @@ avifBool avifJPEGRead(const char * inputFilename, // Each byte set to 0 is a missing byte. Each byte set to 1 was read and copied to totalXMP. avifRWData extendedXMPReadBytes = { NULL, 0 }; - FILE * f = fopen(inputFilename, "rb"); - if (!f) { - fprintf(stderr, "Can't open JPEG file for read: %s\n", inputFilename); - return ret; - } - struct my_error_mgr jerr; struct jpeg_decompress_struct cinfo; cinfo.err = jpeg_std_error(&jerr.pub); @@ -316,9 +896,16 @@ avifBool avifJPEGRead(const char * inputFilename, jpeg_create_decompress(&cinfo); - if (!ignoreExif || !ignoreXMP) { - jpeg_save_markers(&cinfo, JPEG_APP0 + 1, /*length_limit=*/0xFFFF); // Exif/XMP + // See also https://exiftool.org/TagNames/JPEG.html for the meaning of various APP segments. + if (!ignoreExif || !ignoreXMP || !ignoreGainMap) { + // Keep APP1 blocks, for Exif and XMP. + jpeg_save_markers(&cinfo, JPEG_APP0 + 1, /*length_limit=*/0xFFFF); + } + if (!ignoreGainMap) { + // Keep APP2 blocks, for obtaining ICC and MPF data. + jpeg_save_markers(&cinfo, JPEG_APP0 + 2, /*length_limit=*/0xFFFF); } + if (!ignoreColorProfile) { setup_read_icc_profile(&cinfo); } @@ -429,7 +1016,12 @@ avifBool avifJPEGRead(const char * inputFilename, } } } - if (!ignoreXMP) { + + avifBool readXMP = !ignoreXMP; +#if defined(AVIF_ENABLE_EXPERIMENTAL_JPEG_GAIN_MAP_CONVERSION) + readXMP = readXMP || !ignoreGainMap; // Gain map metadata is in XMP. +#endif + if (readXMP) { const uint8_t * standardXMPData = NULL; uint32_t standardXMPSize = 0; // At most 64kB as defined by Adobe XMP Specification Part 3. for (jpeg_saved_marker_ptr marker = cinfo.marker_list; marker != NULL; marker = marker->next) { @@ -568,11 +1160,23 @@ avifBool avifJPEGRead(const char * inputFilename, } avifImageFixXMP(avif); // Remove one trailing null character if any. } + +#if defined(AVIF_ENABLE_EXPERIMENTAL_JPEG_GAIN_MAP_CONVERSION) + // The primary XMP block (for the main image) must contain a node with an hdrgm:Version field if and only if a gain map is present. + if (!ignoreGainMap && avifJPEGHasGainMapXMPNode(avif->xmp.data, avif->xmp.size)) { + // Ignore the return value: continue even if we fail to find/parse/decode the gain map. + avifJPEGExtractGainMapImage(f, inputFilename, &cinfo, &avif->gainMap, requestedFormat, requestedDepth, chromaDownsampling); + } + + if (avif->xmp.size > 0 && ignoreXMP) { + // Clear XMP in case we read it for something else (like gain map). + avifImageSetMetadataXMP(avif, NULL, 0); + } +#endif // AVIF_ENABLE_EXPERIMENTAL_JPEG_GAIN_MAP_CONVERSION jpeg_finish_decompress(&cinfo); ret = AVIF_TRUE; cleanup: jpeg_destroy_decompress(&cinfo); - fclose(f); free(iccData); avifRGBImageFreePixels(&rgb); avifRWDataFree(&totalXMP); @@ -580,6 +1184,27 @@ avifBool avifJPEGRead(const char * inputFilename, return ret; } +avifBool avifJPEGRead(const char * inputFilename, + avifImage * avif, + avifPixelFormat requestedFormat, + uint32_t requestedDepth, + avifChromaDownsampling chromaDownsampling, + avifBool ignoreColorProfile, + avifBool ignoreExif, + avifBool ignoreXMP, + avifBool ignoreGainMap) +{ + FILE * f = fopen(inputFilename, "rb"); + if (!f) { + fprintf(stderr, "Can't open JPEG file for read: %s\n", inputFilename); + return AVIF_FALSE; + } + const avifBool res = + avifJPEGReadInternal(f, inputFilename, avif, requestedFormat, requestedDepth, chromaDownsampling, ignoreColorProfile, ignoreExif, ignoreXMP, ignoreGainMap); + fclose(f); + return res; +} + avifBool avifJPEGWrite(const char * outputFilename, const avifImage * avif, int jpegQuality, avifChromaUpsampling chromaUpsampling) { avifBool ret = AVIF_FALSE; diff --git a/apps/shared/avifjpeg.h b/apps/shared/avifjpeg.h index dce833b3b0..00e622b51d 100644 --- a/apps/shared/avifjpeg.h +++ b/apps/shared/avifjpeg.h @@ -10,6 +10,11 @@ extern "C" { #endif +// Decodes the jpeg file at path 'inputFilename' into 'avif'. +// 'ignoreGainMap' is only relevant for jpeg files that have a gain map +// and only if AVIF_ENABLE_EXPERIMENTAL_JPEG_GAIN_MAP_CONVERSION is ON +// (requires AVIF_ENABLE_EXPERIMENTAL_GAIN_MAP and libxml2). Otherwise +// it has no effect. avifBool avifJPEGRead(const char * inputFilename, avifImage * avif, avifPixelFormat requestedFormat, @@ -17,7 +22,8 @@ avifBool avifJPEGRead(const char * inputFilename, avifChromaDownsampling chromaDownsampling, avifBool ignoreColorProfile, avifBool ignoreExif, - avifBool ignoreXMP); + avifBool ignoreXMP, + avifBool ignoreGainMap); avifBool avifJPEGWrite(const char * outputFilename, const avifImage * avif, int jpegQuality, avifChromaUpsampling chromaUpsampling); #ifdef __cplusplus diff --git a/apps/shared/avifutil.c b/apps/shared/avifutil.c index 91aae6fb31..7a32fc7a28 100644 --- a/apps/shared/avifutil.c +++ b/apps/shared/avifutil.c @@ -247,6 +247,7 @@ avifAppFileFormat avifReadImage(const char * filename, avifBool ignoreExif, avifBool ignoreXMP, avifBool allowChangingCicp, + avifBool ignoreGainMap, avifImage * image, uint32_t * outDepth, avifAppSourceTiming * sourceTiming, @@ -261,7 +262,7 @@ avifAppFileFormat avifReadImage(const char * filename, *outDepth = image->depth; } } else if (format == AVIF_APP_FILE_FORMAT_JPEG) { - if (!avifJPEGRead(filename, image, requestedFormat, requestedDepth, chromaDownsampling, ignoreColorProfile, ignoreExif, ignoreXMP)) { + if (!avifJPEGRead(filename, image, requestedFormat, requestedDepth, chromaDownsampling, ignoreColorProfile, ignoreExif, ignoreXMP, ignoreGainMap)) { return AVIF_APP_FILE_FORMAT_UNKNOWN; } if (outDepth) { diff --git a/apps/shared/avifutil.h b/apps/shared/avifutil.h index d6cf65ffa4..9a39b7367b 100644 --- a/apps/shared/avifutil.h +++ b/apps/shared/avifutil.h @@ -60,6 +60,10 @@ struct y4mFrameIterator; // Reads an image from a file with the requested format and depth. // In case of a y4m file, sourceTiming and frameIter can be set. // Returns AVIF_APP_FILE_FORMAT_UNKNOWN in case of error. +// 'ignoreGainMap' is only relevant for jpeg files that have a gain map +// and only if AVIF_ENABLE_EXPERIMENTAL_JPEG_GAIN_MAP_CONVERSION is ON +// (requires AVIF_ENABLE_EXPERIMENTAL_GAIN_MAP and libxml2). Otherwise +// it has no effect. avifAppFileFormat avifReadImage(const char * filename, avifPixelFormat requestedFormat, int requestedDepth, @@ -68,6 +72,7 @@ avifAppFileFormat avifReadImage(const char * filename, avifBool ignoreExif, avifBool ignoreXMP, avifBool allowChangingCicp, + avifBool ignoreGainMap, avifImage * image, uint32_t * outDepth, avifAppSourceTiming * sourceTiming, diff --git a/ext/libxml2.cmd b/ext/libxml2.cmd new file mode 100755 index 0000000000..ea39c7e3e7 --- /dev/null +++ b/ext/libxml2.cmd @@ -0,0 +1,14 @@ +: # If you want to use a local build of libxml2, you must clone the libxml2 repo in this directory first, then enable CMake's AVIF_LOCAL_LIBXML2 option. +: # The git tag below is known to work, and will occasionally be updated. Feel free to use a more recent commit. + +: # The odd choice of comment style in this file is to try to share this script between *nix and win32. + +: # libxml2 is released under the MIT License. + +git clone -b v2.11.5 --depth 1 https://gitlab.gnome.org/GNOME/libxml2.git + +mkdir -p libxml2/build.libavif +cmake libxml2 -B libxml2/build.libavif/ -G Ninja -DBUILD_SHARED_LIBS=OFF -DCMAKE_INSTALL_PREFIX=libxml2/install.libavif \ + -DLIBXML2_WITH_PYTHON=OFF -DLIBXML2_WITH_ZLIB=OFF -DLIBXML2_WITH_LZMA=OFF +ninja -C libxml2/build.libavif +ninja -C libxml2/build.libavif install diff --git a/include/avif/gainmap.h b/include/avif/gainmap.h index b3b0376aee..1e7500faea 100644 --- a/include/avif/gainmap.h +++ b/include/avif/gainmap.h @@ -33,7 +33,7 @@ typedef struct avifGainMapMetadataDouble // Converts a avifGainMapMetadataDouble to avifGainMapMetadata by converting double values // to the closest uint32_t fractions. // Returns AVIF_FALSE if some field values are < 0 or > UINT32_MAX. -avifBool avifGainMapMetadataDoubleToFractions(avifGainMapMetadata * dst, const avifGainMapMetadataDouble * src); +AVIF_API avifBool avifGainMapMetadataDoubleToFractions(avifGainMapMetadata * dst, const avifGainMapMetadataDouble * src); #endif // AVIF_ENABLE_EXPERIMENTAL_GAIN_MAP diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 64b9f1fc69..7f13d95d8f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -134,6 +134,13 @@ if(AVIF_ENABLE_GTEST) target_link_libraries(avifgainmaptest aviftest_helpers ${GTEST_BOTH_LIBRARIES}) target_include_directories(avifgainmaptest PRIVATE ${GTEST_INCLUDE_DIRS}) add_test(NAME avifgainmaptest COMMAND avifgainmaptest) + + if(AVIF_ENABLE_EXPERIMENTAL_JPEG_GAIN_MAP_CONVERSION) + add_executable(avifjpeggainmaptest gtest/avifjpeggainmaptest.cc) + target_link_libraries(avifjpeggainmaptest aviftest_helpers ${GTEST_BOTH_LIBRARIES}) + target_include_directories(avifjpeggainmaptest PRIVATE ${GTEST_INCLUDE_DIRS}) + add_test(NAME avifjpeggainmaptest COMMAND avifjpeggainmaptest ${CMAKE_CURRENT_SOURCE_DIR}/data/) + endif() endif() add_executable(avifgridapitest gtest/avifgridapitest.cc) @@ -343,6 +350,10 @@ if(AVIF_CODEC_AVM) endif() if(AVIF_ENABLE_EXPERIMENTAL_GAIN_MAP) set_tests_properties(avifgainmaptest PROPERTIES DISABLED True) + + if(AVIF_ENABLE_EXPERIMENTAL_JPEG_GAIN_MAP_CONVERSION) + set_tests_properties(avifjpeggainmaptest PROPERTIES DISABLED True) + endif() endif() endif() diff --git a/tests/data/README.md b/tests/data/README.md index 10f0adc3e0..3d5b879268 100644 --- a/tests/data/README.md +++ b/tests/data/README.md @@ -234,6 +234,55 @@ License: [same as libavif](https://github.com/AOMediaCodec/libavif/blob/main/LIC Source: Encoded from `paris_icc_exif_xmp.png` using `avifenc -s 10` at commit ed52c1b. +### File [paris_exif_xmp_gainmap_littleendian.jpg](paris_exif_xmp_gainmap_littleendian.jpg) + +![](paris_exif_xmp_gainmap_littleendian.jpg) + +License: [same as libavif](https://github.com/AOMediaCodec/libavif/blob/main/LICENSE) + +Source: Based on paris_exif_xmp_icc.jpg with ICC stripped out and a gain map added. +Contains a MPF (Multi-Picture Format) segment with metadata pointing to a second image +at offset 33487. The MPF metadata is in little endian order, as signaled by the four bytes +'II*\0'. + +| address | marker | length | data | +|--------:|-------------|-------:|----------------------------------------------| +| 0 | 0xffd8 SOI | | | +| 2 | 0xffe0 APP0 | 16 | `JFIF.....,.,.` | +| 20 | 0xffe1 APP1 | 838 | `Exif..II*......................` | +| 1156 | 0xffe1 APP1 | 2808 | `http://ns.adobe.com/xap/1.0/. + +#include "avif/avif.h" +#include "aviftest_helpers.h" +#include "gtest/gtest.h" + +namespace libavif { +namespace { + +// Used to pass the data folder path to the GoogleTest suites. +const char* data_path = nullptr; + +//------------------------------------------------------------------------------ + +TEST(JpegTest, ReadJpegWithGainMap) { + for (const char* filename : {"paris_exif_xmp_gainmap_bigendian.jpg", + "paris_exif_xmp_gainmap_littleendian.jpg"}) { + SCOPED_TRACE(filename); + + const testutil::AvifImagePtr image = + testutil::ReadImage(data_path, filename, AVIF_PIXEL_FORMAT_YUV444, 8, + AVIF_CHROMA_DOWNSAMPLING_AUTOMATIC, + /*ignore_icc=*/false, /*ignore_exif=*/false, + /*ignore_xmp=*/true, /*allow_changing_cicp=*/true, + /*ignore_gain_map=*/false); + ASSERT_NE(image, nullptr); + ASSERT_NE(image->gainMap.image, nullptr); + EXPECT_EQ(image->gainMap.image->width, 512u); + EXPECT_EQ(image->gainMap.image->height, 384u); + // Since ignore_xmp is true, there should be no XMP, even if it had to + // be read to parse the gain map. + EXPECT_EQ(image->xmp.size, 0u); + + const auto& m = image->gainMap.metadata; + EXPECT_FALSE(m.baseRenditionIsHDR); + const double kEpsilon = 1e-8; + EXPECT_NEAR(static_cast(m.hdrCapacityMinN) / m.hdrCapacityMinD, 1, + kEpsilon); + EXPECT_NEAR(static_cast(m.hdrCapacityMaxN) / m.hdrCapacityMaxD, + exp2(3.5), kEpsilon); + EXPECT_NEAR(static_cast(m.gainMapMinN[0]) / m.gainMapMinD[0], + exp2(0), kEpsilon); + EXPECT_NEAR(static_cast(m.gainMapMinN[1]) / m.gainMapMinD[1], + exp2(0), kEpsilon); + EXPECT_NEAR(static_cast(m.gainMapMinN[2]) / m.gainMapMinD[2], + exp2(0), kEpsilon); + EXPECT_NEAR(static_cast(m.gainMapMaxN[0]) / m.gainMapMaxD[0], + exp2(3.5), kEpsilon); + EXPECT_NEAR(static_cast(m.gainMapMaxN[1]) / m.gainMapMaxD[1], + exp2(3.6), kEpsilon); + EXPECT_NEAR(static_cast(m.gainMapMaxN[2]) / m.gainMapMaxD[2], + exp2(3.7), kEpsilon); + EXPECT_NEAR(static_cast(m.gainMapGammaN[0]) / m.gainMapGammaD[0], + 1.0, kEpsilon); + EXPECT_NEAR(static_cast(m.gainMapGammaN[1]) / m.gainMapGammaD[1], + 1.0, kEpsilon); + EXPECT_NEAR(static_cast(m.gainMapGammaN[2]) / m.gainMapGammaD[2], + 1.0, kEpsilon); + } +} + +TEST(JpegTest, IgnoreGainMap) { + const testutil::AvifImagePtr image = testutil::ReadImage( + data_path, "paris_exif_xmp_gainmap_littleendian.jpg", + AVIF_PIXEL_FORMAT_YUV444, 8, AVIF_CHROMA_DOWNSAMPLING_AUTOMATIC, + /*ignore_icc=*/false, /*ignore_exif=*/false, + /*ignore_xmp=*/false, /*allow_changing_cicp=*/true, + /*ignore_gain_map=*/true); + ASSERT_NE(image, nullptr); + EXPECT_EQ(image->gainMap.image, nullptr); + // Check there is xmp since ignore_xmp is false (just making sure that + // ignore_gain_map=true has no impact on this). + EXPECT_GT(image->xmp.size, 0u); +} + +//------------------------------------------------------------------------------ + +} // namespace +} // namespace libavif + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + if (argc != 2) { + std::cerr << "There must be exactly one argument containing the path to " + "the test data folder" + << std::endl; + return 1; + } + libavif::data_path = argv[1]; + return RUN_ALL_TESTS(); +} diff --git a/tests/gtest/aviflosslesstest.cc b/tests/gtest/aviflosslesstest.cc index c2faefc650..b75a5c650d 100644 --- a/tests/gtest/aviflosslesstest.cc +++ b/tests/gtest/aviflosslesstest.cc @@ -37,7 +37,8 @@ TEST(BasicTest, EncodeDecodeMatrixCoefficients) { /*requestedDepth=*/0, /*chromaDownsampling=*/AVIF_CHROMA_DOWNSAMPLING_AUTOMATIC, /*ignoreColorProfile=*/false, /*ignoreExif=*/false, - /*ignoreXMP=*/false, /*allowChangingCicp=*/true, image.get(), + /*ignoreXMP=*/false, /*allowChangingCicp=*/true, + /*ignoreGainMap=*/true, image.get(), /*outDepth=*/nullptr, /*sourceTiming=*/nullptr, /*frameIter=*/nullptr); #if defined(AVIF_ENABLE_EXPERIMENTAL_YCGCO_R) diff --git a/tests/gtest/avifpng16bittest.cc b/tests/gtest/avifpng16bittest.cc index fb5c5fc9ff..1f31018085 100644 --- a/tests/gtest/avifpng16bittest.cc +++ b/tests/gtest/avifpng16bittest.cc @@ -33,7 +33,8 @@ testutil::AvifImagePtr ReadImageLosslessBitDepth(const std::string& path, AVIF_CHROMA_DOWNSAMPLING_AUTOMATIC, /*ignoreColorProfile=*/true, /*ignoreExif=*/true, /*ignoreXMP=*/true, - /*allowChangingCicp=*/true, image.get(), &output_depth, + /*allowChangingCicp=*/true, /*ignoreGainMap=*/true, + image.get(), &output_depth, /*sourceTiming=*/nullptr, /*frameIter=*/nullptr) == AVIF_APP_FILE_FORMAT_UNKNOWN) { return {nullptr, nullptr}; diff --git a/tests/gtest/aviftest_helpers.cc b/tests/gtest/aviftest_helpers.cc index 6d081c30b2..ab7496c511 100644 --- a/tests/gtest/aviftest_helpers.cc +++ b/tests/gtest/aviftest_helpers.cc @@ -400,13 +400,14 @@ AvifImagePtr ReadImage(const char* folder_path, const char* file_name, avifPixelFormat requested_format, int requested_depth, avifChromaDownsampling chroma_downsampling, avifBool ignore_icc, avifBool ignore_exif, - avifBool ignore_xmp, avifBool allow_changing_cicp) { + avifBool ignore_xmp, avifBool allow_changing_cicp, + avifBool ignore_gain_map) { testutil::AvifImagePtr image(avifImageCreateEmpty(), avifImageDestroy); if (!image || avifReadImage((std::string(folder_path) + file_name).c_str(), requested_format, requested_depth, chroma_downsampling, ignore_icc, ignore_exif, ignore_xmp, allow_changing_cicp, - image.get(), + ignore_gain_map, image.get(), /*outDepth=*/nullptr, /*sourceTiming=*/nullptr, /*frameIter=*/nullptr) == AVIF_APP_FILE_FORMAT_UNKNOWN) { return {nullptr, nullptr}; diff --git a/tests/gtest/aviftest_helpers.h b/tests/gtest/aviftest_helpers.h index 91299c3f47..10c0c59746 100644 --- a/tests/gtest/aviftest_helpers.h +++ b/tests/gtest/aviftest_helpers.h @@ -119,7 +119,8 @@ AvifImagePtr ReadImage( avifChromaDownsampling chroma_downsampling = AVIF_CHROMA_DOWNSAMPLING_AUTOMATIC, avifBool ignore_icc = false, avifBool ignore_exif = false, - avifBool ignore_xmp = false, avifBool allow_changing_cicp = true); + avifBool ignore_xmp = false, avifBool allow_changing_cicp = true, + avifBool ignore_gain_map = false); // Convenient wrapper around avifPNGWrite() for debugging purposes. // Do not remove. bool WriteImage(const avifImage* image, const char* file_path);