肇鑫的技术博客

业精于勤,荒于嬉

An Error Causing by IndexSet

I got an error of 3301 of PHPhotosError, when using insertAssets(_:at:).

The Limits of Photo Library

The first mistake I found was that my result was reordered by date, but there were two photos with the same date. That meant I had inserted two indexes with the same value to the indexSet, which meant the count of indexSet was smaller than the companied array.

So I created a placeholder date to replace the date matched the item of the array, that made the count of the indexSet and array equal.

But the error was still shown. So I had to investigate again. This time I found the two photos with the same date were the same photo. So when inserting the photos, when the second copy of the same photo was inserted, the photo library had no change and the indexes behind the second copy were all mismatched.

The fix was to excluded the photos from the inserted album that existed in the container, like:

var albumPhotoSet = ...
let containerPhotoSet = ...
albumPhotoSet.substract(containerPhotoSet)
let albums = Array(albumSet)
...

The error still existed.

IndexSet Behaviors Differently from Set

After reading the document of insertAssets(_:at:), I found that IndexSet is a set, but behaviors differently.

let set:Set<Int> = [1, 3, 2, 4, 5]
print(set) // [3, 1, 2, 4, 5], [1, 4, 3, 5, 2], result is radom of 1...5

let indexSet:IndexSet = [1, 3, 2, 4, 5]
print(indexSet) // 5 indexes
print(indexSet.map {$0}) // [1, 2, 3, 4, 5], always

Since the IndexSet is sorted automatically instead of the order it is given. The array it is companied must have the same order.

var albums = Array(albumSet)
albums.sort() // by date
...

This time everything worked.

Conclusion

When inserting photos to an album, there are three steps.

  1. Sorted the photos of the album the same order you wanted for the final result.
  2. Exclude the photos already existed in the album from the inserting photos.
  3. Reorder the inserting photos.
  4. Merge the photos in the album and the inserting photos, reorder the result, and get the indexes of the inserting photos.
  5. Insert the inserting photos to the album.

Something Apple Didn't Say Right On Photos Framework

I am developing an app basing on Photos framework. However, I find that Photos framework is something mainly for Photos.app and not well documented.

I will listed them for further references.

System Default Alert Message May Confuse Users.

When removing an album from photo library, you must use class func deleteAssetCollections(_ assetCollections: NSFastEnumeration). The system will automatically represents an alert to ask the user whether to remove the album. However, there is a message says that only the album is removed, the photos inside the album are kept.

The message is accurate for Photos.app. However, if we want to remove the photos together with the album, there is no API for that, so we have to call the remove API twice, one for photos, the other for album. There will be two alerts shown and the alert message mentioned may confuse the user as the photos are removed this time.

Folders

Folder can contain other Folders, but not its parents.

Folder, which is called PHCollectionList is said can contain other folders. That leads me to say what if folder A contains folder B and B contains A, which make the relation recursive. So I created an example, and I found that when A contains B and B contains A, photo library would rise an error. So the recursive relation is not allowed.

The Collection operations are all movement, not copying.

When putting other collections into one folder, whether you use addChildCollections(_:) or insertChildCollections(_:at:) is irrelevant. The collections are just moved inside the folder, not copied.

This rule also makes there is not duplicated folders in photo library.

The result of fetchCollections(in:options:) is shallow.

Although fetchCollections(in:options:) says it returns "By default, the returned PHFetchResult object contains all collections in the specified collection list." The document is not accurate. It only returns the shallow collections, not all collections. For example, we have three folders, A, B and C. The relations of them is: A -> B -> C, A contains B, B contains C.

let collections = PHCollection.fetchCollections(in: A, options: nil)
print(collections.count)
// 1. Print 1 instead of 2. As folder B is the only collection with shallow search.

Other constrains of folder operations

