SkGainmapEncoder: Add MP extensions to all individual images

In CIPA DC-007, it is not completely clear if it is required that
all images (the base image and the gainmap image) must include MP
extensions (the APP2 marker with MPF metadata).

It is also likely that we will need to include MP extensions if CIPA
DC-007 is updated to include gainmap support.

To be on the safe side, make SkJpegGainmapEncoder::MakeMPF add MP
extensions to all individual images, not just the first one.

To SkJpegMultiPictureParameters::serialize, add an individual
image number parameter, and conditionalize which parts are written
based on that (in particular only write the version for non-first
individual images).

Make SkJpegGainmapEncoder::MakeMPF add the MPF segment to all images.

Then add a bunch of tests.

Bug: b/338342146
Change-Id: I1f7fc59313fec478a243fddbbe6a6a02a104fb8d
Reviewed-on: https://skia-review.googlesource.com/c/skia/+/857840
Commit-Queue: Christopher Cameron <ccameron@google.com>
Reviewed-by: Brian Osman <brianosman@google.com>
Reviewed-by: Christopher Cameron <ccameron@google.com>
diff --git a/src/codec/SkJpegMultiPicture.cpp b/src/codec/SkJpegMultiPicture.cpp
index 29b05fe..a587bf8 100644
--- a/src/codec/SkJpegMultiPicture.cpp
+++ b/src/codec/SkJpegMultiPicture.cpp
@@ -24,9 +24,6 @@
 constexpr uint16_t kTypeUnsignedLong = 0x4;
 constexpr uint16_t kTypeUndefined = 0x7;
 
-constexpr uint32_t kIfdEntrySize = 12;
-constexpr uint32_t kIfdSerializedEntryCount = 3;
-
 constexpr uint16_t kVersionTag = 0xB000;
 constexpr uint32_t kVersionCount = 4;
 constexpr size_t kVersionSize = 4;
@@ -182,8 +179,7 @@
     }
 
     // Start to prepare the result that we will return.
-    auto result = std::make_unique<SkJpegMultiPictureParameters>();
-    result->images.resize(numberOfImages);
+    auto result = std::make_unique<SkJpegMultiPictureParameters>(numberOfImages);
 
     // The next IFD is the Attribute IFD offset. We will not read or validate the Attribute IFD.
 
@@ -220,90 +216,88 @@
     return result;
 }
 
