Skip to content

Instantly share code, notes, and snippets.

@marcpalmer
Last active April 10, 2023 02:09
Show Gist options
  • Save marcpalmer/b30c46e712cbc6ae98f20e1624450b3b to your computer and use it in GitHub Desktop.
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
/// 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