Selaa lähdekoodia

Merge pull request #3799 from dreampiggy/feature/hdr_encoding

Supports HDR encoding on Apple ImageIO coder
DreamPiggy 1 vuosi sitten
vanhempi
sitoutus
0fe2bb799e

+ 1 - 1
README.md

@@ -122,7 +122,7 @@ You can use those directly, or create similar components of your own, by using t
 - watchOS 2.0 or later
 - macOS 10.11 or later (10.15 for Catalyst)
 - visionOS 1.0 or later
-- Xcode 14.0 or later (visionOS requires Xcode 15.0)
+- Xcode 15.0 or later
 
 #### Backwards compatibility
 

+ 14 - 3
SDWebImage/Core/SDImageCoder.h

@@ -15,7 +15,7 @@ typedef NSString * SDImageCoderOption NS_STRING_ENUM;
 typedef NSDictionary<SDImageCoderOption, id> SDImageCoderOptions;
 typedef NSMutableDictionary<SDImageCoderOption, id> SDImageCoderMutableOptions;
 
-#pragma mark - Coder Options
+#pragma mark - Image Decoding Options
 // These options are for image decoding
 /**
  A Boolean value indicating whether to decode the first frame only for animated image during decoding. (NSNumber). If not provide, decode animated image if need.
@@ -89,14 +89,25 @@ FOUNDATION_EXPORT SDImageCoderOption _Nonnull const SDImageCoderDecodeUseLazyDec
 FOUNDATION_EXPORT SDImageCoderOption _Nonnull const SDImageCoderDecodeScaleDownLimitBytes;
 
 /**
- A Boolean value (NSNumber) to provide converting to HDR during decoding. Currently if number is 0, use SDR, else use HDR. But we may extend this option to use `NSUInteger` in the future (means, think this options as int number, but not actual boolean)
+ A Boolean (`SDImageHDRType.rawValue`) value (stored inside NSNumber) to provide converting to HDR during decoding. Currently if number is 0 (`SDImageHDRTypeSDR`), use SDR, else use HDR. But we may extend this option to represent `SDImageHDRType` all cases in the future (means, think this options as uint number, but not actual boolean)
  @note Supported by iOS 17 and above when using ImageIO coder (third-party coder can support lower firmware)
  Defaults to @(NO), decoder will automatically convert SDR.
  @note works for `SDImageCoder`
  */
 FOUNDATION_EXPORT SDImageCoderOption _Nonnull const SDImageCoderDecodeToHDR;
 