-// Return the number of bytes that will be written by SkJpegMultiPictureParametersSerialize, for a
-// given number of images.
-size_t multi_picture_params_serialized_size(size_t numberOfImages) {
-    return sizeof(kMpfSig) +                           // Signature
-           kMpEndianSize +                             // Endianness
-           sizeof(uint32_t) +                          // Index IFD Offset
-           sizeof(uint16_t) +                          // IFD entry count
-           kIfdSerializedEntryCount * kIfdEntrySize +  // 3 IFD entries at 12 bytes each
-           sizeof(uint32_t) +                          // Attribute IFD offset
-           numberOfImages * kMPEntrySize;              // MP Entries for each image
-}
-
-sk_sp<SkData> SkJpegMultiPictureParameters::serialize() const {
-    // Write the MPF signature.
+sk_sp<SkData> SkJpegMultiPictureParameters::serialize(uint32_t individualImageNumber) const {
     SkDynamicMemoryWStream s;
-    if (!s.write(kMpfSig, sizeof(kMpfSig))) {
-        SkCodecPrintf("Failed to write signature.\n");
-        return nullptr;
-    }
+
+    const uint32_t numberOfImages = static_cast<uint32_t>(images.size());
+
+    // Write the MPF signature.
+    s.write(kMpfSig, sizeof(kMpfSig));
 
     // We will always write as big-endian.
-    if (!s.write(kMpBigEndian, kMpEndianSize)) {
-        SkCodecPrintf("Failed to write endianness.\n");
-        return nullptr;
+    s.write(kMpBigEndian, kMpEndianSize);
+
+    // Set the first IFD offset be the position after the endianness value and this offset. This
+    // will be the MP Index IFD for the first individual image and the MP Attribute IFD for all
+    // other images.
+    constexpr uint32_t firstIfdOffset = sizeof(kMpBigEndian) +  // Endian-ness
+                                        sizeof(uint32_t);       // Index IFD offset
+    SkWStreamWriteU32BE(&s, firstIfdOffset);
+    SkASSERT(s.bytesWritten() - sizeof(kMpfSig) == firstIfdOffset);
+
+    if (individualImageNumber == 0) {
+        // The MP Index IFD will write 3 tags (version, number of images, and MP entries). See
+        // in Table 6 (MP Index IFD Tag Support Level) that these are the only mandatory entries.
+        const uint32_t mpIndexIfdNumberOfTags = 3;
+        SkWStreamWriteU16BE(&s, mpIndexIfdNumberOfTags);
+    } else {
+        // The MP Attribute IFD will write 1 tags (version). See in Table 7 (MP Attribute IFD Tag
+        // Support Level for Baseline MP Files) that no tags are required. If gainmap images support
+        // is added to CIPA DC-007, then some tags may be added and become mandatory.
+        const uint16_t mpAttributeIfdNumberOfTags = 1;
+        SkWStreamWriteU16BE(&s, mpAttributeIfdNumberOfTags);
     }
-    // Compute the number of images.
-    uint32_t numberOfImages = static_cast<uint32_t>(images.size());
 
-    // Set the Index IFD offset be the position after the endianness value and this offset.
-    constexpr uint32_t indexIfdOffset =
-            static_cast<uint16_t>(sizeof(kMpBigEndian) + sizeof(uint32_t));
-    SkWStreamWriteU32BE(&s, indexIfdOffset);
-
-    // We will write 3 tags (version, number of images, MP entries).
-    constexpr uint32_t numberOfTags = 3;
-    SkWStreamWriteU16BE(&s, numberOfTags);
-
-    // Write the version tag.
+    // Write the version.
     SkWStreamWriteU16BE(&s, kVersionTag);
     SkWStreamWriteU16BE(&s, kTypeUndefined);
     SkWStreamWriteU32BE(&s, kVersionCount);
-    if (!s.write(kVersionExpected, kVersionSize)) {
-        SkCodecPrintf("Failed to write version.\n");
-        return nullptr;
-    }
+    s.write(kVersionExpected, kVersionSize);
 
-    // Write the number of images.
-    SkWStreamWriteU16BE(&s, kNumberOfImagesTag);
-    SkWStreamWriteU16BE(&s, kTypeUnsignedLong);
-    SkWStreamWriteU32BE(&s, kNumberOfImagesCount);
-    SkWStreamWriteU32BE(&s, numberOfImages);
+    if (individualImageNumber == 0) {
+        // Write the number of images.
+        SkWStreamWriteU16BE(&s, kNumberOfImagesTag);
+        SkWStreamWriteU16BE(&s, kTypeUnsignedLong);
+        SkWStreamWriteU32BE(&s, kNumberOfImagesCount);
+        SkWStreamWriteU32BE(&s, numberOfImages);
 
-    // Write the MP entries.
-    SkWStreamWriteU16BE(&s, kMPEntryTag);
-    SkWStreamWriteU16BE(&s, kTypeUndefined);
-    SkWStreamWriteU32BE(&s, kMPEntrySize * numberOfImages);
-    const uint32_t mpEntryOffset =
-            static_cast<uint32_t>(s.bytesWritten() -  // The bytes written so far
-                                  sizeof(kMpfSig) +   // Excluding the MPF signature
-                                  sizeof(uint32_t) +  // The 4 bytes for this offset
-                                  sizeof(uint32_t));  // The 4 bytes for the attribute IFD offset.
-    SkWStreamWriteU32BE(&s, mpEntryOffset);
+        // Write the MP entries tag.
+        SkWStreamWriteU16BE(&s, kMPEntryTag);
+        SkWStreamWriteU16BE(&s, kTypeUndefined);
+        const uint32_t mpEntriesSize = kMPEntrySize * numberOfImages;
+        SkWStreamWriteU32BE(&s, mpEntriesSize);
+        const uint32_t mpEntryOffset = static_cast<uint32_t>(
+                s.bytesWritten() -  // The bytes written so far
+                sizeof(kMpfSig) +   // Excluding the MPF signature
+                sizeof(uint32_t) +  // The 4 bytes for this offset
+                sizeof(uint32_t));  // The 4 bytes for the attribute IFD offset.
+        SkWStreamWriteU32BE(&s, mpEntryOffset);
 
-    // Write the attribute IFD offset (zero because we don't write it).
-    SkWStreamWriteU32BE(&s, 0);
+        // Write the attribute IFD offset (zero because there is none).
+        SkWStreamWriteU32BE(&s, 0);
 
-    // Write the MP entries.
-    for (size_t i = 0; i < images.size(); ++i) {
-        const auto& image = images[i];
+        // Write the MP entries data.
+        SkASSERT(s.bytesWritten() - sizeof(kMpfSig) == mpEntryOffset);
+        for (size_t i = 0; i < images.size(); ++i) {
+            const auto& image = images[i];
 
-        uint32_t attribute = kMPEntryAttributeFormatJpeg;
-        if (i == 0) {
-            attribute |= kMPEntryAttributeTypePrimary;
+            uint32_t attribute = kMPEntryAttributeFormatJpeg;
+            if (i == 0) {
+                attribute |= kMPEntryAttributeTypePrimary;
+            }
+
+            SkWStreamWriteU32BE(&s, attribute);
+            SkWStreamWriteU32BE(&s, image.size);
+            SkWStreamWriteU32BE(&s, image.dataOffset);
+            // Dependent image 1 and 2 entries are zero.
+            SkWStreamWriteU16BE(&s, 0);
+            SkWStreamWriteU16BE(&s, 0);
         }
-
-        SkWStreamWriteU32BE(&s, attribute);
-        SkWStreamWriteU32BE(&s, image.size);
-        SkWStreamWriteU32BE(&s, image.dataOffset);
-        // Dependent image 1 and 2 entries are zero.
-        SkWStreamWriteU16BE(&s, 0);
-        SkWStreamWriteU16BE(&s, 0);
+    } else {
+        // The non-first-individual-images do not have any further IFDs.
+        SkWStreamWriteU32BE(&s, 0);
     }
 
-    SkASSERT(s.bytesWritten() == multi_picture_params_serialized_size(images.size()));
     return s.detachAsData();
 }
 
diff --git a/src/codec/SkJpegMultiPicture.h b/src/codec/SkJpegMultiPicture.h
index 47bccdb..9d527e0 100644
--- a/src/codec/SkJpegMultiPicture.h
+++ b/src/codec/SkJpegMultiPicture.h
@@ -23,6 +23,8 @@
  * offset parameters from the images in the Index Image File Directory.
  */
 struct SkJpegMultiPictureParameters {
+    explicit SkJpegMultiPictureParameters(size_t numberOfImages) : images(numberOfImages) {}
+
     // An individual image.
     struct Image {
         // The size of the image in bytes.
@@ -46,11 +48,11 @@
             const sk_sp<const SkData>& segmentParameters);
 
     /*
-     * Serialize Jpeg Multi-Picture Format parameters into a segment. This segment will start with
-     * the {'M', 'P', 'F', 0} signature (it will not include the segment marker or parameter
-     * length).
+     * Serialize Jpeg Multi-Picture Format segment parameters for the indicated individual image.
+     * This segment will start with the {'M', 'P', 'F', 0} signature (it will not include the
+     * segment marker or parameter length).
      */
-    sk_sp<SkData> serialize() const;
+    sk_sp<SkData> serialize(uint32_t individualImageNumber) const;
 
     /*
      * Compute the absolute offset (from the start of the image) for the offset in the multi-picture
diff --git a/src/encode/SkJpegGainmapEncoder.cpp b/src/encode/SkJpegGainmapEncoder.cpp
index a4e0992..1a219a9 100644
--- a/src/encode/SkJpegGainmapEncoder.cpp
+++ b/src/encode/SkJpegGainmapEncoder.cpp
@@ -168,9 +168,10 @@
     return encodeStream.detachAsData();
 }
 
-static sk_sp<SkData> get_mpf_segment(const SkJpegMultiPictureParameters& mpParams) {
+static sk_sp<SkData> get_mpf_segment(const SkJpegMultiPictureParameters& mpParams,
+                                     size_t imageNumber) {
     SkDynamicMemoryWStream s;
-    auto segmentParameters = mpParams.serialize();
+    auto segmentParameters = mpParams.serialize(static_cast<uint32_t>(imageNumber));
     const size_t mpParameterLength = kJpegSegmentParameterLengthSize + segmentParameters->size();
     s.write8(0xFF);
     s.write8(kMpfMarker);
@@ -231,9 +232,13 @@
 
         // Include XMP.
         if (includeUltraHDRv1) {
+            // Add to the gainmap image size the size of the MPF segment for image 1 of a 2-image
+            // file.
+            SkJpegMultiPictureParameters mpParams(2);
+            size_t gainmapImageSize = gainmapData->size() + get_mpf_segment(mpParams, 1)->size();
             SkJpegMetadataEncoder::AppendXMPStandard(
                     metadataSegments,
-                    get_base_image_xmp_metadata(static_cast<int32_t>(gainmapData->size())).get());
+                    get_base_image_xmp_metadata(static_cast<int32_t>(gainmapImageSize)).get());
         }
 
         // Include ICC profile metadata.
@@ -265,12 +270,12 @@
         return true;
     }
 
-    // Compute the offset into the first individual image where we will write the MP parameters.
-    size_t mpSegmentOffset = 0;
-    {
+    // Compute the offset into the each image where we will write the MP parameters.
+    std::vector<size_t> mpSegmentOffsets(imageCount);
+    for (size_t i = 0; i < imageCount; ++i) {
         // Scan the image until StartOfScan marker.
         SkJpegSegmentScanner scan(kJpegMarkerStartOfScan);
-        scan.onBytes(images[0]->data(), images[0]->size());
+        scan.onBytes(images[i]->data(), images[i]->size());
         if (!scan.isDone()) {
             SkCodecPrintf("Failed to scan image header.\n");
             return false;
@@ -281,61 +286,45 @@
         // which indicates "The MP Extensions are specified in the APP2 marker segment which follows
         // immediately after the Exif Attributes in the APP1 marker segment except as specified in
         // section 7". We currently do not include Exif metadata in encoded files yet.
-        mpSegmentOffset = scan.getSegments().back().offset;
+        mpSegmentOffsets[i] = scan.getSegments().back().offset;
     }
 
     // Populate the MP parameters (image sizes and offsets).
-    SkJpegMultiPictureParameters mpParams;
-    {
-        mpParams.images.resize(imageCount);
-        size_t cumulativeSize = 0;
-        for (size_t i = 0; i < imageCount; ++i) {
-            size_t imageSize = images[i]->size();
-            if (i == 0) {
-                // Add the size of the MPF segment to the first individual image. Note that the
-                // contents of get_mpf_segment() are incorrect (because we don't have the right
-                // offset values), but the size is correct.
-                imageSize += static_cast<uint32_t>(get_mpf_segment(mpParams)->size());
-            }
-
-            mpParams.images[i].dataOffset = SkJpegMultiPictureParameters::GetImageDataOffset(
-                    cumulativeSize, mpSegmentOffset);
-            mpParams.images[i].size = static_cast<uint32_t>(imageSize);
-            cumulativeSize += imageSize;
-        }
+    SkJpegMultiPictureParameters mpParams(imageCount);
+    size_t cumulativeSize = 0;
+    for (size_t i = 0; i < imageCount; ++i) {
+        // Add the size of the MPF segment to image size. Note that the contents of
+        // get_mpf_segment() are incorrect (because we don't have the right offset values), but
+        // the size is correct.
+        const size_t imageSize = images[i]->size() + get_mpf_segment(mpParams, i)->size();
+        mpParams.images[i].dataOffset = SkJpegMultiPictureParameters::GetImageDataOffset(
+                cumulativeSize, mpSegmentOffsets[0]);
+        mpParams.images[i].size = static_cast<uint32_t>(imageSize);
+        cumulativeSize += imageSize;
     }
 
-    // Write the first individual image.
-    {
-        auto image = images[0];
-
+    // Write the images.
+    for (size_t i = 0; i < imageCount; ++i) {
         // Write up to the MP segment.
-        if (!dst->write(image->bytes(), mpSegmentOffset)) {
+        if (!dst->write(images[i]->bytes(), mpSegmentOffsets[i])) {
             SkCodecPrintf("Failed to write image header.\n");
             return false;
         }
 
         // Write the MP segment.
-        auto mpfSegment = get_mpf_segment(mpParams);
+        auto mpfSegment = get_mpf_segment(mpParams, i);
         if (!dst->write(mpfSegment->data(), mpfSegment->size())) {
             SkCodecPrintf("Failed to write MPF segment.\n");
             return false;
         }
 
         // Write the rest of the image.
-        if (!dst->write(image->bytes() + mpSegmentOffset, image->size() - mpSegmentOffset)) {
+        if (!dst->write(images[i]->bytes() + mpSegmentOffsets[i],
+                        images[i]->size() - mpSegmentOffsets[i])) {
             SkCodecPrintf("Failed to write image body.\n");
             return false;
         }
     }
 
-    // Write the non-first individual images.
-    for (size_t i = 1; i < imageCount; ++i) {
-        auto image = images[i];
-        if (!dst->write(image->bytes(), image->size())) {
-            SkCodecPrintf("Failed to write image body.\n");
-            return false;
-        }
-    }
     return true;
 }
diff --git a/tests/JpegGainmapTest.cpp b/tests/JpegGainmapTest.cpp
index 0fc8abc..75a4507 100644
--- a/tests/JpegGainmapTest.cpp
+++ b/tests/JpegGainmapTest.cpp
@@ -24,6 +24,7 @@
 #include "src/codec/SkJpegMultiPicture.h"
 #include "src/codec/SkJpegSegmentScan.h"
 #include "src/codec/SkJpegSourceMgr.h"
+#include "src/codec/SkTiffUtility.h"
 #include "tests/Test.h"
 #include "tools/Resources.h"
 
@@ -400,7 +401,7 @@
 
     // Verify that we get the same parameters when we re-serialize and de-serialize them
     {
-        auto mpParamsSerialized = mpParams->serialize();
+        auto mpParamsSerialized = mpParams->serialize(0);
         REPORTER_ASSERT(r, mpParamsSerialized);
         auto mpParamsRoundTripped = SkJpegMultiPictureParameters::Make(mpParamsSerialized);
         REPORTER_ASSERT(r, mpParamsRoundTripped);
@@ -896,6 +897,139 @@
     }
 }
 
+static sk_sp<SkData> get_mp_image(sk_sp<SkData> imageData, size_t imageNumber) {
+    SkMemoryStream stream(imageData);
+    auto sourceMgr = SkJpegSourceMgr::Make(&stream);
+
+    std::unique_ptr<SkJpegMultiPictureParameters> mpParams;
+    SkJpegSegment mpParamsSegment;
+    if (!find_mp_params_segment(&stream, &mpParams, &mpParamsSegment)) {
+        return nullptr;
+    }
+    return SkData::MakeSubset(
+            imageData.get(),
+            SkJpegMultiPictureParameters::GetImageAbsoluteOffset(
+                    mpParams->images[imageNumber].dataOffset, mpParamsSegment.offset),
+            mpParams->images[imageNumber].size);
+}
+
+static std::unique_ptr<SkTiffImageFileDirectory> get_mpf_ifd(sk_sp<SkData> imageData) {
+    SkMemoryStream stream(imageData);
+    auto sourceMgr = SkJpegSourceMgr::Make(&stream);
+    for (const auto& segment : sourceMgr->getAllSegments()) {
+        if (segment.marker != kMpfMarker) {
+            continue;
+        }
+        auto parameterData = sourceMgr->getSegmentParameters(segment);
+        if (!parameterData) {
+            continue;
+        }
+        if (parameterData->size() < sizeof(kMpfSig) ||
+            memcmp(kMpfSig, parameterData->data(), sizeof(kMpfSig)) != 0) {
+            continue;
+        }
+        auto ifdData = SkData::MakeSubset(
+                parameterData.get(), sizeof(kMpfSig), parameterData->size() - sizeof(kMpfSig));
+
+        bool littleEndian = false;
+        uint32_t ifdOffset = 0;
+        if (!SkTiffImageFileDirectory::ParseHeader(ifdData.get(), &littleEndian, &ifdOffset)) {
+            return nullptr;
+        }
+        return SkTiffImageFileDirectory::MakeFromOffset(ifdData, littleEndian, ifdOffset);
+    }
+    return nullptr;
+}
+
+DEF_TEST(AndroidCodec_mpfParse, r) {
+    sk_sp<SkData> inputData = GetResourceAsData("images/iphone_13_pro.jpeg");
+
+    {
+        // The MPF in iPhone images has 3 entries: version, image count, and the MP entries.
+        auto ifd = get_mpf_ifd(inputData);
+        REPORTER_ASSERT(r, ifd);
+        REPORTER_ASSERT(r, ifd->getNumEntries() == 3);
+        REPORTER_ASSERT(r, ifd->getEntryTag(0) == 0xB000);
+        REPORTER_ASSERT(r, ifd->getEntryTag(1) == 0xB001);
+        REPORTER_ASSERT(r, ifd->getEntryTag(2) == 0xB002);
+
+        // There is no attribute IFD.
+        REPORTER_ASSERT(r, !ifd->nextIfdOffset());
+    }
+
+    {
+        // The gainmap images have version and image count.
+        auto ifd = get_mpf_ifd(get_mp_image(inputData, 1));
+        REPORTER_ASSERT(r, ifd);
+
+        REPORTER_ASSERT(r, ifd->getNumEntries() == 2);
+        REPORTER_ASSERT(r, ifd->getEntryTag(0) == 0xB000);
+        REPORTER_ASSERT(r, ifd->getEntryTag(1) == 0xB001);
+        uint32_t value = 0;
+        REPORTER_ASSERT(r, ifd->getEntryUnsignedLong(1, 1, &value));
+        REPORTER_ASSERT(r, value == 3);
+
+        // There is no further IFD.
+        REPORTER_ASSERT(r, !ifd->nextIfdOffset());
+    }
+
+    // Replace |inputData| with its transcoded version.
+    {
+        SkBitmap baseBitmap;
+        SkBitmap gainmapBitmap;
+        SkGainmapInfo gainmapInfo;
+        decode_all(r,
+                   std::make_unique<SkMemoryStream>(inputData),
+                   baseBitmap,
+                   gainmapBitmap,
+                   gainmapInfo);
+        gainmapInfo.fType = SkGainmapInfo::Type::kDefault;
+        SkDynamicMemoryWStream encodeStream;
+        bool encodeResult = SkJpegGainmapEncoder::EncodeHDRGM(&encodeStream,
+                                                              baseBitmap.pixmap(),
+                                                              SkJpegEncoder::Options(),
+                                                              gainmapBitmap.pixmap(),
+                                                              SkJpegEncoder::Options(),
+                                                              gainmapInfo);
+        REPORTER_ASSERT(r, encodeResult);
+        inputData = encodeStream.detachAsData();
+    }
+
+    {
+        // The MPF in encoded images has 3 entries: version, image count, and the MP entries.
+        auto ifd = get_mpf_ifd(inputData);
+        REPORTER_ASSERT(r, ifd);
+        REPORTER_ASSERT(r, ifd->getNumEntries() == 3);
+        REPORTER_ASSERT(r, ifd->getEntryTag(0) == 0xB000);
+        REPORTER_ASSERT(r, ifd->getEntryTag(1) == 0xB001);
+        REPORTER_ASSERT(r, ifd->getEntryTag(2) == 0xB002);
+
+        // There is no attribute IFD.
+        REPORTER_ASSERT(r, !ifd->nextIfdOffset());
+    }
+
+    {
+        // The MPF in encoded gainmap images has 2 entries: Version and number of images.
+        auto ifd = get_mpf_ifd(get_mp_image(inputData, 1));
+        REPORTER_ASSERT(r, ifd);
+
+        REPORTER_ASSERT(r, ifd->getNumEntries() == 1);
+        REPORTER_ASSERT(r, ifd->getEntryTag(0) == 0xB000);
+
+        // Verify the version data (don't verify the version in the primary image, because if that
+        // were broken all MPF images would be broken).
+        sk_sp<SkData> versionData = ifd->getEntryUndefinedData(0);
+        REPORTER_ASSERT(r, versionData);
+        REPORTER_ASSERT(r, versionData->bytes()[0] == '0');
+        REPORTER_ASSERT(r, versionData->bytes()[1] == '1');
+        REPORTER_ASSERT(r, versionData->bytes()[2] == '0');
+        REPORTER_ASSERT(r, versionData->bytes()[3] == '0');
+
+        // There is no further IFD.
+        REPORTER_ASSERT(r, !ifd->nextIfdOffset());
+    }
+}
+
 DEF_TEST(AndroidCodec_gainmapInfoParse, r) {
     const uint8_t versionData[] = {
             0x00,  // Minimum version