2

TL; DR: iOS Photo Editing Extension fails to save changes to photos unless they were taken with device in landscape left orientation.


I am trying to develop a Photos editing extension on iOS.

I have based my code on the Xcode template, Apple's sample code, and several tutorials available online.

I have noticed that some photos fail to save after applying the changes; I get an alert view that reads:

Unable to Save Changes

An error occurred while saving. Please try again later.

OK

Searching on the web lead me to the two following questions here on Stack Overflow:

  1. iOS photo extension finishContentEditingWithCompletionHandler: Unable to Save Changes (Already applied fix, not working in my case)
  2. IOS) Photo Extension Unable To Save Changes Issue (No useful answers to the question)

I tried several editing extensions just to make sure there isn't something wrong with my device, and found out that the issue occurs with:

  1. My app,
  2. Apple's own sample Code,
  3. Some of the third-party apps on the AppStore (e.g., Litely), but not others (e.g., BitCam - I wish I could contact the developers of that app to ask for a few tips...).

I noticed that, for a given photo asset from the library, the issue either always occurs, or it never does. That is, it seems to depend on some property of the photo being edited (so the whole "Try again later" business is meaningless in this case).

I decided to set up a breakpoint inside the finishContentEditing(completionHandler:) method (called to save the modified image to the URL specified by the framework), and inspect the various properties of the PHContentEditingInput object passed at the beginning of the editing session.

I quickly realized that the issue always occurs with pictures that were taken with the iPhone in Portrait, Portrait Upside Down, or Landscape Right orientations, and only then. Photos taken in Landscape Left (Home button to the right) can be saved without problems.

What Apple's sample code does is:

  1. Create a CIIMage instance from the fullSizeImageURL property of the PHContentEditingInput instance.
  2. Create an oriented copy of the image from point #1 by calling applyingOrientation() on it, passing the value of the fullSizeImageOrientation property of the input.
  3. Apply appropriate CoreImage filter to the full-size, oriented image from point #2.
  4. Create a CIContext.
  5. Use the context to call writeJPEGRepresentation(of:to:colorSpace:) passing the modified CIImage obtained in #3, the renderedContentURL from the PHContentEditingOutput and the color space of the original CIImage.

Actual Code:

DispatchQueue.global(qos: .userInitiated).async {
    // Load full-size image to process from input.
    guard let url = input.fullSizeImageURL
        else { fatalError("missing input image url") }
    guard let inputImage = CIImage(contentsOf: url)
        else { fatalError("can't load input image to apply edit") }

    // Define output image with Core Image edits.
    let orientedImage = inputImage//.applyingOrientation(input.fullSizeImageOrientation)
    let outputImage: CIImage
    switch selectedFilterName {
        case .some(wwdcFilter):
            outputImage = orientedImage.applyingWWDCDemoEffect()
        case .some(let filterName):
            outputImage = orientedImage.applyingFilter(filterName, parameters: [:])
        default:
            outputImage = orientedImage
    }

    // Usually you want to create a CIContext early and reuse it, but
    // this extension uses one (explicitly) only on exit.
    let context = CIContext()
    // Render the filtered image to the expected output URL.
    if #available(OSXApplicationExtension 10.12, iOSApplicationExtension 10.0, *) {
        // Use Core Image convenience method to write JPEG where supported.
        do {
            try context.writeJPEGRepresentation(of: outputImage, to: output.renderedContentURL, colorSpace: inputImage.colorSpace!)
            completionHandler(output)
        } catch let error {
            NSLog("can't write image: \(error)")
            completionHandler(nil)
        }
    } else {
        // Use CGImageDestination to write JPEG in older OS.
        guard let cgImage = context.createCGImage(outputImage, from: outputImage.extent)
            else { fatalError("can't create CGImage") }
        guard let destination = CGImageDestinationCreateWithURL(output.renderedContentURL as CFURL, kUTTypeJPEG, 1, nil)
            else { fatalError("can't create CGImageDestination") }
        CGImageDestinationAddImage(destination, cgImage, nil)
        let success = CGImageDestinationFinalize(destination)
        if success {
            completionHandler(output)
        } else {
            completionHandler(nil)
        }
    }
}

(slightly refactored to post here. the bluk of the code above resides in a separate method, called from within the dispatch queue block)


When I try to edit a Photo that was taken with the device in (say) Landscape Right orientation:

enter image description here

...choosing Apple's sample code Photo Editing Extension:

enter image description here

...applying the "Sepia" filter and tapping "Done":

enter image description here

...I get the dreaded alert:

enter image description here

...and after dismissing it, the image preview somehow gets rotated to the orientation relative to Landscape Left:

enter image description here

(i.e., a photo that was taken in Landscape Right is rotated 180 degrees, a photo taken in Portrait is rotated 90 degrees, etc.)

Tapping either "Done" or "Cancel" and then "Discard Changes" finishes the session and the image is restored to its correct orientation:

enter image description here

Evidently, there is some pitfall that neither I, nor the developers of Litely, nor Apple's 2016 sample code is aware of (but the developers of BitCam are).

What's Going On?


Workaround?

If I take a picture with the iPhone in Portrait orientation and try to edit it, on the debugger the fullSizeImageOrientation is .right and editing fails as just described.

But if I rotate the image once by 180 degrees using the default tool:

enter image description here

