Last active
April 10, 2023 02:09
-
-
Save marcpalmer/b30c46e712cbc6ae98f20e1624450b3b to your computer and use it in GitHub Desktop.
Pulling down a video asset from Photos and exporting it, with progress and cancellation with Combine
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// Get the payload of a video asset and export it to a local temp file. | |
/// The resulting publsher will emit values until completed or cancelled. The `Double` is the progress, | |
/// which is a combination of the download progress and the export progress, so it will range from 0 to 1 but the | |
/// last export part is probably a lot quicker than the download part. | |
/// | |
/// Calling cancel() on the publisher will cancel both the image request and the export as appropriate | |
func exportAVAsset(forPHAsset phAsset: PHAsset) -> (AnyPublisher<(URL?, Double), MediaError>) { | |
let avAssetOptions = PHVideoRequestOptions() | |
avAssetOptions.isNetworkAccessAllowed = true | |
avAssetOptions.deliveryMode = .highQualityFormat | |
avAssetOptions.version = .current | |
// This publisher will emit the Photos download progress | |
let downloadProgressPublisher = CurrentValueSubject<Double, Never>(0) | |
avAssetOptions.progressHandler = { progress, error, stop, info in | |
downloadProgressPublisher.send(progress) | |
} | |
// We stash the download request then the Future for the session runs, so we can cancel it if we need to | |
var downloadRequestID: PHImageRequestID? = nil | |
// We need a subject to store the session separately once the Future has it, so that we can monitor progress on the export | |
let sessionSubject: CurrentValueSubject<AVAssetExportSession?, Never> = CurrentValueSubject(nil) | |
// A future for the session, which we'll subscribe to and then request the export | |
let sessionFuture: Future<AVAssetExportSession, MediaError> = Future() { promise in | |
downloadRequestID = PHImageManager.default().requestExportSession(forVideo: phAsset, | |
options: avAssetOptions, | |
exportPreset: AVAssetExportPresetPassthrough) { (session, info) in | |
guard let session = session else { | |
let cancelled = info?[PHImageCancelledKey] as? Bool | |
if cancelled == true { | |
promise(.failure(MediaError.userCancelled)) | |
} else { | |
promise(.failure(MediaError.requestFailed)) | |
} | |
return | |
} | |
promise(.success(session)) | |
} | |
} | |
// A future for the temp file URL itself, which we'll return as part of the final publisher | |
let exportedURLFuture: AnyPublisher<URL?, MediaError> = sessionFuture.handleEvents(receiveOutput: { session in | |
sessionSubject.send(session) // Send it to the publisher that will monitor progress | |
}) | |
.handleEvents(receiveCancel: { | |
// If we receive cancel we need to cancel the request we stashed | |
if let downloadRequestID = downloadRequestID { | |
PHImageManager.default().cancelImageRequest(downloadRequestID) | |
} | |
}) | |
.flatMap { session -> AnyPublisher<URL?, MediaError> in | |
// Here we perform the export and use the Future result to emit the temp file URL | |
let urlFuture: AnyPublisher<URL?, MediaError> = Future<URL?, MediaError>() { promise in | |
do { | |
let tempURL = try Self.getTempDirURL() | |
let outputType = AVFileType.mov | |
session.outputFileType = outputType | |
let tempFileURL = try Self.getTempFileURL(folder: tempURL, type: outputType) | |
session.outputURL = tempFileURL | |
session.exportAsynchronously { | |
promise(.success(tempFileURL)) | |
} | |
} catch { | |
promise(.failure(MediaError.exportFailed)) | |
} | |
} | |
.handleEvents(receiveCancel: { | |
// Cancel the export if the subscription is cancelled | |
session.cancelExport() | |
}) | |
.eraseToAnyPublisher() | |
return urlFuture | |
} | |
.eraseToAnyPublisher() | |
// A publisher for the progress of just the export part, which relies on the session Subject having a value | |
let exportProgressPublisher: AnyPublisher<Double, Never> = sessionSubject.compactMap { $0 } | |
.flatMap { session in | |
// We have to poll the export for progress, there is no KVO, so we make a publisher for this and | |
// map to that | |
Timer.publish(every: 0.1, on: RunLoop.main, in: .common) | |
.map { _ -> Double in | |
return Double(session.progress) | |
} | |
} | |
.prepend(0) // We need to start with a value or combineLatest won't trigger if the export takes < 0.1s | |
.eraseToAnyPublisher() | |
// Now we bring all the progress work together - combining values from both the download progress | |
// and the export progress publisher. | |
let progressPublisher = downloadProgressPublisher.combineLatest(exportProgressPublisher) | |
.map { downloadProgress, exportProgress -> Double in | |
return (downloadProgress + exportProgress)/2.0 | |
} | |
.eraseToAnyPublisher() | |
// And finally we stich everything together into one publisher | |
let mergedURLAndProgress: AnyPublisher<(URL?, Double), MediaError> = exportedURLFuture | |
.prepend(nil) // Force an initial value on the URL to satisfy combineLatest | |
.combineLatest( | |
progressPublisher | |
.prepend(0) // Force an initial zero on the meta-progress publisher so we always start emitting | |
.setFailureType(to: MediaError.self) | |
) | |
.eraseToAnyPublisher() | |
return mergedURLAndProgress | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment