iosswifturluikitphasset

Add metadata to existing image URL without reading image into memory?


For UIImages I am writing images with EXIF/IPTC/etc meta using Photos like:

let dataBundle = mergeImageData(...)
let assetChangeRequest = PHAssetCreationRequest.forAsset()
let assetOptions = PHAssetResourceCreationOptions()
assetOptions.originalFilename =  "\(fileName)"
assetChangeRequest.addResource(with: .photo, data: dataBundle, options: assetOptions)
    
PHPhotoLibrary.shared().performChanges({
    ...
})

func mergeImageData(image: UIImage, with metadata: NSDictionary) -> Data {}

But my app works with images that are potentially too large to read into memory and saves the image to Photos with a URL not a Data representation:

let creationRequest = PHAssetCreationRequest.forAsset()
creationRequest.addResource(with: .photo, fileURL: imageFileURL, options: options)

I don't see any .meta option for adding a resource which seems like a shame. Is there anyway to add meta to an existing resource without reading it as a Data or UIImage?


Solution

  • The following is some code I have from an Objective-C project that is an extension to NSURL for getting and setting an image's caption. The code uses some lower level CoreGraphics code to read and write the image metadata without the need to load the image itself into memory. After the Objective-C code (which is working in a production app) I will post my the minimally tested Swift version of the code. One quick test with the Swift code worked. The Photos app was able to display the caption set on an image that was then saved to the photo library using a UIActivityViewController.

    Setting a nil caption removes any existing caption.

    BTW - this is the image caption that can be viewed and edited when viewing photos in the Photos app. If you set a caption with this code on an image and then save the image to the photo library then you will be able to see the caption. Or if you import an image from the photo library then this code can access the image's caption if it has one.

    Objective-C code:

    NSURL+Caption.h

    @interface NSURL (Caption)
    
    - (nullable NSString *)imageCaption;
    - (BOOL)setImageCaption:(nullable NSString *)caption;
    
    @end
    

    NSURL+Caption.m

    // The following two functions are shared with the NSURL category and an NSData category
    static NSString *imageCaption(CGImageSourceRef source) {
        NSString *caption = nil;
    
        if (source) {
            CGImageMetadataRef meta = CGImageSourceCopyMetadataAtIndex(source, 0, nil);
            CFRelease(source);
            if (meta) {
                //NSLog(@"meta: %@", meta);
                CGImageMetadataTagRef descTag = CGImageMetadataCopyTagWithPath(meta, nil, CFSTR("dc:description"));
                if (descTag) {
                    CFTypeRef descVal = CGImageMetadataTagCopyValue(descTag);
                    NSObject *val = CFBridgingRelease(descVal);
                    if ([val isKindOfClass:[NSString class]]) {
                        caption = (NSString *)val;
                    } else if ([val isKindOfClass:[NSArray class]]) {
                        NSArray *caps = (NSArray *)val;
                        CGImageMetadataTagRef descRef = (__bridge CGImageMetadataTagRef)(caps.firstObject);
                        if (descRef) {
                            CFTypeRef desc = CGImageMetadataTagCopyValue(descRef);
                            NSString *comment = CFBridgingRelease(desc);
                            if ([comment isKindOfClass:[NSString class]]) {
                                caption = comment;
                            }
                        }
                    }
                    CFRelease(descTag);
                }
                CFRelease(meta);
            }
        }
    
        return caption;
    }
    
    static BOOL setImageCaption(NSString *caption, CGImageSourceRef source, CGImageDestinationRef dest) {
        BOOL res = NO;
        CGMutableImageMetadataRef meta = CGImageMetadataCreateMutable();
    
        NSArray *capTags = @[];
        if (caption.length) {
            CGImageMetadataTagRef capTag = CGImageMetadataTagCreate(kCGImageMetadataNamespaceDublinCore, kCGImageMetadataPrefixDublinCore, CFSTR("[x-default]"), kCGImageMetadataTypeString, (__bridge CFTypeRef _Nonnull)(caption));
            //NSLog(@"capTag: %@", capTag);
            capTags = @[ CFBridgingRelease(capTag) ];
        }
        CGImageMetadataTagRef descTag = CGImageMetadataTagCreate(kCGImageMetadataNamespaceDublinCore, kCGImageMetadataPrefixDublinCore, CFSTR("description"), kCGImageMetadataTypeArrayOrdered, (__bridge CFTypeRef _Nonnull)(capTags));
        CGImageMetadataSetTagWithPath(meta, nil, CFSTR("dc:description"), descTag);
        CFRelease(descTag);
        //NSLog(@"new meta: %@", meta);
    
        CFMutableDictionaryRef options = CFDictionaryCreateMutable(CFAllocatorGetDefault(), 2, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
        CFDictionarySetValue(options, kCGImageDestinationMergeMetadata, kCFBooleanTrue);
        CFDictionarySetValue(options, kCGImageDestinationMetadata, meta);
        CFErrorRef error;
        if (!CGImageDestinationCopyImageSource(dest, source, options, &error)) {
            NSLog(@"error: %@", error);
        } else {
            // Finalize isn't to be used with CGImageDestinationCopyImageSource. Using it prints an error to the console
            // even though the image caption is updated properly.
            //CGImageDestinationFinalize(dest);
            res = YES;
        }
        CFRelease(options);
        CFRelease(meta);
    
        return res;
    }
    
    @implementation NSURL (Caption)
    
    - (nullable NSString *)imageCaption {
        CGImageSourceRef source = CGImageSourceCreateWithURL((__bridge CFURLRef)self, nil);
        //NSLog(@"%@", self);
        return imageCaption(source);
    }
    
    - (BOOL)setImageCaption:(nullable NSString *)caption {
        BOOL res = NO;
        CGImageSourceRef source = CGImageSourceCreateWithURL((__bridge CFURLRef)self, nil);
        if (source) {
            CFStringRef uti = CGImageSourceGetType(source);
            CGImageDestinationRef dest = CGImageDestinationCreateWithURL((__bridge CFURLRef)self, uti, 1, NULL);
            if (dest) {
                if (setImageCaption(caption, source, dest)) {
                    res = YES;
                }
                CFRelease(dest);
            }
    
            CFRelease(source);
        }
    
        return res;
    }
    
    @end
    

    The Swift translation:

    private func getImageCaption(from source: CGImageSource) -> String? {
        var caption: String? = nil
    
        if let meta = CGImageSourceCopyMetadataAtIndex(source, 0, nil) {
            if let descTag = CGImageMetadataCopyTagWithPath(meta, nil, "dc:description" as CFString) {
                let descVal = CGImageMetadataTagCopyValue(descTag)
                if let str = descVal as? String {
                    caption = str
                } else if let caps = descVal as? [CGImageMetadataTag] {
                    if let descRef = caps.first {
                        let desc = CGImageMetadataTagCopyValue(descRef)
                        if let comment = desc as? String {
                            caption = comment
                        }
                    }
                }
            }
        }
    
        return caption
    }
    
    private func setImageCaption(_ caption: String?, source: CGImageSource, destination: CGImageDestination) -> Bool {
        var res = false
    
        var meta = CGImageMetadataCreateMutable()
        var capTags: [CGImageMetadataTag] = []
        if let caption, !caption.isEmpty {
            if let capTag = CGImageMetadataTagCreate(kCGImageMetadataNamespaceDublinCore, kCGImageMetadataPrefixDublinCore, "[x-default]" as CFString, .string, caption as CFTypeRef) {
                capTags = [ capTag ]
            }
        }
        if let descTag = CGImageMetadataTagCreate(kCGImageMetadataNamespaceDublinCore, kCGImageMetadataPrefixDublinCore, "description" as CFString, .arrayOrdered, capTags as CFTypeRef) {
            CGImageMetadataSetTagWithPath(meta, nil, "dc:description" as CFString, descTag)
        }
    
        let options = [ kCGImageDestinationMergeMetadata : true, kCGImageDestinationMetadata : meta ] as CFDictionary
        var error: Unmanaged<CFError>? = nil
        if CGImageDestinationCopyImageSource(destination, source, options, &error) {
            res = true
        } else {
            print("Can't copy image info: \(error)")
        }
    
        return res
    }
    
    extension URL {
        var imageCaption: String? {
            get {
                if let source = CGImageSourceCreateWithURL(self as CFURL, nil) {
                    return getImageCaption(from: source)
                } else {
                    // Probably means the URL isn't for an image
                    return nil
                }
            }
            set {
                if let source = CGImageSourceCreateWithURL(self as CFURL, nil) {
                    if let uti = CGImageSourceGetType(source),
                       let destination = CGImageDestinationCreateWithURL(self as CFURL, uti, 1, nil) {
                        _ = setImageCaption(newValue, source: source, destination: destination)
                    }
                }
            }
        }
    }