...saving, editing again and rotating another 180 degrees (or alternatively, 90 + 270 degrees, but always in two separate edits), returning it back to its original orientation, and then try to edit using the extension, now the value of fullSizeImageOrientation is .up, and saving succeeds. I believe that is because this tool actually rotates the pixel data instead of just modifying the orientation metadata (the fact that it can crop and rotate at arbitrary angles, not just multiples of 90 degrees I think gives it away...)

Of course, this would require inconvenient user interaction so it isn't really a workaround (a programmatic equivalent would be, though).


Addendum:

I'm using Xcode 10.0, and the above has been confirmed on both iPhone 8 running iOS 12 GM, and iPhone 5s running iOS 11.4.1).

Nicolas Miari
  • 16,006
  • 8
  • 81
  • 189
  • 1
    I don't seem to have this problem with my photos extension, but I did until (as you say) I worked out the orientation stuff... I'd tell you what I think you might be doing wrong but you didn't show _any_ code. – matt Sep 19 '18 at 01:41
  • @matt Thank you. Right now, I am basically running Apple’s sample code unmodified (link in the question). – Nicolas Miari Sep 19 '18 at 01:42
  • Well, that's not very helpful. I've provided an outline of what I do, but you'll have to work out how (if at all) it differs from what you do. – matt Sep 19 '18 at 01:47
  • @matt I'm sorry, I will now add the actual code to my question. It _is_ though essentially what is described after "What Apple's sample code does is:..." (5 point bullet list). – Nicolas Miari Sep 19 '18 at 01:59
  • Well, you are saying `orientedImage = inputImage` (the CIImage taken from the bitmap data at the URL) and I'm saying `var ci = CIImage(contentsOf: inurl, options: [.applyOrientationProperty:true])!`. – matt Sep 19 '18 at 02:11
  • But the other big difference is that you have completely forgotten to set the adjustment data. That is absolutely crucial. I am surprised you can _ever_ save successfully with that. – matt Sep 19 '18 at 02:12
  • @matt Sorry, I commented out the `.applyingOrientation(input.fullSizeImageOrientation)` part to see if it made a difference (it doesn't) and forgot to put it back in. – Nicolas Miari Sep 19 '18 at 02:13
  • It isn't _quite_ the same thing. Try it my way, please. – matt Sep 19 '18 at 02:13
  • I am setting the adjustment data, in the outer method that calls this one. Apple's code supports Photos, Video and LivePhotos so `finishContentEditing(completionHandler:)` branches into three separate methods. But the adjustment data is set before branching, and thus omitted from the code I posted. – Nicolas Miari Sep 19 '18 at 02:14
  • 1
    OK just checking! :) – matt Sep 19 '18 at 02:16
  • @matt I _have_ tried you code, but no changes so far. There must be something else that is different. The complexity of Apple's code doesn't help; I'll begin from scratch using your code and see from there. – Nicolas Miari Sep 19 '18 at 02:16
  • Let me know if my answer totally fails you and I'll delete it so as not to mislead anyone! – matt Sep 19 '18 at 02:16

1 Answers1

3

I have certainly seen the save fail because the orientation stuff was wrong, but the following architecture currently seems to work for me:

func startContentEditing(with contentEditingInput: PHContentEditingInput, placeholderImage: UIImage) {
    self.input = contentEditingInput
    if let im = self.input?.displaySizeImage {
        self.displayImage = CIImage(image:im, options: [.applyOrientationProperty:true])!
        // ... other stuff depending on what the adjustment data was ...
    }
    self.mtkview.setNeedsDisplay()
}
func finishContentEditing(completionHandler: @escaping ((PHContentEditingOutput?) -> Void)) {
    DispatchQueue.global(qos:.default).async {
        let inurl = self.input!.fullSizeImageURL!
        let output = PHContentEditingOutput(contentEditingInput:self.input!)
        let outurl = output.renderedContentURL
        var ci = CIImage(contentsOf: inurl, options: [.applyOrientationProperty:true])!
        let space = ci.colorSpace!
        // ... apply real filter to `ci` based on user edits ...
        try! CIContext().writeJPEGRepresentation(
            of: ci, to: outurl, colorSpace: space)
        let data = // whatever
        output.adjustmentData = PHAdjustmentData(
            formatIdentifier: self.myidentifier, formatVersion: "1.0", data: data)
        completionHandler(output)
    }
}
matt
  • 515,959
  • 87
  • 875
  • 1,141
  • I gave up trying to get `MTKView` set up properly, but I'm doing lightweight stuff on still images so just using a regular `UIImageView` for the preview. – Nicolas Miari Sep 19 '18 at 02:22
  • 1
    Well it took me a while to figure it out too! Getting the CIFilter rendering to appear in the right spot is the big issue. See https://stackoverflow.com/a/51753747/341994 for some working boilerplate. (I would have thought this would be a piece of cake for a major OpenGL dude like you!) – matt Sep 19 '18 at 02:26
  • Thank you a million times. I couldn't get your code to work within Apple's sample project, but it **does** work on a fresh project. My OpenGL is quite rusty now (and will likely stay that way...) – Nicolas Miari Sep 19 '18 at 02:28
  • 1
    Son of a gun, it works, eh? That's good because I thrashed around a LOT before I ended up with this. :) – matt Sep 19 '18 at 02:30
  • The take away is that "Unable to Save Changes" encompasses **a lot** of different things that can go wrong, and thus provides no help at all. In that light, I completely agree that my question, without actual source code was "too broad". – Nicolas Miari Sep 19 '18 at 02:34
  • I wonder whether the timing of when you set the adjustment data _does_ have something to do with it. – matt Sep 19 '18 at 02:42