PHPhotoLibrary.shared().performChanges {
    let requestA = PHCollectionListChangeRequest.creationRequestForCollectionList(withTitle: "FolderA")
    let requestB = PHCollectionListChangeRequest.creationRequestForCollectionList(withTitle: "FolderB")
    let folderB = requestB.placeholderForCreatedCollectionList
    let requestC = PHCollectionListChangeRequest.creationRequestForCollectionList(withTitle: "FolderC")
    let folderC = requestC.placeholderForCreatedCollectionList
    requestA.addChildCollections([folderB] as NSFastEnumeration) // move B to A, works
    requestB.addChildCollections([folderC] as NSFastEnumeration) // move C to B, works
    requestA.addChildCollections([folderC] as NSFastEnumeration) // move C to A, doesn't work
} completionHandler: { success, error in
    if success {
        print("success")
    } else {
        print(error?.localizedDescription ?? "nil")
    }
}

Result in Photos.app: A -> B -> C
test_1

Line 9 doesn't work. I guess because C has been already in A by contained by B.

However, code below works.

PHPhotoLibrary.shared().performChanges {
    let requestA = PHCollectionListChangeRequest.creationRequestForCollectionList(withTitle: "FolderA")
    let requestB = PHCollectionListChangeRequest.creationRequestForCollectionList(withTitle: "FolderB")
    let folderB = requestB.placeholderForCreatedCollectionList
    let requestC = PHCollectionListChangeRequest.creationRequestForCollectionList(withTitle: "FolderC")
    let folderC = requestC.placeholderForCreatedCollectionList
    requestA.addChildCollections([folderB] as NSFastEnumeration) // move B to A, works
    requestA.addChildCollections([folderC] as NSFastEnumeration) // move C to A, works
    requestB.addChildCollections([folderC] as NSFastEnumeration) // move C to B, works
} completionHandler: { success, error in
    if success {
        print("success")
    } else {
        print(error?.localizedDescription ?? "nil")
    }
}

Apple avoids structural complexity by design.

In Photos.app on macOS, you can only move the collections besides the target folder on the same level. This avoid the complexity of the model structure. In fact, you can move any folder to the target folder. However, that will make not every move operation working, which may confuse user that doesn't familiar with the rules.

Remove the video part from a live photo

Someone may think the process is as easy as getting the video from a live photo, removing the video and saving the other parts back. It is wrong.

We can get the video from a live photo using PHAssetResource's class func assetResources(for livePhoto: PHLivePhoto) -> [PHAssetResource]. But the PHAssetResource it gets contains empty assetLocalIdentifier. So you can't get its asset directly. And you can't remove it separately.

The correct way is to get the photo part, save it and remove the live photo.

Get the photo and save it

We could get the photo in three ways. Two from PHImageManager and one from PHAssetResourceManager. However, only one method is right way.

requestImage(for:targetSize:contentMode:options:resultHandler:)

OS, Mac Catalyst, tvOS
func requestImage(for asset: PHAsset, targetSize: CGSize, contentMode: PHImageContentMode, options: PHImageRequestOptions?, resultHandler: @escaping (UIImage?, [AnyHashable : Any]?) -> Void) -> PHImageRequestID
macOS
func requestImage(for asset: PHAsset, targetSize: CGSize, contentMode: PHImageContentMode, options: PHImageRequestOptions?, resultHandler: @escaping (NSImage?, [AnyHashable : Any]?) -> Void) -> PHImageRequestID

We should not use this method as it returns UIImage/NSImage, according to Apple, those two classes lacks metadata.

A UIImage object does not contain all metadata associated with the image file it was originally loaded from (for example, Exif tags such as geographic location, camera model, and exposure parameters). To ensure such metadata is saved in the Photos library, instead use the creationRequestForAssetFromImage(atFileURL:) method or the PHAssetCreationRequest class. To copy metadata from one file to another, see Image I/O.
creationRequestForAsset(from:)

requestData(for:options:dataReceivedHandler:completionHandler:)

We cannot use requestData(for:options:dataReceivedHandler:completionHandler:) of PHAssetResourceManager either. It does return Data instead of UIImage/NSImage. But the data it returns cannot save to PHPhotoLibrary correctly.

I think it is because of the data it returns contains the same localIdentifier as the live photo, which is allowed to save separately.

requestImageDataAndOrientation(for:options:resultHandler:)

This is the only way we should use to save the photo part from a live photo.

Remove the live photo

This is an easy job. Just use PHAssetChangeRequest.deleteAssets(_ assets: NSFastEnumeration).