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.
This commit is contained in:
Doug
2024-10-16 14:50:24 +01:00
committed by GitHub
parent 35d49c4d85
commit 6078398ad3
5 changed files with 434 additions and 166 deletions
+8
View File
@@ -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 = "<group>"; };
9B67DF223EEB8DCAF178A1D4 /* AnalyticsPromptScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsPromptScreenCoordinator.swift; sourceTree = "<group>"; };
9B7D8D3638864B7482E148CC /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/InfoPlist.strings; sourceTree = "<group>"; };
9C2BCB402FEB0FA1A54BEF4B /* test_animated_image.gif */ = {isa = PBXFileReference; lastKnownFileType = image.gif; path = test_animated_image.gif; sourceTree = "<group>"; };
9C3ACC093F88FD9888518561 /* AuthenticationStartScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationStartScreenViewModel.swift; sourceTree = "<group>"; };
9C4048041C1A6B20CB97FD18 /* TestMeasurementParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestMeasurementParser.swift; sourceTree = "<group>"; };
9C5E81214D27A6B898FC397D /* ElementX.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ElementX.entitlements; sourceTree = "<group>"; };
@@ -2274,6 +2277,7 @@
F5D8FEB1FED10E995CB002F7 /* TimelineBubbleLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineBubbleLayout.swift; sourceTree = "<group>"; };
F5E23D8EE6CBACF32F1EC874 /* MediaPlayerProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerProviderProtocol.swift; sourceTree = "<group>"; };
F64A8582F65567AC38C2976A /* PollFormScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollFormScreenViewModel.swift; sourceTree = "<group>"; };
F6B676B4866F5B383DE819B2 /* test_apple_image.heic */ = {isa = PBXFileReference; path = test_apple_image.heic; sourceTree = "<group>"; };
F72EFC8C634469F9262659C7 /* NSItemProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSItemProvider.swift; sourceTree = "<group>"; };
F733F135E6D67BBBEB76CC30 /* AppLockUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockUITests.swift; sourceTree = "<group>"; };
F74532E01B317C56C1BE8FA8 /* RoomTimelineProviderMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineProviderMock.swift; sourceTree = "<group>"; };
@@ -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 */,
@@ -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<MediaInfo, MediaUploadingPreprocessorError> {
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
private func processImage(at url: inout URL, type: UTType, mimeType: String) -> Result<MediaInfo, MediaUploadingPreprocessorError> {
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)
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)
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<MediaInfo, MediaUploadingPreprocessorError> {
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<MediaInfo, MediaUploadingPreprocessorError> {
private func processFile(at url: URL, mimeType: String?) -> Result<MediaInfo, MediaUploadingPreprocessorError> {
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<ImageProcessingInfo, MediaUploadingPreprocessorError> {
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<ImageProcessingInfo, MediaUploadingPreprocessorError> {
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<UIImage, MediaUploadingPreprocessorError> {
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<UIImage, MediaUploadingPreprocessorError> {
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<ImageProcessingInfo, MediaUploadingPreprocessorError> {
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 {
// 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))
cgImage = try await assetImageGenerator.image(at: location).image
} catch {
return .failure(.failedGeneratingVideoThumbnail(error))
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 {
try data.write(to: thumbnailURL)
} catch {
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<VideoProcessingInfo, MediaUploadingPreprocessorError> {
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)
}
}
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ba429285ac3d7e29a5167bd7fab9a5b5721a9817afe824f61436520f6e6651ec
size 127823
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9f110a5cd97e0e9b38258833274944e816f36379631eeb6883ddf6babb9bef37
size 1827200
@@ -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
}
}