From 6078398ad36ed63e6cd5aeca4f4d54c502683634 Mon Sep 17 00:00:00 2001 From: Doug <6060466+pixlwave@users.noreply.github.com> Date: Wed, 16 Oct 2024 14:50:24 +0100 Subject: [PATCH] Allow image uploads to be optimised to reduce bandwidth. (#3412) * Use typed throws for intermediate media upload preprocessing steps. * Remove unnecessary async/await usages. * Resize images when optimizedMediaUploads is enabled. * Reduce the JPEG quality. * Add tests for PNG and HEIC, fix mimetypes for these. * Add special handling for GIFs. * Test the files to make sure their mime types match the info. * Update the filename when converting formats. * Extend test timeout for video encoding. --- ElementX.xcodeproj/project.pbxproj | 8 + .../Media/MediaUploadingPreprocessor.swift | 336 ++++++++++-------- .../Resources/Media/test_animated_image.gif | 3 + .../Resources/Media/test_apple_image.heic | 3 + .../MediaUploadingPreprocessorTests.swift | 248 ++++++++++++- 5 files changed, 433 insertions(+), 165 deletions(-) create mode 100644 UnitTests/Resources/Media/test_animated_image.gif create mode 100644 UnitTests/Resources/Media/test_apple_image.heic diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index b1b8124cdd..67bd0831f0 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -278,6 +278,7 @@ 3DA57CA0D609A6B37CA1DC2F /* BugReportService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DC38E64A5ED3FDB201029A /* BugReportService.swift */; }; 3DAD62988F072607441CB7A5 /* PollFormScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F8664F1FB95AF3C4202478 /* PollFormScreenCoordinator.swift */; }; 3DAF325D8AE461F7CDB282BD /* StartChatScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6861FE915C7B5466E6962BBA /* StartChatScreen.swift */; }; + 3E23BB48F91485D893D0A429 /* test_animated_image.gif in Resources */ = {isa = PBXBuildFile; fileRef = 9C2BCB402FEB0FA1A54BEF4B /* test_animated_image.gif */; }; 3E7B65C2C97748D5D65AAA8B /* NotificationPermissionsScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB2FC2AA9A07EE792DF65CF /* NotificationPermissionsScreenModels.swift */; }; 3EC5A41F9FB7DD63A4DC6144 /* RoomChangeRolesScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B14B1DE3E2D5D26732C49036 /* RoomChangeRolesScreenViewModel.swift */; }; 3EC698F80DDEEFA273857841 /* ArrayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 893777A4997BBDB68079D4F5 /* ArrayTests.swift */; }; @@ -722,6 +723,7 @@ 9DE801D278AC34737467F937 /* VoiceMessageMediaManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 889DEDD63C68ABDA8AD29812 /* VoiceMessageMediaManagerProtocol.swift */; }; 9E838A62918E47BC72D6640D /* UserIndicatorPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AB54B4F94686CCF0289B72F /* UserIndicatorPresenter.swift */; }; 9EBDC79CAC9B63A0D626E333 /* LegalInformationScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EB2CAA266B921D128C35710 /* LegalInformationScreenCoordinator.swift */; }; + 9EF9773DBE3F6497A25CE236 /* test_apple_image.heic in Resources */ = {isa = PBXBuildFile; fileRef = F6B676B4866F5B383DE819B2 /* test_apple_image.heic */; }; 9F11B9F347F9E2D236799FB3 /* ElementCallServiceConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 406C90AF8C3E98DF5D4E5430 /* ElementCallServiceConstants.swift */; }; 9F11E743EA01482E78A438B0 /* GlobalSearchScreenCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22DB19219E6CC4D002E15D48 /* GlobalSearchScreenCell.swift */; }; 9F19096BFA629C0AC282B1E4 /* CreateRoomScreenUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CEB4634C0DD7779C4AB504 /* CreateRoomScreenUITests.swift */; }; @@ -1873,6 +1875,7 @@ 9B663BE498BB39EADC24025D /* SettingsScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenModels.swift; sourceTree = ""; }; 9B67DF223EEB8DCAF178A1D4 /* AnalyticsPromptScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptScreenCoordinator.swift; sourceTree = ""; }; 9B7D8D3638864B7482E148CC /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = ""; }; + 9C2BCB402FEB0FA1A54BEF4B /* test_animated_image.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = test_animated_image.gif; sourceTree = ""; }; 9C3ACC093F88FD9888518561 /* AuthenticationStartScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationStartScreenViewModel.swift; sourceTree = ""; }; 9C4048041C1A6B20CB97FD18 /* TestMeasurementParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestMeasurementParser.swift; sourceTree = ""; }; 9C5E81214D27A6B898FC397D /* ElementX.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ElementX.entitlements; sourceTree = ""; }; @@ -2274,6 +2277,7 @@ F5D8FEB1FED10E995CB002F7 /* TimelineBubbleLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBubbleLayout.swift; sourceTree = ""; }; F5E23D8EE6CBACF32F1EC874 /* MediaPlayerProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerProviderProtocol.swift; sourceTree = ""; }; F64A8582F65567AC38C2976A /* PollFormScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFormScreenViewModel.swift; sourceTree = ""; }; + F6B676B4866F5B383DE819B2 /* test_apple_image.heic */ = {isa = PBXFileReference; path = test_apple_image.heic; sourceTree = ""; }; F72EFC8C634469F9262659C7 /* NSItemProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSItemProvider.swift; sourceTree = ""; }; F733F135E6D67BBBEB76CC30 /* AppLockUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockUITests.swift; sourceTree = ""; }; F74532E01B317C56C1BE8FA8 /* RoomTimelineProviderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineProviderMock.swift; sourceTree = ""; }; @@ -4699,6 +4703,8 @@ 9A2AC7BE17C05CF7D2A22338 /* landscape_test_video.mov */, AF042B0FB2EE88977C91E330 /* portrait_test_image.jpg */, F2D513D2477B57F90E98EEC0 /* portrait_test_video.mp4 */, + 9C2BCB402FEB0FA1A54BEF4B /* test_animated_image.gif */, + F6B676B4866F5B383DE819B2 /* test_apple_image.heic */, D5E26C54362206BBDD096D83 /* test_audio.mp3 */, C733D11B421CFE3A657EF230 /* test_image.png */, 3FFDA99C98BE05F43A92343B /* test_pdf.pdf */, @@ -5874,6 +5880,8 @@ 858276B19C7C0AD4CA98EA78 /* portrait_test_image.jpg in Resources */, 6BB6944443C421C722ED1E7D /* portrait_test_video.mp4 in Resources */, 35E975CFDA60E05362A7CF79 /* target.yml in Resources */, + 3E23BB48F91485D893D0A429 /* test_animated_image.gif in Resources */, + 9EF9773DBE3F6497A25CE236 /* test_apple_image.heic in Resources */, 87CEDB8A0696F0D5AE2ABB28 /* test_audio.mp3 in Resources */, 21BF2B7CEDFE3CA67C5355AD /* test_image.png in Resources */, E77469C5CD7F7F58C0AC9752 /* test_pdf.pdf in Resources */, diff --git a/ElementX/Sources/Services/Media/MediaUploadingPreprocessor.swift b/ElementX/Sources/Services/Media/MediaUploadingPreprocessor.swift index ef88c3d797..0f967d8a5d 100644 --- a/ElementX/Sources/Services/Media/MediaUploadingPreprocessor.swift +++ b/ElementX/Sources/Services/Media/MediaUploadingPreprocessor.swift @@ -86,7 +86,8 @@ struct MediaUploadingPreprocessor { enum Constants { static let maximumThumbnailSize = CGSize(width: 800, height: 600) - static let thumbnailCompressionQuality = 0.8 + static let optimizedMaxPixelSize = 2048.0 + static let jpegCompressionQuality = 0.78 static let videoThumbnailTime = 5.0 // seconds } @@ -98,7 +99,7 @@ struct MediaUploadingPreprocessor { // Start by copying the file to a unique temporary location in order to avoid conflicts if processing it multiple times // All the other operations will be made relative to it let uniqueFolder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - let newURL = uniqueFolder.appendingPathComponent(url.lastPathComponent) + var newURL = uniqueFolder.appendingPathComponent(url.lastPathComponent) do { try FileManager.default.createDirectory(at: uniqueFolder, withIntermediateDirectories: true) try FileManager.default.copyItem(at: url, to: newURL) @@ -109,17 +110,17 @@ struct MediaUploadingPreprocessor { // Process unknown types as plain files guard let type = UTType(filenameExtension: newURL.pathExtension), let mimeType = type.preferredMIMEType else { - return await processFile(at: newURL, mimeType: "application/octet-stream") + return processFile(at: newURL, mimeType: "application/octet-stream") } if type.conforms(to: .image) { - return await processImage(at: newURL, type: type, mimeType: mimeType) + return processImage(at: &newURL, type: type, mimeType: mimeType) } else if type.conforms(to: .movie) || type.conforms(to: .video) { return await processVideo(at: newURL) } else if type.conforms(to: .audio) { return await processAudio(at: newURL, mimeType: mimeType) } else { - return await processFile(at: newURL, mimeType: mimeType) + return processFile(at: newURL, mimeType: mimeType) } } @@ -131,34 +132,55 @@ struct MediaUploadingPreprocessor { /// - type: its UTType /// - mimeType: the mimeType extracted from the UTType /// - Returns: Returns a `MediaInfo.image` containing the URLs for the modified image and its thumbnail plus the corresponding `ImageInfo` - private func processImage(at url: URL, type: UTType, mimeType: String) async -> Result { - switch await stripLocationFromImage(at: url, type: type, mimeType: mimeType) { - case .success(let result): - switch await generateThumbnailForImage(at: url) { - case .success(let thumbnailResult): - let imageSize = (try? UInt64(FileManager.default.sizeForItem(at: result.url))) ?? 0 - let thumbnailSize = (try? UInt64(FileManager.default.sizeForItem(at: thumbnailResult.url))) ?? 0 - - let thumbnailInfo = ThumbnailInfo(height: UInt64(thumbnailResult.height), - width: UInt64(thumbnailResult.width), - mimetype: thumbnailResult.mimeType, - size: thumbnailSize) - - let imageInfo = ImageInfo(height: UInt64(result.height), - width: UInt64(result.width), - mimetype: result.mimeType, - size: imageSize, - thumbnailInfo: thumbnailInfo, - thumbnailSource: nil, - blurhash: thumbnailResult.blurhash) - - let mediaInfo = MediaInfo.image(imageURL: result.url, thumbnailURL: thumbnailResult.url, imageInfo: imageInfo) + private func processImage(at url: inout URL, type: UTType, mimeType: String) -> Result { + do { + try stripLocationFromImage(at: url, type: type) + + var mimeType = mimeType + if appSettings.optimizeMediaUploads, !type.conforms(to: .gif) { + let outputType = type.conforms(to: .png) ? UTType.png : .jpeg + mimeType = outputType.preferredMIMEType ?? "application/octet-stream" + try resizeImage(at: url, maxPixelSize: Constants.optimizedMaxPixelSize, destination: url, type: outputType) - return .success(mediaInfo) - case .failure(let error): - return .failure(.failedProcessingImage(error)) + if let preferredFilenameExtension = outputType.preferredFilenameExtension, + url.pathExtension != preferredFilenameExtension { + let convertedURL = url.deletingPathExtension().appendingPathExtension(preferredFilenameExtension) + do { + try FileManager.default.moveItem(at: url, to: convertedURL) + } catch { + return .failure(.failedResizingImage) + } + url = convertedURL + } } - case .failure(let error): + + let thumbnailResult = try generateThumbnailForImage(at: url) + + guard let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil), + let imageSize = imageSource.size else { + return .failure(.failedProcessingImage(.failedStrippingLocationData)) + } + + let fileSize = (try? UInt64(FileManager.default.sizeForItem(at: url))) ?? 0 + let thumbnailFileSize = (try? UInt64(FileManager.default.sizeForItem(at: thumbnailResult.url))) ?? 0 + + let thumbnailInfo = ThumbnailInfo(height: UInt64(thumbnailResult.height), + width: UInt64(thumbnailResult.width), + mimetype: thumbnailResult.mimeType, + size: thumbnailFileSize) + + let imageInfo = ImageInfo(height: UInt64(imageSize.height), + width: UInt64(imageSize.width), + mimetype: mimeType, + size: fileSize, + thumbnailInfo: thumbnailInfo, + thumbnailSource: nil, + blurhash: thumbnailResult.blurhash) + + let mediaInfo = MediaInfo.image(imageURL: url, thumbnailURL: thumbnailResult.url, imageInfo: imageInfo) + + return .success(mediaInfo) + } catch { return .failure(.failedProcessingImage(error)) } } @@ -170,34 +192,31 @@ struct MediaUploadingPreprocessor { /// - mimeType: the mimeType extracted from the UTType /// - Returns: Returns a `MediaInfo.video` containing the URLs for the modified video and its thumbnail plus the corresponding `VideoInfo` private func processVideo(at url: URL) async -> Result { - switch await convertVideoToMP4(url) { - case .success(let result): - switch await generateThumbnailForVideoAt(result.url) { - case .success(let thumbnailResult): - let videoSize = (try? UInt64(FileManager.default.sizeForItem(at: result.url))) ?? 0 - let thumbnailSize = (try? UInt64(FileManager.default.sizeForItem(at: thumbnailResult.url))) ?? 0 - - let thumbnailInfo = ThumbnailInfo(height: UInt64(thumbnailResult.height), - width: UInt64(thumbnailResult.width), - mimetype: thumbnailResult.mimeType, - size: thumbnailSize) - - let videoInfo = VideoInfo(duration: result.duration, - height: UInt64(result.height), - width: UInt64(result.width), - mimetype: result.mimeType, - size: videoSize, - thumbnailInfo: thumbnailInfo, - thumbnailSource: nil, - blurhash: thumbnailResult.blurhash) - - let mediaInfo = MediaInfo.video(videoURL: result.url, thumbnailURL: thumbnailResult.url, videoInfo: videoInfo) - - return .success(mediaInfo) - case .failure(let error): - return .failure(.failedProcessingVideo(error)) - } - case .failure(let error): + do { + let result = try await convertVideoToMP4(url) + let thumbnailResult = try await generateThumbnailForVideoAt(result.url) + + let videoSize = (try? UInt64(FileManager.default.sizeForItem(at: result.url))) ?? 0 + let thumbnailSize = (try? UInt64(FileManager.default.sizeForItem(at: thumbnailResult.url))) ?? 0 + + let thumbnailInfo = ThumbnailInfo(height: UInt64(thumbnailResult.height), + width: UInt64(thumbnailResult.width), + mimetype: thumbnailResult.mimeType, + size: thumbnailSize) + + let videoInfo = VideoInfo(duration: result.duration, + height: UInt64(result.height), + width: UInt64(result.width), + mimetype: result.mimeType, + size: videoSize, + thumbnailInfo: thumbnailInfo, + thumbnailSource: nil, + blurhash: thumbnailResult.blurhash) + + let mediaInfo = MediaInfo.video(videoURL: result.url, thumbnailURL: thumbnailResult.url, videoInfo: videoInfo) + + return .success(mediaInfo) + } catch { return .failure(.failedProcessingVideo(error)) } } @@ -225,31 +244,30 @@ struct MediaUploadingPreprocessor { /// - type: its UTType /// - mimeType: the mimeType extracted from the UTType /// - Returns: Returns a `MediaInfo.file` containing the file URL plus the corresponding `FileInfo` - private func processFile(at url: URL, mimeType: String?) async -> Result { + private func processFile(at url: URL, mimeType: String?) -> Result { let fileSize = (try? UInt64(FileManager.default.sizeForItem(at: url))) ?? 0 let fileInfo = FileInfo(mimetype: mimeType, size: fileSize, thumbnailInfo: nil, thumbnailSource: nil) return .success(.file(fileURL: url, fileInfo: fileInfo)) } - // MARK: Images + // MARK: Image Helpers /// Removes the GPS dictionary from an image's metadata /// - Parameters: /// - url: the URL for the original image /// - type: its UTType /// - Returns: the URL for the modified image and its size as an `ImageProcessingResult` - private func stripLocationFromImage(at url: URL, type: UTType, mimeType: String) async -> Result { + private func stripLocationFromImage(at url: URL, type: UTType) throws(MediaUploadingPreprocessorError) { guard let originalData = NSData(contentsOf: url), - let originalImage = UIImage(data: originalData as Data), let imageSource = CGImageSourceCreateWithData(originalData, nil) else { - return .failure(.failedStrippingLocationData) + throw .failedStrippingLocationData } guard let originalMetadata = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil), (originalMetadata as NSDictionary).value(forKeyPath: "\(kCGImagePropertyGPSDictionary)") != nil else { - MXLog.info("No GPS metadata found. Returning original image") - return .success(.init(url: url, height: Double(originalImage.size.height), width: Double(originalImage.size.width), mimeType: mimeType, blurhash: nil)) + MXLog.info("No GPS metadata found. Nothing to do.") + return } let count = CGImageSourceGetCount(imageSource) @@ -257,110 +275,111 @@ struct MediaUploadingPreprocessor { let data = NSMutableData() guard let destination = CGImageDestinationCreateWithData(data as CFMutableData, type.identifier as CFString, count, nil) else { - return .failure(.failedStrippingLocationData) + throw .failedStrippingLocationData } CGImageDestinationAddImageFromSource(destination, imageSource, 0, metadataKeysToRemove as NSDictionary) CGImageDestinationFinalize(destination) do { try data.write(to: url) - return .success(.init(url: url, height: Double(originalImage.size.height), width: Double(originalImage.size.width), mimeType: mimeType, blurhash: nil)) } catch { - return .failure(.failedStrippingLocationData) + throw .failedStrippingLocationData } } /// Generates a thumbnail for an image /// - Parameter url: the original image URL /// - Returns: the URL for the resulting thumbnail and its sizing info as an `ImageProcessingResult` - private func generateThumbnailForImage(at url: URL) async -> Result { - switch await resizeImage(at: url, targetSize: Constants.maximumThumbnailSize) { - case .success(let thumbnail): - guard let data = thumbnail.jpegData(compressionQuality: Constants.thumbnailCompressionQuality) else { - return .failure(.failedGeneratingImageThumbnail(nil)) - } - - let blurhash = thumbnail.blurHash(numberOfComponents: (3, 3)) - - do { - let fileName = "thumbnail-\((url.lastPathComponent as NSString).deletingPathExtension).jpeg" - let thumbnailURL = url.deletingLastPathComponent().appendingPathComponent(fileName) - try data.write(to: thumbnailURL) - return .success(.init(url: thumbnailURL, height: thumbnail.size.height, width: thumbnail.size.width, mimeType: "image/jpeg", blurhash: blurhash)) - } catch { - return .failure(.failedGeneratingImageThumbnail(error)) - } - - case .failure(let error): - return .failure(.failedGeneratingImageThumbnail(error)) + private func generateThumbnailForImage(at url: URL) throws(MediaUploadingPreprocessorError) -> ImageProcessingInfo { + let thumbnailFileName = "thumbnail-\((url.lastPathComponent as NSString).deletingPathExtension).jpeg" + let thumbnailURL = url.deletingLastPathComponent().appendingPathComponent(thumbnailFileName) + let thumbnailMaxPixelSize = max(Constants.maximumThumbnailSize.height, Constants.maximumThumbnailSize.width) + + do { + try resizeImage(at: url, maxPixelSize: thumbnailMaxPixelSize, destination: thumbnailURL, type: .jpeg) + } catch { + throw .failedGeneratingImageThumbnail(error) + } + + guard let thumbnail = try? UIImage(contentsOf: thumbnailURL, cachePolicy: .useProtocolCachePolicy) else { + throw .failedGeneratingImageThumbnail(nil) } + let blurhash = thumbnail.blurHash(numberOfComponents: (3, 3)) + + return .init(url: thumbnailURL, height: thumbnail.size.height, width: thumbnail.size.width, mimeType: "image/jpeg", blurhash: blurhash) } - private func resizeImage(at url: URL, targetSize: CGSize) async -> Result { - let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil) - guard let imageSource else { - return .failure(.failedResizingImage) + private func resizeImage(at url: URL, maxPixelSize: CGFloat, destination: URL, type: UTType) throws(MediaUploadingPreprocessorError) { + guard let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil) else { + throw .failedResizingImage } - return await resizeImage(withSource: imageSource, targetSize: targetSize) + try resizeImage(withSource: imageSource, maxPixelSize: maxPixelSize, destination: destination, type: type) } /// Aspect ratio resizes an image so it fits in the given size. This is useful for resizing images without loading them directly into memory /// - Parameters: /// - imageSource: the original image `CGImageSource` - /// - targetSize: maximum resulting size + /// - maxPixelSize: maximum resulting size for the largest dimension of the image. /// - Returns: the resized image - private func resizeImage(withSource imageSource: CGImageSource, targetSize: CGSize) async -> Result { - let maximumSize = max(targetSize.height, targetSize.width) - + private func resizeImage(withSource imageSource: CGImageSource, maxPixelSize: CGFloat, destination destinationURL: URL, type: UTType) throws(MediaUploadingPreprocessorError) { let options: [NSString: Any] = [ // The maximum width and height in pixels of a thumbnail. - kCGImageSourceThumbnailMaxPixelSize: maximumSize, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, kCGImageSourceCreateThumbnailFromImageAlways: true, // Should include kCGImageSourceCreateThumbnailWithTransform: true in the options dictionary. Otherwise, the image result will appear rotated when an image is taken from camera in the portrait orientation. kCGImageSourceCreateThumbnailWithTransform: true ] - guard let scaledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary) else { - return .failure(.failedResizingImage) + guard let scaledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as NSDictionary), + let destination = CGImageDestinationCreateWithURL(destinationURL as CFURL, type.identifier as CFString, 1, nil) else { + throw .failedResizingImage } - - return .success(UIImage(cgImage: scaledImage)) + let properties = [kCGImageDestinationLossyCompressionQuality: Constants.jpegCompressionQuality] + + CGImageDestinationAddImage(destination, scaledImage, properties as NSDictionary) + CGImageDestinationFinalize(destination) } - // MARK: Videos + // MARK: Video Helpers /// Generates a thumbnail for the video at the given URL /// - Parameter url: the video URL /// - Returns: the URL for the resulting thumbnail and its sizing info as an `ImageProcessingResult` - private func generateThumbnailForVideoAt(_ url: URL) async -> Result { + private func generateThumbnailForVideoAt(_ url: URL) async throws(MediaUploadingPreprocessorError) -> ImageProcessingInfo { let assetImageGenerator = AVAssetImageGenerator(asset: AVAsset(url: url)) assetImageGenerator.appliesPreferredTrackTransform = true assetImageGenerator.maximumSize = Constants.maximumThumbnailSize + // Avoid the first frames as on a lot of videos they're black. + // If the specified seconds are longer than the actual video a frame close to the end of the video will be used, at AVFoundation's discretion + let location = CMTime(seconds: Constants.videoThumbnailTime, preferredTimescale: 1) + + let cgImage: CGImage + do { + cgImage = try await assetImageGenerator.image(at: location).image + } catch { + throw .failedGeneratingVideoThumbnail(error) + } + + let thumbnail = UIImage(cgImage: cgImage) + + guard let data = thumbnail.jpegData(compressionQuality: Constants.jpegCompressionQuality) else { + throw .failedGeneratingVideoThumbnail(nil) + } + + let blurhash = thumbnail.blurHash(numberOfComponents: (3, 3)) + + let fileName = "\((url.lastPathComponent as NSString).deletingPathExtension).jpeg" + let thumbnailURL = url.deletingLastPathComponent().appendingPathComponent(fileName) + do { - // Avoid the first frames as on a lot of videos they're black. - // If the specified seconds are longer than the actual video a frame close to the end of the video will be used, at AVFoundation's discretion - let location = CMTime(seconds: Constants.videoThumbnailTime, preferredTimescale: 1) - let cgImage = try await assetImageGenerator.image(at: location).image - - let thumbnail = UIImage(cgImage: cgImage) - - guard let data = thumbnail.jpegData(compressionQuality: Constants.thumbnailCompressionQuality) else { - return .failure(.failedGeneratingVideoThumbnail(nil)) - } - - let blurhash = thumbnail.blurHash(numberOfComponents: (3, 3)) - - let fileName = "\((url.lastPathComponent as NSString).deletingPathExtension).jpeg" - let thumbnailURL = url.deletingLastPathComponent().appendingPathComponent(fileName) try data.write(to: thumbnailURL) - - return .success(.init(url: thumbnailURL, height: thumbnail.size.height, width: thumbnail.size.width, mimeType: "image/jpeg", blurhash: blurhash)) - } catch { - return .failure(.failedGeneratingVideoThumbnail(error)) + throw .failedGeneratingVideoThumbnail(error) } + + return .init(url: thumbnailURL, height: thumbnail.size.height, width: thumbnail.size.width, mimeType: "image/jpeg", blurhash: blurhash) } /// Converts the given video to an 1080p mp4 @@ -368,12 +387,12 @@ struct MediaUploadingPreprocessor { /// - url: the original video URL /// - targetFileSize: the maximum resulting file size. 90% of this will be used /// - Returns: the URL for the resulting video and its media info as a `VideoProcessingResult` - private func convertVideoToMP4(_ url: URL, targetFileSize: UInt = 0) async -> Result { + private func convertVideoToMP4(_ url: URL, targetFileSize: UInt = 0) async throws(MediaUploadingPreprocessorError) -> VideoProcessingInfo { let asset = AVURLAsset(url: url) let presetName = appSettings.optimizeMediaUploads ? AVAssetExportPreset640x480 : AVAssetExportPreset1920x1080 guard let exportSession = AVAssetExportSession(asset: asset, presetName: presetName) else { - return .failure(.failedConvertingVideo) + throw .failedConvertingVideo } // AVAssetExportSession will fail if the output URL already exists @@ -387,7 +406,7 @@ struct MediaUploadingPreprocessor { exportSession.outputFileType = AVFileType.mp4 guard exportSession.supportedFileTypes.contains(AVFileType.mp4) else { - return .failure(.failedConvertingVideo) + throw .failedConvertingVideo } if targetFileSize > 0 { @@ -397,33 +416,44 @@ struct MediaUploadingPreprocessor { await exportSession.export() - switch exportSession.status { - case .completed: - do { - // Delete the original - try? FileManager.default.removeItem(at: url) - // Strip the UUID from the new version - let newOutputURL = url.deletingLastPathComponent().appendingPathComponent("\(originalFilenameWithoutExtension).mp4") - try FileManager.default.moveItem(at: outputURL, to: newOutputURL) - - let newAsset = AVURLAsset(url: newOutputURL) - guard let track = try? await newAsset.loadTracks(withMediaType: .video).first, - let durationInSeconds = try? await newAsset.load(.duration).seconds, - let adjustedNaturalSize = try? await track.size else { - return .failure(.failedConvertingVideo) - } - - return .success(.init(url: newOutputURL, - height: adjustedNaturalSize.height, - width: adjustedNaturalSize.width, - duration: durationInSeconds, - mimeType: "video/mp4")) - } catch { - return .failure(.failedConvertingVideo) - } - default: - return .failure(.failedConvertingVideo) + guard exportSession.status == .completed else { + throw .failedConvertingVideo + } + + // Delete the original + try? FileManager.default.removeItem(at: url) + // Strip the UUID from the new version + let newOutputURL = url.deletingLastPathComponent().appendingPathComponent("\(originalFilenameWithoutExtension).mp4") + + do { try FileManager.default.moveItem(at: outputURL, to: newOutputURL) } catch { + throw .failedConvertingVideo + } + + let newAsset = AVURLAsset(url: newOutputURL) + guard let track = try? await newAsset.loadTracks(withMediaType: .video).first, + let durationInSeconds = try? await newAsset.load(.duration).seconds, + let adjustedNaturalSize = try? await track.size else { + throw .failedConvertingVideo + } + + return .init(url: newOutputURL, + height: adjustedNaturalSize.height, + width: adjustedNaturalSize.width, + duration: durationInSeconds, + mimeType: "video/mp4") + } +} + +// MARK: - Extensions + +private extension CGImageSource { + var size: CGSize? { + guard let properties = CGImageSourceCopyPropertiesAtIndex(self, 0, nil) as? [NSString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Int, + let height = properties[kCGImagePropertyPixelHeight] as? Int else { + return nil } + return CGSize(width: width, height: height) } } diff --git a/UnitTests/Resources/Media/test_animated_image.gif b/UnitTests/Resources/Media/test_animated_image.gif new file mode 100644 index 0000000000..c31ede46c1 --- /dev/null +++ b/UnitTests/Resources/Media/test_animated_image.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ba429285ac3d7e29a5167bd7fab9a5b5721a9817afe824f61436520f6e6651ec +size 127823 diff --git a/UnitTests/Resources/Media/test_apple_image.heic b/UnitTests/Resources/Media/test_apple_image.heic new file mode 100644 index 0000000000..2b53713818 --- /dev/null +++ b/UnitTests/Resources/Media/test_apple_image.heic @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9f110a5cd97e0e9b38258833274944e816f36379631eeb6883ddf6babb9bef37 +size 1827200 diff --git a/UnitTests/Sources/MediaUploadingPreprocessorTests.swift b/UnitTests/Sources/MediaUploadingPreprocessorTests.swift index 8f4e7c8443..7438c3a84c 100644 --- a/UnitTests/Sources/MediaUploadingPreprocessorTests.swift +++ b/UnitTests/Sources/MediaUploadingPreprocessorTests.swift @@ -5,6 +5,7 @@ // Please see LICENSE in the repository root for full details. // +import UniformTypeIdentifiers import XCTest @testable import ElementX @@ -45,6 +46,9 @@ final class MediaUploadingPreprocessorTests: XCTestCase { } func testLandscapeMovVideoProcessing() async { + // Allow double the default execution time as we encode the video twice now. + executionTimeAllowance = 120 + guard let url = Bundle(for: Self.self).url(forResource: "landscape_test_video.mov", withExtension: nil) else { XCTFail("Failed retrieving test asset") return @@ -58,6 +62,7 @@ final class MediaUploadingPreprocessorTests: XCTestCase { // Check that the file name is preserved XCTAssertEqual(videoURL.lastPathComponent, "landscape_test_video.mp4") + XCTAssertEqual(videoURL.pathExtension, "mp4", "The file extension should match the container we use.") // Check that the thumbnail is generated correctly guard let thumbnailData = try? Data(contentsOf: thumbnailURL), @@ -79,7 +84,7 @@ final class MediaUploadingPreprocessorTests: XCTestCase { XCTAssertNotNil(videoInfo.thumbnailInfo) XCTAssertEqual(videoInfo.thumbnailInfo?.mimetype, "image/jpeg") - XCTAssertEqual(videoInfo.thumbnailInfo?.size ?? 0, 34206, accuracy: 100) + XCTAssertEqual(videoInfo.thumbnailInfo?.size ?? 0, 33611, accuracy: 100) XCTAssertEqual(videoInfo.thumbnailInfo?.width, 800) XCTAssertEqual(videoInfo.thumbnailInfo?.height, 450) @@ -87,11 +92,13 @@ final class MediaUploadingPreprocessorTests: XCTestCase { appSettings.optimizeMediaUploads = true guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url), - case let .video(_, _, optimizedVideoInfo) = optimizedResult else { + case let .video(optimizedVideoURL, _, optimizedVideoInfo) = optimizedResult else { XCTFail("Failed processing asset") return } + XCTAssertEqual(optimizedVideoURL.pathExtension, "mp4", "The file extension should match the container we use.") + // Check optimised video info XCTAssertEqual(optimizedVideoInfo.mimetype, "video/mp4") XCTAssertEqual(optimizedVideoInfo.blurhash, "K32PJbx^I7jYaebHMvV?o$") @@ -102,6 +109,9 @@ final class MediaUploadingPreprocessorTests: XCTestCase { } func testPortraitMp4VideoProcessing() async { + // Allow double the default execution time as we encode the video twice now. + executionTimeAllowance = 120 + guard let url = Bundle(for: Self.self).url(forResource: "portrait_test_video.mp4", withExtension: nil) else { XCTFail("Failed retrieving test asset") return @@ -115,6 +125,7 @@ final class MediaUploadingPreprocessorTests: XCTestCase { // Check that the file name is preserved XCTAssertEqual(videoURL.lastPathComponent, "portrait_test_video.mp4") + XCTAssertEqual(videoURL.pathExtension, "mp4", "The file extension should match the container we use.") // Check that the thumbnail is generated correctly guard let thumbnailData = try? Data(contentsOf: thumbnailURL), @@ -136,7 +147,7 @@ final class MediaUploadingPreprocessorTests: XCTestCase { XCTAssertNotNil(videoInfo.thumbnailInfo) XCTAssertEqual(videoInfo.thumbnailInfo?.mimetype, "image/jpeg") - XCTAssertEqual(videoInfo.thumbnailInfo?.size ?? 0, 83220, accuracy: 100) + XCTAssertEqual(videoInfo.thumbnailInfo?.size ?? 0, 81515, accuracy: 100) XCTAssertEqual(videoInfo.thumbnailInfo?.width, 337) XCTAssertEqual(videoInfo.thumbnailInfo?.height, 600) @@ -144,11 +155,13 @@ final class MediaUploadingPreprocessorTests: XCTestCase { appSettings.optimizeMediaUploads = true guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url), - case let .video(_, _, optimizedVideoInfo) = optimizedResult else { + case let .video(optimizedVideoURL, _, optimizedVideoInfo) = optimizedResult else { XCTFail("Failed processing asset") return } + XCTAssertEqual(optimizedVideoURL.pathExtension, "mp4", "The file extension should match the container we use.") + // Check optimised video info XCTAssertEqual(optimizedVideoInfo.mimetype, "video/mp4") XCTAssertEqual(optimizedVideoInfo.blurhash, "K7BDNJD*0L%#sl_2~C9ZE1") @@ -181,9 +194,27 @@ final class MediaUploadingPreprocessorTests: XCTestCase { XCTAssertNotNil(imageInfo.thumbnailInfo) XCTAssertEqual(imageInfo.thumbnailInfo?.mimetype, "image/jpeg") - XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 89553, accuracy: 100) + XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 87733, accuracy: 100) XCTAssertEqual(imageInfo.thumbnailInfo?.width, 800) XCTAssertEqual(imageInfo.thumbnailInfo?.height, 344) + + // Repeat with optimised media setting + appSettings.optimizeMediaUploads = true + + guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url), + case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else { + XCTFail("Failed processing asset") + return + } + + compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL) + + // Check optimised image info + XCTAssertEqual(optimizedImageInfo.mimetype, "image/jpeg") + XCTAssertEqual(optimizedImageInfo.blurhash, "K%I#.NofkC_4ayaxxujsWB") + XCTAssertEqual(optimizedImageInfo.size ?? 0, 524_226, accuracy: 100) + XCTAssertEqual(optimizedImageInfo.width, 2048) + XCTAssertEqual(optimizedImageInfo.height, 879) } func testPortraitImageProcessing() async { @@ -202,16 +233,193 @@ final class MediaUploadingPreprocessorTests: XCTestCase { // Check resulting image info XCTAssertEqual(imageInfo.mimetype, "image/jpeg") - XCTAssertEqual(imageInfo.blurhash, "KdE:ets+RP^-n*RP%OWAV@") + XCTAssertEqual(imageInfo.blurhash, "KdE|0Ls+RP^-n*RP%OWAV@") XCTAssertEqual(imageInfo.size ?? 0, 4_414_666, accuracy: 100) XCTAssertEqual(imageInfo.width, 3024) XCTAssertEqual(imageInfo.height, 4032) XCTAssertNotNil(imageInfo.thumbnailInfo) XCTAssertEqual(imageInfo.thumbnailInfo?.mimetype, "image/jpeg") - XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 264_500, accuracy: 100) + XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 258_914, accuracy: 100) + XCTAssertEqual(imageInfo.thumbnailInfo?.width, 600) + XCTAssertEqual(imageInfo.thumbnailInfo?.height, 800) + + // Repeat with optimised media setting + appSettings.optimizeMediaUploads = true + + guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url), + case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else { + XCTFail("Failed processing asset") + return + } + + compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL) + + // Check optimised image info + XCTAssertEqual(optimizedImageInfo.mimetype, "image/jpeg") + XCTAssertEqual(optimizedImageInfo.blurhash, "KdE|0Ls+RP^-n*RP%OWAV@") + XCTAssertEqual(optimizedImageInfo.size ?? 0, 1_462_937, accuracy: 100) + XCTAssertEqual(optimizedImageInfo.width, 1536) + XCTAssertEqual(optimizedImageInfo.height, 2048) + } + + func testPNGImageProcessing() async { + guard let url = Bundle(for: Self.self).url(forResource: "test_image.png", withExtension: nil) else { + XCTFail("Failed retrieving test asset") + return + } + + guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url), + case let .image(convertedImageURL, _, imageInfo) = result else { + XCTFail("Failed processing asset") + return + } + + // Make sure the output file matches the image info. + XCTAssertEqual(mimeType(from: convertedImageURL), "image/png", "PNGs should always be sent as PNG to preserve the alpha channel.") + XCTAssertEqual(convertedImageURL.pathExtension, "png", "The file extension should match the MIME type.") + + // Check resulting image info + XCTAssertEqual(imageInfo.mimetype, "image/png") + XCTAssertEqual(imageInfo.blurhash, "K0TSUA~qfQ~qj[fQfQfQfQ") + XCTAssertEqual(imageInfo.size ?? 0, 4868, accuracy: 100) + XCTAssertEqual(imageInfo.width, 240) + XCTAssertEqual(imageInfo.height, 240) + + XCTAssertNotNil(imageInfo.thumbnailInfo) + XCTAssertEqual(imageInfo.thumbnailInfo?.mimetype, "image/jpeg") + XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 1725, accuracy: 100) + XCTAssertEqual(imageInfo.thumbnailInfo?.width, 240) + XCTAssertEqual(imageInfo.thumbnailInfo?.height, 240) + + // Repeat with optimised media setting + appSettings.optimizeMediaUploads = true + + guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url), + case let .image(optimizedImageURL, _, optimizedImageInfo) = optimizedResult else { + XCTFail("Failed processing asset") + return + } + + // Make sure the output file matches the image info. + XCTAssertEqual(mimeType(from: optimizedImageURL), "image/png", "PNGs should always be sent as PNG to preserve the alpha channel.") + XCTAssertEqual(optimizedImageURL.pathExtension, "png", "The file extension should match the MIME type.") + + // Check optimised image info + XCTAssertEqual(optimizedImageInfo.mimetype, "image/png") + XCTAssertEqual(optimizedImageInfo.blurhash, "K0TSUA~qfQ~qj[fQfQfQfQ") + XCTAssertEqual(optimizedImageInfo.size ?? 0, 8199, accuracy: 100) + // Assert that resizing didn't upscale to the maxPixelSize. + XCTAssertEqual(optimizedImageInfo.width, 240) + XCTAssertEqual(optimizedImageInfo.height, 240) + } + + func testHEICImageProcessing() async { + guard let url = Bundle(for: Self.self).url(forResource: "test_apple_image.heic", withExtension: nil) else { + XCTFail("Failed retrieving test asset") + return + } + + guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url), + case let .image(convertedImageURL, thumbnailURL, imageInfo) = result else { + XCTFail("Failed processing asset") + return + } + + compare(originalImageAt: url, toConvertedImageAt: convertedImageURL, withThumbnailAt: thumbnailURL) + + // Make sure the output file matches the image info. + XCTAssertEqual(mimeType(from: convertedImageURL), "image/heic", "Unoptimised HEICs should always be sent as is.") + XCTAssertEqual(convertedImageURL.pathExtension, "heic", "The file extension should match the MIME type.") + + // Check resulting image info + XCTAssertEqual(imageInfo.mimetype, "image/heic") + XCTAssertEqual(imageInfo.blurhash, "KGD]3ns:T00$kWxFXmt6xv") + XCTAssertEqual(imageInfo.size ?? 0, 1_857_833, accuracy: 100) + XCTAssertEqual(imageInfo.width, 3024) + XCTAssertEqual(imageInfo.height, 4032) + + XCTAssertNotNil(imageInfo.thumbnailInfo) + XCTAssertEqual(imageInfo.thumbnailInfo?.mimetype, "image/jpeg") + XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 218_108, accuracy: 100) XCTAssertEqual(imageInfo.thumbnailInfo?.width, 600) XCTAssertEqual(imageInfo.thumbnailInfo?.height, 800) + + // Repeat with optimised media setting + appSettings.optimizeMediaUploads = true + + guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url), + case let .image(optimizedImageURL, thumbnailURL, optimizedImageInfo) = optimizedResult else { + XCTFail("Failed processing asset") + return + } + + compare(originalImageAt: url, toConvertedImageAt: optimizedImageURL, withThumbnailAt: thumbnailURL) + + // Make sure the output file matches the image info. + XCTAssertEqual(mimeType(from: optimizedImageURL), "image/jpeg", "Optimised HEICs should always be converted to JPEG for compatibility.") + XCTAssertEqual(optimizedImageURL.pathExtension, "jpeg", "The file extension should match the MIME type.") + + // Check optimised image info + XCTAssertEqual(optimizedImageInfo.mimetype, "image/jpeg") + XCTAssertEqual(optimizedImageInfo.blurhash, "KGD]3ns:T00#kWxFb^s:xv") + XCTAssertEqual(optimizedImageInfo.size ?? 0, 1_049_393, accuracy: 100) + XCTAssertEqual(optimizedImageInfo.width, 1536) + XCTAssertEqual(optimizedImageInfo.height, 2048) + } + + func testGIFImageProcessing() async { + guard let url = Bundle(for: Self.self).url(forResource: "test_animated_image.gif", withExtension: nil) else { + XCTFail("Failed retrieving test asset") + return + } + guard let originalSize = try? FileManager.default.sizeForItem(at: url), originalSize > 0 else { + XCTFail("Failed fetching test asset's original size") + return + } + + guard case let .success(result) = await mediaUploadingPreprocessor.processMedia(at: url), + case let .image(convertedImageURL, _, imageInfo) = result else { + XCTFail("Failed processing asset") + return + } + + // Make sure the output file matches the image info. + XCTAssertEqual(mimeType(from: convertedImageURL), "image/gif", "GIFs should always be sent as GIF to preserve the animation.") + XCTAssertEqual(convertedImageURL.pathExtension, "gif", "The file extension should match the MIME type.") + + // Check resulting image info + XCTAssertEqual(imageInfo.mimetype, "image/gif") + XCTAssertEqual(imageInfo.blurhash, "K7SY{qs;%NxuRjof~qozIU") + XCTAssertEqual(imageInfo.size ?? 0, UInt64(originalSize), accuracy: 100) + XCTAssertEqual(imageInfo.width, 490) + XCTAssertEqual(imageInfo.height, 498) + + XCTAssertNotNil(imageInfo.thumbnailInfo) + XCTAssertEqual(imageInfo.thumbnailInfo?.mimetype, "image/jpeg") + XCTAssertEqual(imageInfo.thumbnailInfo?.size ?? 0, 29511, accuracy: 100) + XCTAssertEqual(imageInfo.thumbnailInfo?.width, 490) + XCTAssertEqual(imageInfo.thumbnailInfo?.height, 498) + + // Repeat with optimised media setting + appSettings.optimizeMediaUploads = true + + guard case let .success(optimizedResult) = await mediaUploadingPreprocessor.processMedia(at: url), + case let .image(optimizedImageURL, _, optimizedImageInfo) = optimizedResult else { + XCTFail("Failed processing asset") + return + } + + // Make sure the output file matches the image info. + XCTAssertEqual(mimeType(from: optimizedImageURL), "image/gif", "GIFs should always be sent as GIF to preserve the animation.") + XCTAssertEqual(optimizedImageURL.pathExtension, "gif", "The file extension should match the MIME type.") + + // Ensure optimised image is still the same as the original image. + XCTAssertEqual(optimizedImageInfo.mimetype, "image/gif") + XCTAssertEqual(optimizedImageInfo.blurhash, "K7SY{qs;%NxuRjof~qozIU") + XCTAssertEqual(optimizedImageInfo.size ?? 0, UInt64(originalSize), accuracy: 100) + XCTAssertEqual(optimizedImageInfo.width, 490) + XCTAssertEqual(optimizedImageInfo.height, 498) } // MARK: - Private @@ -224,11 +432,16 @@ final class MediaUploadingPreprocessorTests: XCTestCase { fatalError() } - // Check that the file name is preserved - XCTAssertEqual(originalImageURL.lastPathComponent, convertedImageURL.lastPathComponent) - - // Check that new image is the same size as the original one - XCTAssertEqual(originalImage.size, convertedImage.size) + if appSettings.optimizeMediaUploads { + // Check that new image has been scaled within the requirements for an optimised image + XCTAssert(convertedImage.size.width <= MediaUploadingPreprocessor.Constants.optimizedMaxPixelSize) + XCTAssert(convertedImage.size.height <= MediaUploadingPreprocessor.Constants.optimizedMaxPixelSize) + } else { + // Check that the file name is preserved + XCTAssertEqual(originalImageURL.lastPathComponent, convertedImageURL.lastPathComponent) + // Check that new image is the same size as the original one + XCTAssertEqual(originalImage.size, convertedImage.size) + } // Check that the GPS data has been stripped let originalMetadata = metadata(from: originalImageData) @@ -269,4 +482,15 @@ final class MediaUploadingPreprocessorTests: XCTestCase { return convertedMetadata } + + private func mimeType(from url: URL) -> String? { + guard let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil), + let typeIdentifier = CGImageSourceGetType(imageSource), + let type = UTType(typeIdentifier as String), + let mimeType = type.preferredMIMEType else { + XCTFail("Failed to get mimetype from URL.") + return nil + } + return mimeType + } }