-// These options are for image encoding
+#pragma mark - Image Encoding Options
+/**
+ A NSUInteger (`SDImageHDRType.rawValue`) value (stored inside NSNumber) to provide converting to HDR during encoding. Read the below carefully to choose the value.
+ @note 0(`SDImageHDRTypeSDR`) means SDR; 1(`SDImageHDRTypeISOHDR`) means ISO HDR (at least using 10 bits per components or above, supported by AVIF/HEIF/JPEG-XL); 2(`SDImageHDRTypeISOGainMap`) means ISO Gain Map HDR (may use 8 bits per components, supported by AVIF/HEIF/JPEG-XL, as well as traditional JPEG)
+ @note Gain Map like a mask image with metadata, which contains the depth/bright information for pixels (1/4 resolution), which used to convert between HDR and SDR.
+ @note If you use CIImage as HDR pipeline, you can export as CGImage for encoding. (But it's also recommanded to use CIImage's `JPEGRepresentationOfImage` or `HEIFRepresentationOfImage`)
+ @note Supported by iOS 18 and above when using ImageIO coder (third-party coder can support lower firmware)
+ Defaults to @(0), encoder will automatically convert SDR.
+ @note works for `SDImageCoder`
+ */
+FOUNDATION_EXPORT SDImageCoderOption _Nonnull const SDImageCoderEncodeToHDR;
+
 /**
  A Boolean value indicating whether to encode the first frame only for animated image during encoding. (NSNumber). If not provide, encode animated image if need.
  @note works for `SDImageCoder`.

+ 1 - 0
SDWebImage/Core/SDImageCoder.m

@@ -18,6 +18,7 @@ SDImageCoderOption const SDImageCoderDecodeUseLazyDecoding = @"decodeUseLazyDeco
 SDImageCoderOption const SDImageCoderDecodeScaleDownLimitBytes = @"decodeScaleDownLimitBytes";
 SDImageCoderOption const SDImageCoderDecodeToHDR = @"decodeToHDR";
 
+SDImageCoderOption const SDImageCoderEncodeToHDR = @"encodeToHDR";
 SDImageCoderOption const SDImageCoderEncodeFirstFrameOnly = @"encodeFirstFrameOnly";
 SDImageCoderOption const SDImageCoderEncodeCompressionQuality = @"encodeCompressionQuality";
 SDImageCoderOption const SDImageCoderEncodeBackgroundColor = @"encodeBackgroundColor";

+ 11 - 0
SDWebImage/Core/SDImageCoderHelper.h

@@ -33,6 +33,17 @@ typedef NS_ENUM(NSUInteger, SDImageForceDecodePolicy) {
     SDImageForceDecodePolicyAlways
 };
 
+/// These enum is used to represent the High Dynamic Range type during image encoding/decoding.
+/// There are alao other HDR type in history before ISO Standard (ISO 21496-1), including Google and Apple's old OSs captured photos, but which is non-standard and we do not support.
+typedef NS_ENUM(NSUInteger, SDImageHDRType) {
+    /// SDR, mostly only 8 bits color per components, RGBA8
+    SDImageHDRTypeSDR = 0,
+    /// ISO HDR (supported by modern format only, like HEIF/AVIF/JPEG-XL)
+    SDImageHDRTypeISOHDR = 1,
+    /// ISO Gain Map based HDR (supported by nearly all format, including tranditional JPEG, which stored the gain map into XMP)
+    SDImageHDRTypeISOGainMap = 2,
+};
+
 /// Byte alignment the bytes size with alignment
 /// - Parameters:
 ///   - size: The bytes size

+ 33 - 0
SDWebImage/Core/SDImageIOAnimatedCoder.m

@@ -28,6 +28,12 @@ static CGImageSourceRef (*SDCGImageGetImageSource)(CGImageRef);
 
 // Specify File Size for lossy format encoding, like JPEG
 static NSString * kSDCGImageDestinationRequestedFileSize = @"kCGImageDestinationRequestedFileSize";
+// Support Xcode 15 SDK, use raw value instead of symbol
+static NSString * kSDCGImageDestinationEncodeRequest = @"kCGImageDestinationEncodeRequest";
+static NSString * kSDCGImageDestinationEncodeToSDR = @"kCGImageDestinationEncodeToSDR";
+static NSString * kSDCGImageDestinationEncodeToISOHDR = @"kCGImageDestinationEncodeToISOHDR";
+static NSString * kSDCGImageDestinationEncodeToISOGainmap = @"kCGImageDestinationEncodeToISOGainmap";
+
 
 // This strip the un-wanted CGImageProperty, like the internal CGImageSourceRef in iOS 15+
 // However, CGImageCreateCopy still keep those CGImageProperty, not suit for our use case
@@ -282,6 +288,18 @@ static BOOL SDImageIOPNGPluginBuggyNeedWorkaround(void) {
     BOOL _decodeToHDR;
 }
 
+#if SD_IMAGEIO_HDR_ENCODING
++ (void)initialize {
+    if (@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *)) {
+        // Use SDK instead of raw value
+        kSDCGImageDestinationEncodeRequest = (__bridge NSString *)kCGImageDestinationEncodeRequest;
+        kSDCGImageDestinationEncodeToSDR = (__bridge NSString *)kCGImageDestinationEncodeToSDR;
+        kSDCGImageDestinationEncodeToISOHDR = (__bridge NSString *)kCGImageDestinationEncodeToISOHDR;
+        kSDCGImageDestinationEncodeToISOGainmap = (__bridge NSString *)kCGImageDestinationEncodeToISOGainmap;
+    }
+}
+#endif
+
 - (void)dealloc
 {
     if (_imageSource) {
@@ -895,6 +913,21 @@ static BOOL SDImageIOPNGPluginBuggyNeedWorkaround(void) {
         maxPixelSize = maxPixelSizeValue.CGSizeValue;
 #endif
     }
+    // HDR Encoding
+    NSUInteger encodeToHDR = 0;
+    if (options[SDImageCoderEncodeToHDR]) {
+        encodeToHDR = [options[SDImageCoderEncodeToHDR] unsignedIntegerValue];
+    }
+    if (@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *)) {
+        if (encodeToHDR == SDImageHDRTypeISOHDR) {
+            properties[kSDCGImageDestinationEncodeRequest] = kSDCGImageDestinationEncodeToISOHDR;
+        } else if (encodeToHDR == SDImageHDRTypeISOGainMap) {
+            properties[kSDCGImageDestinationEncodeRequest] = kSDCGImageDestinationEncodeToISOGainmap;
+        } else {
+            properties[kSDCGImageDestinationEncodeRequest] = kSDCGImageDestinationEncodeToSDR;
+        }
+    }
+    
     CGFloat pixelWidth = (CGFloat)CGImageGetWidth(imageRef);
     CGFloat pixelHeight = (CGFloat)CGImageGetHeight(imageRef);
     CGFloat finalPixelSize = 0;

+ 33 - 0
SDWebImage/Core/SDImageIOCoder.m

@@ -18,6 +18,12 @@
 
 // Specify File Size for lossy format encoding, like JPEG
 static NSString * kSDCGImageDestinationRequestedFileSize = @"kCGImageDestinationRequestedFileSize";
+// Support Xcode 15 SDK, use raw value instead of symbol
+static NSString * kSDCGImageDestinationEncodeRequest = @"kCGImageDestinationEncodeRequest";
+static NSString * kSDCGImageDestinationEncodeToSDR = @"kCGImageDestinationEncodeToSDR";
+static NSString * kSDCGImageDestinationEncodeToISOHDR = @"kCGImageDestinationEncodeToISOHDR";
+static NSString * kSDCGImageDestinationEncodeToISOGainmap = @"kCGImageDestinationEncodeToISOGainmap";
+
 
 @implementation SDImageIOCoder {
     size_t _width, _height;
@@ -31,6 +37,18 @@ static NSString * kSDCGImageDestinationRequestedFileSize = @"kCGImageDestination
     BOOL _decodeToHDR;
 }
 
+#if SD_IMAGEIO_HDR_ENCODING
++ (void)initialize {
+    if (@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *)) {
+        // Use SDK instead of raw value
+        kSDCGImageDestinationEncodeRequest = (__bridge NSString *)kCGImageDestinationEncodeRequest;
+        kSDCGImageDestinationEncodeToSDR = (__bridge NSString *)kCGImageDestinationEncodeToSDR;
+        kSDCGImageDestinationEncodeToISOHDR = (__bridge NSString *)kCGImageDestinationEncodeToISOHDR;
+        kSDCGImageDestinationEncodeToISOGainmap = (__bridge NSString *)kCGImageDestinationEncodeToISOGainmap;
+    }
+}
+#endif
+
 - (void)dealloc {
     if (_imageSource) {
         CFRelease(_imageSource);
@@ -381,6 +399,21 @@ static NSString * kSDCGImageDestinationRequestedFileSize = @"kCGImageDestination
         maxPixelSize = maxPixelSizeValue.CGSizeValue;
 #endif
     }
+    // HDR Encoding
+    NSUInteger encodeToHDR = 0;
+    if (options[SDImageCoderEncodeToHDR]) {
+        encodeToHDR = [options[SDImageCoderEncodeToHDR] unsignedIntegerValue];
+    }
+    if (@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *)) {
+        if (encodeToHDR == SDImageHDRTypeISOHDR) {
+            properties[kSDCGImageDestinationEncodeRequest] = kSDCGImageDestinationEncodeToISOHDR;
+        } else if (encodeToHDR == SDImageHDRTypeISOGainMap) {
+            properties[kSDCGImageDestinationEncodeRequest] = kSDCGImageDestinationEncodeToISOGainmap;
+        } else {
+            properties[kSDCGImageDestinationEncodeRequest] = kSDCGImageDestinationEncodeToSDR;
+        }
+    }
+    
     CGFloat pixelWidth = (CGFloat)CGImageGetWidth(imageRef);
     CGFloat pixelHeight = (CGFloat)CGImageGetHeight(imageRef);
     CGFloat finalPixelSize = 0;

+ 3 - 0
SDWebImage/Private/SDImageIOAnimatedCoderInternal.h

@@ -10,6 +10,9 @@
 #import <ImageIO/ImageIO.h>
 #import "SDImageIOAnimatedCoder.h"
 
+// Xcode 16 SDK contains HDR encoding API, but we still support Xcode 15
+#define SD_IMAGEIO_HDR_ENCODING (__IPHONE_OS_VERSION_MAX_ALLOWED >= 180000)
+
 // AVFileTypeHEIC/AVFileTypeHEIF is defined in AVFoundation via iOS 11, we use this without import AVFoundation
 #define kSDUTTypeHEIC  ((__bridge CFStringRef)@"public.heic")
 #define kSDUTTypeHEIF  ((__bridge CFStringRef)@"public.heif")

+ 71 - 3
Tests/Tests/SDImageCoderTests.m

@@ -666,9 +666,6 @@
             expect(SDRBPC).beLessThanOrEqualTo(8);
             expect([SDRImage sd_colorAtPoint:CGPointMake(1, 1)]).notTo.beNil();
 #endif
-            
-            // FIXME: Encoding need iOS 18+/macOS 15+
-            // And need test both GainMap HDR or ISO HDR, TODO
         }
     }
 #endif
@@ -706,6 +703,64 @@
 #endif
 }
 
+- (void)test34ThatHDREncodeWorks {
+    // FIXME: Encoding need iOS 18+/macOS 15+, No simulator
+    // GitHub Action virtualization framework contains issue for Gain Map HDR convert:
+    if (SDTestCase.isCI) {
+        return;
+    }
+    // Actually we test 4 cases, because decoded CGImage can contains gain map or not
+    // heic -> heic / heic -> jpeg / jpeg(gain map) -> heic / jpeg(gain map) -> jpeg
+    if (@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *)) {
+        NSArray *decodeFormats = @[@"heic", @"jpeg"];
+        for (NSString *decodeFormat in decodeFormats) {
+            NSURL *url = [[NSBundle bundleForClass:[self class]] URLForResource:@"TestHDR" withExtension:decodeFormat];
+            NSData *data = [NSData dataWithContentsOfURL:url];
+            // Decoding
+            UIImage *HDRImage = [SDImageIOCoder.sharedCoder decodedImageWithData:data options:@{SDImageCoderDecodeToHDR : @(YES)}];
+            float headroom = CGImageGetContentHeadroom(HDRImage.CGImage);
+            expect(headroom).beGreaterThan(1);
+#if !TARGET_OS_SIMULATOR
+            NSArray *encodeFormats = @[@"heic", @"jpeg"];
+            for (NSString *encodeFormat in encodeFormats) {
+                NSLog(@"Testing HDR encodde from original : %@ to %@", decodeFormat, encodeFormat);
+                // HEIC with ISO Gain Map
+                SDImageFormat format = SDImageFormatHEIC;
+                if ([encodeFormat isEqualToString:@"jpeg"]) {
+                    // JPEG with XMP Gain Map
+                    format = SDImageFormatJPEG;
+                }
+                NSData *SDRData = [SDImageIOCoder.sharedCoder encodedDataWithImage:HDRImage format:format options:@{SDImageCoderEncodeToHDR : @(SDImageHDRTypeSDR)}];
+                NSData *HDRData = [SDImageIOCoder.sharedCoder encodedDataWithImage:HDRImage format:format options:@{SDImageCoderEncodeToHDR : @(SDImageHDRTypeISOHDR)}];
+                NSData *HDRGainMapData = [SDImageIOCoder.sharedCoder encodedDataWithImage:HDRImage format:format options:@{SDImageCoderEncodeToHDR : @(SDImageHDRTypeISOGainMap)}];
+                expect(SDRData).notTo.beNil();
+                expect(HDRData).notTo.beNil();
+                expect(HDRGainMapData).notTo.beNil();
+                // JPEG has no built-in support Gain Map, so it stored in XMP and be larger
+                if ([encodeFormat isEqualToString:@"jpeg"]) {
+                    expect(HDRGainMapData.length).beGreaterThan(HDRData.length);
+                }
+                
+                // Check gain map information
+                CGImageSourceRef source = CGImageSourceCreateWithData((__bridge CFDataRef)HDRGainMapData, NULL);
+                NSDictionary *gainMap = [self gainMapFromImageSource:source];
+                expect(gainMap.count).beGreaterThan(0);
+                // At least gain map contains `kCGImageAuxiliaryDataInfoMetadata`
+                CGImageMetadataRef meta = (__bridge CGImageMetadataRef)(gainMap[(__bridge NSString *)kCGImageAuxiliaryDataInfoMetadata]);
+                expect(meta).notTo.beNil();
+                
+                // A check for redecoded CGImage
+                CGImageRef redecodeSDRImage = CGImageSourceCreateImageAtIndex(source, 0, nil);
+                expect(redecodeSDRImage).notTo.beNil();
+                headroom = CGImageGetContentHeadroom(redecodeSDRImage);
+                expect(headroom).equal(1);
+                CFRelease(source);
+            }
+#endif
+        }
+    }
+}
+
 #pragma mark - Utils
 
 - (void)verifyCoder:(id<SDImageCoder>)coder
@@ -835,6 +890,19 @@ withLocalImageURL:(NSURL *)imageUrl
     return thumbnailImages;
 }
 
+- (NSDictionary *)gainMapFromImageSource:(CGImageSourceRef)source {
+    if (@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *)) {
+        CFDictionaryRef ISOGainMap = CGImageSourceCopyAuxiliaryDataInfoAtIndex(source, 0, kCGImageAuxiliaryDataTypeISOGainMap);
+        CFDictionaryRef HDRGainMap = CGImageSourceCopyAuxiliaryDataInfoAtIndex(source, 0, kCGImageAuxiliaryDataTypeHDRGainMap);
+        NSDictionary *result = ISOGainMap ? (__bridge_transfer NSDictionary *)ISOGainMap : (__bridge_transfer NSDictionary *)HDRGainMap;
+        if (HDRGainMap) CFRelease(HDRGainMap);
+        if (ISOGainMap) CFRelease(ISOGainMap);
+        return result;
+    } else {
+        return nil;
+    }
+}
+
 #pragma mark - Utils
 - (CGRect)boxRectFromPDFData:(nonnull NSData *)data {
     CGDataProviderRef provider = CGDataProviderCreateWithCFData((__bridge CFDataRef)data);