mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-06 04:18:31 +00:00
Move synchronous image upload preparation off the main actor (#6323)
* Move synchronous image upload preparation off the main actor Signed-off-by: Ivan Magda <imagda15@gmail.com> * Make media preparation cooperative with cancellation Replace the dispatch queue with concurrent copy, image and video-thumbnail functions. Return sendable upload metadata directly, bound each batch to two preparations and discard unfinished results when cancellation occurs. Keep file metadata reads synchronous and prevent the preview from sending a result after its preparation task has been cancelled. Replace unrelated tests with cancellation and batch-order coverage requested during review. * Update MediaUploadingPreprocessor.swift * Run media preparation in a nonisolated preprocessor Make MediaUploadingPreprocessor a nonisolated struct whose copy, image processing and video thumbnail run in @concurrent instance methods, instead of a main-actor type with static helpers and an extra setting parameter. Check cancellation with plain early returns between the slow steps, name the batch limit, stop logging cancelled video exports as errors, and restore develop's structure and doc comments where they were changed without need. Replace the tests that didn't reach the new code with tests for cleanup on failure, on batch failure and on cancellation, and make the preview test set up its blocking mock before the view model starts processing. * Apply review suggestions to media preparation Docs: - Drop the doc comment on maximumConcurrentProcessingCount, the name says it. - Move the cleanup note into the main description of both processMedia functions, so the Returns lines match develop again. Tests: - Rename the test helper to copyResourceToTemporaryDirectory(_:). --------- Signed-off-by: Ivan Magda <imagda15@gmail.com> Co-authored-by: Ivan Magda <imagda15@gmail.com>
This commit is contained in:
@@ -22,9 +22,9 @@
|
|||||||
|
|
||||||
// swiftlint:disable all
|
// swiftlint:disable all
|
||||||
|
|
||||||
import UIKit
|
import SwiftUI
|
||||||
|
|
||||||
extension UIImage {
|
nonisolated extension UIImage {
|
||||||
public func blurHash(numberOfComponents components: (Int, Int)) -> String? {
|
public func blurHash(numberOfComponents components: (Int, Int)) -> String? {
|
||||||
let pixelWidth = Int(round(size.width * scale))
|
let pixelWidth = Int(round(size.width * scale))
|
||||||
let pixelHeight = Int(round(size.height * scale))
|
let pixelHeight = Int(round(size.height * scale))
|
||||||
@@ -116,14 +116,14 @@ extension UIImage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func encodeDC(_ value: (Float, Float, Float)) -> Int {
|
private nonisolated func encodeDC(_ value: (Float, Float, Float)) -> Int {
|
||||||
let roundedR = linearTosRGB(value.0)
|
let roundedR = linearTosRGB(value.0)
|
||||||
let roundedG = linearTosRGB(value.1)
|
let roundedG = linearTosRGB(value.1)
|
||||||
let roundedB = linearTosRGB(value.2)
|
let roundedB = linearTosRGB(value.2)
|
||||||
return (roundedR << 16) + (roundedG << 8) + roundedB
|
return (roundedR << 16) + (roundedG << 8) + roundedB
|
||||||
}
|
}
|
||||||
|
|
||||||
private func encodeAC(_ value: (Float, Float, Float), maximumValue: Float) -> Int {
|
private nonisolated func encodeAC(_ value: (Float, Float, Float), maximumValue: Float) -> Int {
|
||||||
let quantR = Int(max(0, min(18, floor(signPow(value.0 / maximumValue, 0.5) * 9 + 9.5))))
|
let quantR = Int(max(0, min(18, floor(signPow(value.0 / maximumValue, 0.5) * 9 + 9.5))))
|
||||||
let quantG = Int(max(0, min(18, floor(signPow(value.1 / maximumValue, 0.5) * 9 + 9.5))))
|
let quantG = Int(max(0, min(18, floor(signPow(value.1 / maximumValue, 0.5) * 9 + 9.5))))
|
||||||
let quantB = Int(max(0, min(18, floor(signPow(value.2 / maximumValue, 0.5) * 9 + 9.5))))
|
let quantB = Int(max(0, min(18, floor(signPow(value.2 / maximumValue, 0.5) * 9 + 9.5))))
|
||||||
@@ -131,11 +131,11 @@ private func encodeAC(_ value: (Float, Float, Float), maximumValue: Float) -> In
|
|||||||
return quantR * 19 * 19 + quantG * 19 + quantB
|
return quantR * 19 * 19 + quantG * 19 + quantB
|
||||||
}
|
}
|
||||||
|
|
||||||
private func signPow(_ value: Float, _ exp: Float) -> Float {
|
private nonisolated func signPow(_ value: Float, _ exp: Float) -> Float {
|
||||||
copysign(pow(abs(value), exp), value)
|
copysign(pow(abs(value), exp), value)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func linearTosRGB(_ value: Float) -> Int {
|
private nonisolated func linearTosRGB(_ value: Float) -> Int {
|
||||||
let v = max(0, min(1, value))
|
let v = max(0, min(1, value))
|
||||||
if v <= 0.0031308 {
|
if v <= 0.0031308 {
|
||||||
return Int(v * 12.92 * 255 + 0.5)
|
return Int(v * 12.92 * 255 + 0.5)
|
||||||
@@ -144,7 +144,7 @@ private func linearTosRGB(_ value: Float) -> Int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float {
|
private nonisolated func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float {
|
||||||
let v = Float(Int64(value)) / 255
|
let v = Float(Int64(value)) / 255
|
||||||
if v <= 0.04045 {
|
if v <= 0.04045 {
|
||||||
return v / 12.92
|
return v / 12.92
|
||||||
@@ -153,9 +153,9 @@ private func sRGBToLinear<Type: BinaryInteger>(_ value: Type) -> Float {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private let encodeCharacters: [String] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) }
|
private nonisolated let encodeCharacters: [String] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) }
|
||||||
|
|
||||||
extension BinaryInteger {
|
nonisolated extension BinaryInteger {
|
||||||
func encode83(length: Int) -> String {
|
func encode83(length: Int) -> String {
|
||||||
var result = ""
|
var result = ""
|
||||||
for i in 1...length {
|
for i in 1...length {
|
||||||
@@ -166,7 +166,7 @@ extension BinaryInteger {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pow(_ base: Int, _ exponent: Int) -> Int {
|
private nonisolated func pow(_ base: Int, _ exponent: Int) -> Int {
|
||||||
(0..<exponent).reduce(1) { value, _ in value * base }
|
(0..<exponent).reduce(1) { value, _ in value * base }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -63,10 +63,12 @@ class MediaUploadPreviewScreenViewModel: MediaUploadPreviewScreenViewModelType,
|
|||||||
case .send:
|
case .send:
|
||||||
startLoading()
|
startLoading()
|
||||||
|
|
||||||
Task {
|
Task { [processingTask] in
|
||||||
defer { stopLoading() }
|
defer { stopLoading() }
|
||||||
|
|
||||||
switch await processingTask.value {
|
let result = await processingTask.value
|
||||||
|
guard !processingTask.isCancelled else { return }
|
||||||
|
switch result {
|
||||||
case .success(let mediaInfos):
|
case .success(let mediaInfos):
|
||||||
if galleryEnabled, mediaInfos.count > 1 {
|
if galleryEnabled, mediaInfos.count > 1 {
|
||||||
switch await sendGallery(mediaInfos: mediaInfos, caption: caption) {
|
switch await sendGallery(mediaInfos: mediaInfos, caption: caption) {
|
||||||
|
|||||||
@@ -8,10 +8,11 @@
|
|||||||
|
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import MatrixRustSDK
|
import MatrixRustSDK
|
||||||
import UIKit
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
indirect enum MediaUploadingPreprocessorError: Error {
|
indirect nonisolated enum MediaUploadingPreprocessorError: Error {
|
||||||
|
case cancelled
|
||||||
case maxUploadSizeUnknown
|
case maxUploadSizeUnknown
|
||||||
case maxUploadSizeExceeded(limit: UInt)
|
case maxUploadSizeExceeded(limit: UInt)
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ indirect enum MediaUploadingPreprocessorError: Error {
|
|||||||
case failedConvertingVideo
|
case failedConvertingVideo
|
||||||
}
|
}
|
||||||
|
|
||||||
enum MediaInfo {
|
nonisolated enum MediaInfo {
|
||||||
case image(imageURL: URL, thumbnailURL: URL, imageInfo: ImageInfo)
|
case image(imageURL: URL, thumbnailURL: URL, imageInfo: ImageInfo)
|
||||||
case video(videoURL: URL, thumbnailURL: URL, videoInfo: VideoInfo)
|
case video(videoURL: URL, thumbnailURL: URL, videoInfo: VideoInfo)
|
||||||
case audio(audioURL: URL, audioInfo: AudioInfo)
|
case audio(audioURL: URL, audioInfo: AudioInfo)
|
||||||
@@ -69,7 +70,7 @@ enum MediaInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct ImageProcessingInfo {
|
private nonisolated struct ImageProcessingInfo {
|
||||||
let url: URL
|
let url: URL
|
||||||
let height: Double
|
let height: Double
|
||||||
let width: Double
|
let width: Double
|
||||||
@@ -77,7 +78,7 @@ private struct ImageProcessingInfo {
|
|||||||
let blurhash: String?
|
let blurhash: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct VideoProcessingInfo {
|
private nonisolated struct VideoProcessingInfo {
|
||||||
let url: URL
|
let url: URL
|
||||||
let height: Double
|
let height: Double
|
||||||
let width: Double
|
let width: Double
|
||||||
@@ -85,90 +86,151 @@ private struct VideoProcessingInfo {
|
|||||||
let mimeType: String
|
let mimeType: String
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MediaUploadingPreprocessor {
|
nonisolated struct MediaUploadingPreprocessor {
|
||||||
let userSettings: UserSettings
|
let userSettings: UserSettings
|
||||||
|
|
||||||
enum Constants {
|
nonisolated enum Constants {
|
||||||
static let maximumThumbnailSize = CGSize(width: 800, height: 600)
|
static let maximumThumbnailSize = CGSize(width: 800, height: 600)
|
||||||
static let optimizedMaxPixelSize = 2048.0
|
static let optimizedMaxPixelSize = 2048.0
|
||||||
static let jpegCompressionQuality = 0.78
|
static let jpegCompressionQuality = 0.78
|
||||||
static let videoThumbnailTime = 5.0 // seconds
|
static let videoThumbnailTime = 5.0 // seconds
|
||||||
|
static let maximumConcurrentProcessingCount = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Processes media at the given URLs. It will generate thumbnails for images and videos, convert videos to 1080p mp4, strip GPS locations
|
/// Processes media at the given URLs. It will generate thumbnails for images and videos, convert videos to 1080p mp4, strip GPS locations
|
||||||
/// from images and retrieve associated media information
|
/// from images and retrieve associated media information. On failure or cancellation all the prepared files are removed.
|
||||||
/// - Parameter urls: the file URL
|
/// - Parameter urls: the file URLs
|
||||||
/// - Returns: a collection of results containing specific type of `MediaInfo` depending on the file type
|
/// - Returns: a collection of results containing specific type of `MediaInfo` depending on the file type
|
||||||
/// and its associated details or any resulting error
|
/// and its associated details or any resulting error
|
||||||
func processMedia(at urls: [URL], maxUploadSize: UInt) async -> Result<[MediaInfo], MediaUploadingPreprocessorError> {
|
func processMedia(at urls: [URL], maxUploadSize: UInt) async -> Result<[MediaInfo], MediaUploadingPreprocessorError> {
|
||||||
await withTaskGroup { taskGroup in
|
await withTaskGroup(of: (Int, Result<MediaInfo, MediaUploadingPreprocessorError>).self) { taskGroup in
|
||||||
for (index, url) in urls.enumerated() {
|
var pendingIndices = urls.indices.makeIterator()
|
||||||
taskGroup.addTask {
|
|
||||||
let result = await processMedia(at: url, maxUploadSize: maxUploadSize)
|
func processNextURL() {
|
||||||
return (index, result)
|
guard let index = pendingIndices.next() else { return }
|
||||||
|
_ = taskGroup.addTaskUnlessCancelled {
|
||||||
|
await (index, processMedia(at: urls[index], maxUploadSize: maxUploadSize))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _ in 0..<Constants.maximumConcurrentProcessingCount {
|
||||||
|
processNextURL()
|
||||||
|
}
|
||||||
|
|
||||||
// Store results in their respective index as they come in
|
// Store results in their respective index as they come in
|
||||||
var results = [MediaInfo?](repeating: nil, count: urls.count)
|
var results = [MediaInfo?](repeating: nil, count: urls.count)
|
||||||
|
var failure: MediaUploadingPreprocessorError?
|
||||||
|
|
||||||
for await (index, result) in taskGroup {
|
for await (index, result) in taskGroup {
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let mediaInfo):
|
case .success(let mediaInfo):
|
||||||
results[index] = mediaInfo
|
results[index] = mediaInfo
|
||||||
|
processNextURL()
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
return .failure(error)
|
failure = failure ?? error
|
||||||
|
taskGroup.cancelAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return .success(results.compactMap { $0 })
|
let mediaInfos = results.compactMap { $0 }
|
||||||
|
|
||||||
|
if Task.isCancelled {
|
||||||
|
failure = .cancelled
|
||||||
|
}
|
||||||
|
if let failure {
|
||||||
|
await removeUniqueFolders(containing: mediaInfos.map(\.url))
|
||||||
|
return .failure(failure)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .success(mediaInfos)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Processes media at a given URL. It will generate thumbnails for images and videos, convert videos to 1080p mp4, strip GPS locations
|
/// Processes media at a given URL. It will generate thumbnails for images and videos, convert videos to 1080p mp4, strip GPS locations
|
||||||
/// from images and retrieve associated media information
|
/// from images and retrieve associated media information. On failure or cancellation all the prepared files are removed.
|
||||||
/// - Parameter url: the file URL
|
/// - Parameter url: the file URL
|
||||||
/// - Returns: a specific type of `MediaInfo` depending on the file type and its associated details
|
/// - Returns: a specific type of `MediaInfo` depending on the file type and its associated details
|
||||||
func processMedia(at url: URL, maxUploadSize: UInt) async -> Result<MediaInfo, MediaUploadingPreprocessorError> {
|
func processMedia(at url: URL, maxUploadSize: UInt) async -> Result<MediaInfo, MediaUploadingPreprocessorError> {
|
||||||
// Start by copying the file to a unique temporary location in order to avoid conflicts if processing it multiple times
|
guard !Task.isCancelled else { return .failure(.cancelled) }
|
||||||
// All the other operations will be made relative to it
|
|
||||||
let uniqueFolder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
let preparedURL: URL
|
||||||
var newURL = uniqueFolder.appendingPathComponent(url.lastPathComponent)
|
|
||||||
do {
|
do {
|
||||||
try FileManager.default.createDirectory(at: uniqueFolder, withIntermediateDirectories: true)
|
preparedURL = try await copyToUniqueFolder(url)
|
||||||
try FileManager.default.copyItem(at: url, to: newURL)
|
|
||||||
} catch {
|
} catch {
|
||||||
return .failure(.failedProcessingMedia(error))
|
return .failure(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process unknown types as plain files
|
let result = await processPreparedMedia(at: preparedURL, maxUploadSize: maxUploadSize)
|
||||||
guard let type = UTType(filenameExtension: newURL.pathExtension),
|
|
||||||
let mimeType = type.preferredMIMEType else {
|
if Task.isCancelled {
|
||||||
return processFile(at: newURL, mimeType: "application/octet-stream", maxUploadSize: maxUploadSize)
|
await removeUniqueFolders(containing: [preparedURL])
|
||||||
|
return .failure(.cancelled)
|
||||||
|
}
|
||||||
|
if case .failure = result {
|
||||||
|
await removeUniqueFolders(containing: [preparedURL])
|
||||||
}
|
}
|
||||||
|
|
||||||
if type.conforms(to: .image) {
|
return result
|
||||||
return processImage(at: &newURL, type: type, mimeType: mimeType, maxUploadSize: maxUploadSize)
|
|
||||||
} else if type.conforms(to: .movie) || type.conforms(to: .video) {
|
|
||||||
return await processVideo(at: newURL, maxUploadSize: maxUploadSize)
|
|
||||||
} else if type.conforms(to: .audio) {
|
|
||||||
return await processAudio(at: newURL, mimeType: mimeType, maxUploadSize: maxUploadSize)
|
|
||||||
} else {
|
|
||||||
return processFile(at: newURL, mimeType: mimeType, maxUploadSize: maxUploadSize)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
|
/// Copies the file into a new unique folder to avoid conflicts when processing the same file more than once.
|
||||||
|
/// Every file derived from the copy (converted media, thumbnails) is written into that same folder.
|
||||||
|
@concurrent
|
||||||
|
private func copyToUniqueFolder(_ url: URL) async throws(MediaUploadingPreprocessorError) -> URL {
|
||||||
|
let uniqueFolder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||||
|
let newURL = uniqueFolder.appendingPathComponent(url.lastPathComponent)
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: uniqueFolder, withIntermediateDirectories: true)
|
||||||
|
try FileManager.default.copyItem(at: url, to: newURL)
|
||||||
|
return newURL
|
||||||
|
} catch {
|
||||||
|
try? FileManager.default.removeItem(at: uniqueFolder)
|
||||||
|
throw .failedProcessingMedia(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the whole unique folder (see `copyToUniqueFolder`) that holds each of the given files.
|
||||||
|
@concurrent
|
||||||
|
private func removeUniqueFolders(containing urls: [URL]) async {
|
||||||
|
for url in urls {
|
||||||
|
try? FileManager.default.removeItem(at: url.deletingLastPathComponent())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func processPreparedMedia(at url: URL, maxUploadSize: UInt) async -> Result<MediaInfo, MediaUploadingPreprocessorError> {
|
||||||
|
guard !Task.isCancelled else { return .failure(.cancelled) }
|
||||||
|
|
||||||
|
// Process unknown types as plain files
|
||||||
|
guard let type = UTType(filenameExtension: url.pathExtension),
|
||||||
|
let mimeType = type.preferredMIMEType else {
|
||||||
|
return processFile(at: url, mimeType: "application/octet-stream", maxUploadSize: maxUploadSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if type.conforms(to: .image) {
|
||||||
|
return await processImage(at: url, type: type, mimeType: mimeType, maxUploadSize: maxUploadSize)
|
||||||
|
} else if type.conforms(to: .movie) || type.conforms(to: .video) {
|
||||||
|
return await processVideo(at: url, maxUploadSize: maxUploadSize)
|
||||||
|
} else if type.conforms(to: .audio) {
|
||||||
|
return await processAudio(at: url, mimeType: mimeType, maxUploadSize: maxUploadSize)
|
||||||
|
} else {
|
||||||
|
return processFile(at: url, mimeType: mimeType, maxUploadSize: maxUploadSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Prepares an image for upload. Strips location data from it and generates a thumbnail
|
/// Prepares an image for upload. Strips location data from it and generates a thumbnail
|
||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - url: The image URL
|
/// - url: The image URL
|
||||||
/// - type: its UTType
|
/// - type: its UTType
|
||||||
/// - mimeType: the mimeType extracted from the 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`
|
/// - Returns: Returns a `MediaInfo.image` containing the URLs for the modified image and its thumbnail plus the corresponding `ImageInfo`
|
||||||
private func processImage(at url: inout URL, type: UTType, mimeType: String, maxUploadSize: UInt) -> Result<MediaInfo, MediaUploadingPreprocessorError> {
|
@concurrent
|
||||||
|
private func processImage(at url: URL, type: UTType, mimeType: String, maxUploadSize: UInt) async -> Result<MediaInfo, MediaUploadingPreprocessorError> {
|
||||||
|
var url = url
|
||||||
do {
|
do {
|
||||||
try stripLocationFromImage(at: url, type: type)
|
try stripLocationFromImage(at: url, type: type)
|
||||||
|
guard !Task.isCancelled else { return .failure(.cancelled) }
|
||||||
|
|
||||||
var mimeType = mimeType
|
var mimeType = mimeType
|
||||||
if userSettings.app.optimizeMediaUploads, !type.conforms(to: .gif) {
|
if userSettings.app.optimizeMediaUploads, !type.conforms(to: .gif) {
|
||||||
@@ -187,6 +249,7 @@ struct MediaUploadingPreprocessor {
|
|||||||
url = convertedURL
|
url = convertedURL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
guard !Task.isCancelled else { return .failure(.cancelled) }
|
||||||
|
|
||||||
let thumbnailResult = try generateThumbnailForImage(at: url)
|
let thumbnailResult = try generateThumbnailForImage(at: url)
|
||||||
|
|
||||||
@@ -231,7 +294,9 @@ struct MediaUploadingPreprocessor {
|
|||||||
private func processVideo(at url: URL, maxUploadSize: UInt) async -> Result<MediaInfo, MediaUploadingPreprocessorError> {
|
private func processVideo(at url: URL, maxUploadSize: UInt) async -> Result<MediaInfo, MediaUploadingPreprocessorError> {
|
||||||
do {
|
do {
|
||||||
let result = try await convertVideoToMP4(url, targetFileSize: UInt(maxUploadSize))
|
let result = try await convertVideoToMP4(url, targetFileSize: UInt(maxUploadSize))
|
||||||
let thumbnailResult = try await generateThumbnailForVideoAt(result.url)
|
guard !Task.isCancelled else { return .failure(.cancelled) }
|
||||||
|
|
||||||
|
let thumbnailResult = try await generateThumbnailForVideo(at: result.url)
|
||||||
|
|
||||||
let videoSize = (try? FileManager.default.sizeForItem(at: result.url)) ?? 0
|
let videoSize = (try? FileManager.default.sizeForItem(at: result.url)) ?? 0
|
||||||
let thumbnailSize = (try? FileManager.default.sizeForItem(at: thumbnailResult.url)) ?? 0
|
let thumbnailSize = (try? FileManager.default.sizeForItem(at: thumbnailResult.url)) ?? 0
|
||||||
@@ -396,7 +461,8 @@ struct MediaUploadingPreprocessor {
|
|||||||
/// Generates a thumbnail for the video at the given URL
|
/// Generates a thumbnail for the video at the given URL
|
||||||
/// - Parameter url: the video URL
|
/// - Parameter url: the video URL
|
||||||
/// - Returns: the URL for the resulting thumbnail and its sizing info as an `ImageProcessingResult`
|
/// - Returns: the URL for the resulting thumbnail and its sizing info as an `ImageProcessingResult`
|
||||||
private func generateThumbnailForVideoAt(_ url: URL) async throws(MediaUploadingPreprocessorError) -> ImageProcessingInfo {
|
@concurrent
|
||||||
|
private func generateThumbnailForVideo(at url: URL) async throws(MediaUploadingPreprocessorError) -> ImageProcessingInfo {
|
||||||
let assetImageGenerator = AVAssetImageGenerator(asset: AVURLAsset(url: url))
|
let assetImageGenerator = AVAssetImageGenerator(asset: AVURLAsset(url: url))
|
||||||
assetImageGenerator.appliesPreferredTrackTransform = true
|
assetImageGenerator.appliesPreferredTrackTransform = true
|
||||||
assetImageGenerator.maximumSize = Constants.maximumThumbnailSize
|
assetImageGenerator.maximumSize = Constants.maximumThumbnailSize
|
||||||
@@ -464,6 +530,7 @@ struct MediaUploadingPreprocessor {
|
|||||||
do {
|
do {
|
||||||
try await exportSession.export(to: outputURL, as: .mp4)
|
try await exportSession.export(to: outputURL, as: .mp4)
|
||||||
} catch {
|
} catch {
|
||||||
|
guard !Task.isCancelled else { throw .cancelled }
|
||||||
MXLog.error("Video conversion failed: \(error)")
|
MXLog.error("Video conversion failed: \(error)")
|
||||||
throw .failedConvertingVideo
|
throw .failedConvertingVideo
|
||||||
}
|
}
|
||||||
@@ -494,7 +561,7 @@ struct MediaUploadingPreprocessor {
|
|||||||
|
|
||||||
// MARK: - Extensions
|
// MARK: - Extensions
|
||||||
|
|
||||||
private extension CGImageSource {
|
private nonisolated extension CGImageSource {
|
||||||
var size: CGSize? {
|
var size: CGSize? {
|
||||||
guard let properties = CGImageSourceCopyPropertiesAtIndex(self, 0, nil) as? [NSString: Any],
|
guard let properties = CGImageSourceCopyPropertiesAtIndex(self, 0, nil) as? [NSString: Any],
|
||||||
var width = properties[kCGImagePropertyPixelWidth] as? Int,
|
var width = properties[kCGImagePropertyPixelWidth] as? Int,
|
||||||
@@ -517,7 +584,7 @@ private extension CGImageSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension AVAssetTrack {
|
private nonisolated extension AVAssetTrack {
|
||||||
var size: CGSize {
|
var size: CGSize {
|
||||||
get async throws {
|
get async throws {
|
||||||
let naturalSize = try await load(.naturalSize)
|
let naturalSize = try await load(.naturalSize)
|
||||||
|
|||||||
@@ -34,6 +34,35 @@ final class MediaUploadPreviewScreenViewModelTests {
|
|||||||
userIndicatorController = UserIndicatorControllerMock()
|
userIndicatorController = UserIndicatorControllerMock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func cancelWhileSendWaitsForPreparation() async throws {
|
||||||
|
// Given a screen whose media preparation is still running.
|
||||||
|
let (startedStream, startedContinuation) = AsyncStream<Void>.makeStream()
|
||||||
|
let (resumeStream, resumeContinuation) = AsyncStream<Void>.makeStream()
|
||||||
|
defer { resumeContinuation.finish() }
|
||||||
|
|
||||||
|
let preparationStarted = deferFulfillment(startedStream) { _ in true }
|
||||||
|
setUpViewModel(urls: [imageURL], expectedCaption: nil) {
|
||||||
|
startedContinuation.yield()
|
||||||
|
var iterator = resumeStream.makeAsyncIterator()
|
||||||
|
_ = await iterator.next() // Only returns once the preparation is cancelled.
|
||||||
|
return .success(100 * 1024 * 1024)
|
||||||
|
}
|
||||||
|
try await preparationStarted.fulfill()
|
||||||
|
|
||||||
|
// When sending and then dismissing the screen before the preparation finishes.
|
||||||
|
context.send(viewAction: .send)
|
||||||
|
let loadingFinished = deferFulfillment(context.observe(\.viewState.shouldDisableInteraction)) { !$0 }
|
||||||
|
context.send(viewAction: .cancel)
|
||||||
|
viewModel.stopProcessing() // Called by the coordinator when the screen is dismissed.
|
||||||
|
try await loadingFinished.fulfill()
|
||||||
|
|
||||||
|
// Then nothing is sent and no error is shown.
|
||||||
|
#expect(timelineProxy.sendImageUrlThumbnailURLImageInfoCaptionRequestHandleCallsCount == 0)
|
||||||
|
#expect(userIndicatorController.submitIndicatorDelayCallsCount == 1, "Only the loading indicator should be shown")
|
||||||
|
#expect(context.viewState.bindings.alertInfo == nil)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func imageUploadWithoutCaption() async throws {
|
func imageUploadWithoutCaption() async throws {
|
||||||
setUpViewModel(urls: [imageURL], expectedCaption: nil)
|
setUpViewModel(urls: [imageURL], expectedCaption: nil)
|
||||||
@@ -293,7 +322,8 @@ final class MediaUploadPreviewScreenViewModelTests {
|
|||||||
expectedCaption: String?,
|
expectedCaption: String?,
|
||||||
maxUploadSizeResult: Result<UInt, ClientProxyError>? = nil,
|
maxUploadSizeResult: Result<UInt, ClientProxyError>? = nil,
|
||||||
simulateGallerySendFailure: Bool = false,
|
simulateGallerySendFailure: Bool = false,
|
||||||
galleryEnabled: Bool = true) {
|
galleryEnabled: Bool = true,
|
||||||
|
maxUploadSizeClosure: (() async -> Result<UInt, ClientProxyError>)? = nil) {
|
||||||
timelineProxy = TimelineProxyMock(.init())
|
timelineProxy = TimelineProxyMock(.init())
|
||||||
timelineProxy.sendAudioUrlAudioInfoCaptionRequestHandleClosure = { [weak self] _, _, caption, _ in
|
timelineProxy.sendAudioUrlAudioInfoCaptionRequestHandleClosure = { [weak self] _, _, caption, _ in
|
||||||
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
|
self?.verifyCaption(caption, expectedCaption: expectedCaption) ?? .failure(.sdkError(TestError.unknown))
|
||||||
@@ -316,6 +346,7 @@ final class MediaUploadPreviewScreenViewModelTests {
|
|||||||
if let maxUploadSizeResult {
|
if let maxUploadSizeResult {
|
||||||
clientProxy.underlyingMaxMediaUploadSize = maxUploadSizeResult
|
clientProxy.underlyingMaxMediaUploadSize = maxUploadSizeResult
|
||||||
}
|
}
|
||||||
|
clientProxy.maxMediaUploadSizeClosure = maxUploadSizeClosure
|
||||||
|
|
||||||
viewModel = MediaUploadPreviewScreenViewModel(mediaURLs: urls,
|
viewModel = MediaUploadPreviewScreenViewModel(mediaURLs: urls,
|
||||||
caption: nil,
|
caption: nil,
|
||||||
|
|||||||
@@ -435,8 +435,90 @@ final class MediaUploadingPreprocessorTests {
|
|||||||
#expect(optimizedImageInfo.height == 2048)
|
#expect(optimizedImageInfo.height == 2048)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func batchPreservesInputOrderBeyondConcurrencyLimit() async throws {
|
||||||
|
let names = ["test_image.png", "test_animated_image.gif", "landscape_test_image.jpg"]
|
||||||
|
try #require(names.count > MediaUploadingPreprocessor.Constants.maximumConcurrentProcessingCount)
|
||||||
|
let inputs = try names.map { try #require(Bundle(for: Self.self).url(forResource: $0, withExtension: nil)) }
|
||||||
|
let results = try await mediaUploadingPreprocessor.processMedia(at: inputs, maxUploadSize: maxUploadSize).get()
|
||||||
|
defer {
|
||||||
|
for result in results {
|
||||||
|
try? FileManager.default.removeItem(at: result.url.deletingLastPathComponent())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#expect(results.map(\.url.lastPathComponent) == names)
|
||||||
|
for result in results {
|
||||||
|
let thumbnailURL = try #require(result.thumbnailURL)
|
||||||
|
#expect(try Data(contentsOf: result.url).isEmpty == false)
|
||||||
|
#expect(try Data(contentsOf: thumbnailURL).isEmpty == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func failedPreparationRemovesItsFiles() async throws {
|
||||||
|
let url = try copyResourceToTemporaryDirectory("test_image.png")
|
||||||
|
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||||
|
|
||||||
|
guard case .failure(.maxUploadSizeExceeded) = await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: 1) else {
|
||||||
|
Issue.record("Expected the upload size to be exceeded")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(try preparedFolders(for: url).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func batchFailureReturnsTheErrorAndRemovesPreparedFiles() async throws {
|
||||||
|
let url = try copyResourceToTemporaryDirectory("test_image.png")
|
||||||
|
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||||
|
let missingURL = url.deletingLastPathComponent().appendingPathComponent("missing.png")
|
||||||
|
|
||||||
|
// The missing file comes last so that at least one image has already been prepared when the batch fails.
|
||||||
|
guard case .failure(.failedProcessingMedia) = await mediaUploadingPreprocessor.processMedia(at: [url, url, missingURL], maxUploadSize: maxUploadSize) else {
|
||||||
|
Issue.record("Expected the missing file to fail the batch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(try preparedFolders(for: url).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func cancellationRemovesPreparedFiles() async throws {
|
||||||
|
let url = try copyResourceToTemporaryDirectory("test_image.png")
|
||||||
|
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||||
|
|
||||||
|
let task = Task { await mediaUploadingPreprocessor.processMedia(at: url, maxUploadSize: maxUploadSize) }
|
||||||
|
await Task.yield() // Let the preparation start.
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
guard case .failure(.cancelled) = await task.value else {
|
||||||
|
Issue.record("Expected the preparation to be cancelled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(try preparedFolders(for: url).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
|
/// Copies a test asset to a uniquely named file, so the folders the preprocessor prepares from it can be found afterwards.
|
||||||
|
private func copyResourceToTemporaryDirectory(_ resource: String) throws -> URL {
|
||||||
|
let assetURL = try #require(Bundle(for: Self.self).url(forResource: resource, withExtension: nil))
|
||||||
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("\(Self.self)-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
let url = directory.appendingPathComponent("\(UUID().uuidString).\(assetURL.pathExtension)")
|
||||||
|
try FileManager.default.copyItem(at: assetURL, to: url)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The unique folders created by the preprocessor for the given file that still exist.
|
||||||
|
private func preparedFolders(for url: URL) throws -> [URL] {
|
||||||
|
let name = url.deletingPathExtension().lastPathComponent
|
||||||
|
return try FileManager.default.contentsOfDirectory(at: FileManager.default.temporaryDirectory, includingPropertiesForKeys: nil)
|
||||||
|
.filter { UUID(uuidString: $0.lastPathComponent) != nil }
|
||||||
|
.filter { folder in
|
||||||
|
let contents = (try? FileManager.default.contentsOfDirectory(atPath: folder.path(percentEncoded: false))) ?? []
|
||||||
|
return contents.contains { $0.contains(name) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func isEqual<N: UnsignedInteger>(_ lhs: N, _ rhs: N, within tolerance: N) -> Bool {
|
private func isEqual<N: UnsignedInteger>(_ lhs: N, _ rhs: N, within tolerance: N) -> Bool {
|
||||||
isEqual(Double(lhs), Double(rhs), within: Double(tolerance))
|
isEqual(Double(lhs), Double(rhs), within: Double(tolerance))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user