diff --git a/SJSAssetExportSession.xcodeproj/project.pbxproj b/SJSAssetExportSession.xcodeproj/project.pbxproj index cff1e9a..0e4541f 100644 --- a/SJSAssetExportSession.xcodeproj/project.pbxproj +++ b/SJSAssetExportSession.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 60; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -13,7 +13,9 @@ 7B9BC0192C305D2C00C160C2 /* SJSAssetExportSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9BC0182C305D2C00C160C2 /* SJSAssetExportSessionTests.swift */; }; 7B9BC01A2C305D2C00C160C2 /* SJSAssetExportSession.h in Headers */ = {isa = PBXBuildFile; fileRef = 7B9BC00C2C305D2C00C160C2 /* SJSAssetExportSession.h */; settings = {ATTRIBUTES = (Public, ); }; }; 7B9BC0282C30612C00C160C2 /* ExportSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9BC0272C30612C00C160C2 /* ExportSession.swift */; }; - 7BC5FC712C3A52B50090B757 /* test.mov in Resources */ = {isa = PBXBuildFile; fileRef = 7BC5FC702C3A52B50090B757 /* test.mov */; }; + 7BC5FC772C3B8C5A0090B757 /* SendableWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC5FC762C3B8C5A0090B757 /* SendableWrapper.swift */; }; + 7BC5FC792C3B90F70090B757 /* AutoDestructingURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC5FC782C3B90F70090B757 /* AutoDestructingURL.swift */; }; + 7BC5FC7B2C3B93270090B757 /* AVFoundation+sending.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC5FC7A2C3B93270090B757 /* AVFoundation+sending.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -34,9 +36,15 @@ 7B9BC0132C305D2C00C160C2 /* SJSAssetExportSessionTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SJSAssetExportSessionTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7B9BC0182C305D2C00C160C2 /* SJSAssetExportSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SJSAssetExportSessionTests.swift; sourceTree = ""; }; 7B9BC0272C30612C00C160C2 /* ExportSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportSession.swift; sourceTree = ""; }; - 7BC5FC702C3A52B50090B757 /* test.mov */ = {isa = PBXFileReference; lastKnownFileType = video.quicktime; path = test.mov; sourceTree = ""; }; + 7BC5FC762C3B8C5A0090B757 /* SendableWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendableWrapper.swift; sourceTree = ""; }; + 7BC5FC782C3B90F70090B757 /* AutoDestructingURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoDestructingURL.swift; sourceTree = ""; }; + 7BC5FC7A2C3B93270090B757 /* AVFoundation+sending.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVFoundation+sending.swift"; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 7BC5FC812C3B9E3D0090B757 /* Resources */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Resources; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 7B9BC0062C305D2C00C160C2 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -88,20 +96,15 @@ 7B9BC0172C305D2C00C160C2 /* SJSAssetExportSessionTests */ = { isa = PBXGroup; children = ( - 7BC5FC6D2C3A525A0090B757 /* Resources */, + 7BC5FC782C3B90F70090B757 /* AutoDestructingURL.swift */, + 7BC5FC812C3B9E3D0090B757 /* Resources */, + 7BC5FC762C3B8C5A0090B757 /* SendableWrapper.swift */, 7B9BC0182C305D2C00C160C2 /* SJSAssetExportSessionTests.swift */, + 7BC5FC7A2C3B93270090B757 /* AVFoundation+sending.swift */, ); path = SJSAssetExportSessionTests; sourceTree = ""; }; - 7BC5FC6D2C3A525A0090B757 /* Resources */ = { - isa = PBXGroup; - children = ( - 7BC5FC702C3A52B50090B757 /* test.mov */, - ); - path = Resources; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -147,6 +150,9 @@ dependencies = ( 7B9BC0162C305D2C00C160C2 /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + 7BC5FC812C3B9E3D0090B757 /* Resources */, + ); name = SJSAssetExportSessionTests; productName = SJSAssetExportSessionTests; productReference = 7B9BC0132C305D2C00C160C2 /* SJSAssetExportSessionTests.xctest */; @@ -201,7 +207,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 7BC5FC712C3A52B50090B757 /* test.mov in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -223,6 +228,9 @@ buildActionMask = 2147483647; files = ( 7B9BC0192C305D2C00C160C2 /* SJSAssetExportSessionTests.swift in Sources */, + 7BC5FC792C3B90F70090B757 /* AutoDestructingURL.swift in Sources */, + 7BC5FC772C3B8C5A0090B757 /* SendableWrapper.swift in Sources */, + 7BC5FC7B2C3B93270090B757 /* AVFoundation+sending.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/SJSAssetExportSession/ExportSession.swift b/SJSAssetExportSession/ExportSession.swift index f4cf01b..9411b16 100644 --- a/SJSAssetExportSession/ExportSession.swift +++ b/SJSAssetExportSession/ExportSession.swift @@ -7,25 +7,87 @@ public import AVFoundation -public final class ExportSession { - public enum Error: LocalizedError { - case setupFailure(reason: String) + +// @unchecked Sendable because progress properties are mutable, it's safe though. +public final class ExportSession: @unchecked Sendable { + public enum SetupFailureReason: String, Sendable, CustomStringConvertible { + case audioSettingsEmpty + case audioSettingsInvalid + case cannotAddAudioInput + case cannotAddAudioOutput + case cannotAddVideoInput + case cannotAddVideoOutput + case videoSettingsEmpty + case videoSettingsInvalid + case videoTracksEmpty + + public var description: String { + switch self { + case .audioSettingsEmpty: + "Must provide audio output settings" + case .audioSettingsInvalid: + "Invalid audio output settings" + case .cannotAddAudioInput: + "Can't add audio input to writer" + case .cannotAddAudioOutput: + "Can't add audio output to reader" + case .cannotAddVideoInput: + "Can't add video input to writer" + case .cannotAddVideoOutput: + "Can't add video output to reader" + case .videoSettingsEmpty: + "Must provide video output settings" + case .videoSettingsInvalid: + "Invalid video output settings" + case .videoTracksEmpty: + "No video track" + } + } + } + + public enum Error: LocalizedError, Equatable { + case setupFailure(SetupFailureReason) case readFailure((any Swift.Error)?) case writeFailure((any Swift.Error)?) public var errorDescription: String? { switch self { case let .setupFailure(reason): - reason + reason.description case let .readFailure(underlyingError): underlyingError?.localizedDescription ?? "Unknown read failure" case let .writeFailure(underlyingError): underlyingError?.localizedDescription ?? "Unknown write failure" } } + + public static func == (lhs: ExportSession.Error, rhs: ExportSession.Error) -> Bool { + switch (lhs, rhs) { + case let (.setupFailure(lhsReason), .setupFailure(rhsReason)): + lhsReason == rhsReason + case let (.readFailure(lhsError), .readFailure(rhsError)): + String(describing: lhsError) == String(describing: rhsError) + case let (.writeFailure(lhsError), .writeFailure(rhsError)): + String(describing: lhsError) == String(describing: rhsError) + default: + false + } + } } - public static func export( + public typealias ProgressStream = AsyncStream + + public var progressStream: ProgressStream = ProgressStream(unfolding: { 0.0 }) + + private var progressContinuation: ProgressStream.Continuation? + + public init() { + progressStream = AsyncStream { continuation in + progressContinuation = continuation + } + } + + public func export( asset: sending AVAsset, audioMix: sending AVAudioMix?, audioOutputSettings: [String: (any Sendable)], @@ -47,6 +109,11 @@ public final class ExportSession { outputURL: outputURL, fileType: fileType ) + Task { [progressContinuation] in + for await progress in await sampleWriter.progressStream { + progressContinuation?.yield(progress) + } + } try await sampleWriter.writeSamples() } } diff --git a/SJSAssetExportSession/SampleWriter.swift b/SJSAssetExportSession/SampleWriter.swift index f47c67c..de00310 100644 --- a/SJSAssetExportSession/SampleWriter.swift +++ b/SJSAssetExportSession/SampleWriter.swift @@ -53,7 +53,7 @@ actor SampleWriter { private var videoInput: AVAssetWriterInput? - private lazy var progressStream: AsyncStream = AsyncStream { continuation in + lazy var progressStream: AsyncStream = AsyncStream { continuation in progressContinuation = continuation } @@ -70,6 +70,10 @@ actor SampleWriter { outputURL: URL, fileType: AVFileType ) async throws { + guard !videoOutputSettings.isEmpty else { + throw Error.setupFailure(.videoSettingsEmpty) + } + let duration = if timeRange.duration.isValid && !timeRange.duration.isPositiveInfinity { timeRange.duration @@ -82,12 +86,25 @@ actor SampleWriter { let writer = try AVAssetWriter(outputURL: outputURL, fileType: fileType) writer.shouldOptimizeForNetworkUse = optimizeForNetworkUse - guard writer.canApply(outputSettings: videoOutputSettings, forMediaType: .video) else { - throw Error.setupFailure(reason: "Cannot apply video output settings") - } let audioTracks = try await asset.sendTracks(withMediaType: .audio) + if !audioTracks.isEmpty { + guard !audioOutputSettings.isEmpty else { + throw Error.setupFailure(.audioSettingsEmpty) + } + guard writer.canApply(outputSettings: audioOutputSettings, forMediaType: .audio) else { + throw Error.setupFailure(.audioSettingsInvalid) + } + } + let videoTracks = try await asset.sendTracks(withMediaType: .video) + guard !videoTracks.isEmpty else { + throw Error.setupFailure(.videoTracksEmpty) + } + guard writer.canApply(outputSettings: videoOutputSettings, forMediaType: .video) else { + throw Error.setupFailure(.videoSettingsInvalid) + } + self.audioMix = audioMix self.audioOutputSettings = audioOutputSettings @@ -103,30 +120,40 @@ actor SampleWriter { } func writeSamples() async throws { - progressContinuation?.yield(0) + progressContinuation?.yield(0.0) writer.startWriting() reader.startReading() writer.startSession(atSourceTime: timeRange.start) - await encodeAudioTracks() await encodeVideoTracks() + await encodeAudioTracks() - if reader.status == .cancelled || writer.status == .cancelled { + try Task.checkCancellation() + + guard reader.status != .cancelled && writer.status != .cancelled else { throw CancellationError() - } else if writer.status == .failed { + } + guard writer.status != .failed else { reader.cancelReading() throw Error.writeFailure(writer.error) - } else if reader.status == .failed { + } + guard reader.status != .failed else { writer.cancelWriting() throw Error.readFailure(reader.error) - } else { - await withCheckedContinuation { continuation in - writer.finishWriting { - continuation.resume(returning: ()) - } + } + + await withCheckedContinuation { continuation in + writer.finishWriting { + continuation.resume(returning: ()) } } + + progressContinuation?.yield(1.0) + progressContinuation?.finish() + + // Make sure the last progress value is yielded before returning. + await Task.yield() } private func setUpAudio(audioTracks: [AVAssetTrack]) throws { @@ -136,7 +163,7 @@ actor SampleWriter { audioOutput.alwaysCopiesSampleData = false audioOutput.audioMix = audioMix guard reader.canAdd(audioOutput) else { - throw Error.setupFailure(reason: "Can't add audio output to reader") + throw Error.setupFailure(.cannotAddAudioOutput) } reader.add(audioOutput) self.audioOutput = audioOutput @@ -144,16 +171,14 @@ actor SampleWriter { let audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioOutputSettings) audioInput.expectsMediaDataInRealTime = false guard writer.canAdd(audioInput) else { - throw Error.setupFailure(reason: "Can't add audio input to writer") + throw Error.setupFailure(.cannotAddAudioInput) } writer.add(audioInput) self.audioInput = audioInput } private func setUpVideo(videoTracks: [AVAssetTrack]) throws { - guard !videoTracks.isEmpty else { - throw Error.setupFailure(reason: "No video tracks") - } + precondition(!videoTracks.isEmpty, "Video tracks must be provided") let videoOutput = AVAssetReaderVideoCompositionOutput( videoTracks: videoTracks, @@ -162,7 +187,7 @@ actor SampleWriter { videoOutput.alwaysCopiesSampleData = false videoOutput.videoComposition = videoComposition guard reader.canAdd(videoOutput) else { - throw Error.setupFailure(reason: "Can't add video output to reader") + throw Error.setupFailure(.cannotAddVideoOutput) } reader.add(videoOutput) self.videoOutput = videoOutput @@ -170,7 +195,7 @@ actor SampleWriter { let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoOutputSettings) videoInput.expectsMediaDataInRealTime = false guard writer.canAdd(videoInput) else { - throw Error.setupFailure(reason: "Can't add video input to writer") + throw Error.setupFailure(.cannotAddVideoInput) } writer.add(videoInput) self.videoInput = videoInput @@ -185,6 +210,10 @@ actor SampleWriter { return false } + let samplePresentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) - timeRange.start + let progress = Float(samplePresentationTime.seconds / duration.seconds) + progressContinuation?.yield(progress) + guard input.append(sampleBuffer) else { log.error(""" Failed to append audio sample buffer \(String(describing: sampleBuffer)) to @@ -200,6 +229,8 @@ actor SampleWriter { } private func encodeAudioTracks() async { + guard audioInput != nil, audioOutput != nil else { return } + return await withCheckedContinuation { continuation in self.audioInput!.requestMediaDataWhenReady(on: queue) { let hasMoreSamples = self.assumeIsolated { _self in diff --git a/SJSAssetExportSessionTests/AVFoundation+sending.swift b/SJSAssetExportSessionTests/AVFoundation+sending.swift new file mode 100644 index 0000000..6a16070 --- /dev/null +++ b/SJSAssetExportSessionTests/AVFoundation+sending.swift @@ -0,0 +1,14 @@ +// +// AVFoundation+sending.swift +// SJSAssetExportSessionTests +// +// Created by Sami Samhuri on 2024-07-07. +// + +import AVFoundation + +extension AVAsset { + func sendTracks(withMediaType mediaType: AVMediaType) async throws -> sending [AVAssetTrack] { + try await loadTracks(withMediaType: mediaType) + } +} diff --git a/SJSAssetExportSessionTests/AutoDestructingURL.swift b/SJSAssetExportSessionTests/AutoDestructingURL.swift new file mode 100644 index 0000000..1e160d8 --- /dev/null +++ b/SJSAssetExportSessionTests/AutoDestructingURL.swift @@ -0,0 +1,41 @@ +// +// AutoDestructingURL.swift +// SJSAssetExportSessionTests +// +// Created by Sami Samhuri on 2024-07-07. +// + +import Foundation +import OSLog + +private let log = Logger(OSLog(subsystem: "SJSAssetExportSessionTests", category: "AutoDestructingURL")) + +/// Wraps a URL and deletes it when this instance is deallocated. Failures to delete the file are logged. +final class AutoDestructingURL: Hashable, Sendable { + let url: URL + + init(url: URL) { + precondition(url.isFileURL, "AutoDestructFile only works with local file URLs") + self.url = url + } + + deinit { + let fm = FileManager.default + guard fm.fileExists(atPath: url.path) else { return } + + do { + try fm.removeItem(at: url) + log.debug("Auto-destructed \(self.url)") + } catch { + log.error("Failed to auto-destruct \(self.url): \(error)") + } + } + + static func == (lhs: AutoDestructingURL, rhs: AutoDestructingURL) -> Bool { + lhs.url == rhs.url + } + + func hash(into hasher: inout Hasher) { + hasher.combine(url) + } +} diff --git a/SJSAssetExportSessionTests/Resources/test.mov b/SJSAssetExportSessionTests/Resources/test-4k-hdr-hevc-30fps.mov similarity index 100% rename from SJSAssetExportSessionTests/Resources/test.mov rename to SJSAssetExportSessionTests/Resources/test-4k-hdr-hevc-30fps.mov diff --git a/SJSAssetExportSessionTests/Resources/test-720p-h264-24fps.mov b/SJSAssetExportSessionTests/Resources/test-720p-h264-24fps.mov new file mode 100644 index 0000000..934d34c Binary files /dev/null and b/SJSAssetExportSessionTests/Resources/test-720p-h264-24fps.mov differ diff --git a/SJSAssetExportSessionTests/Resources/test-no-audio.mp4 b/SJSAssetExportSessionTests/Resources/test-no-audio.mp4 new file mode 100644 index 0000000..b8b4f66 Binary files /dev/null and b/SJSAssetExportSessionTests/Resources/test-no-audio.mp4 differ diff --git a/SJSAssetExportSessionTests/Resources/test-no-video.m4a b/SJSAssetExportSessionTests/Resources/test-no-video.m4a new file mode 100644 index 0000000..5b890f8 Binary files /dev/null and b/SJSAssetExportSessionTests/Resources/test-no-video.m4a differ diff --git a/SJSAssetExportSessionTests/SJSAssetExportSessionTests.swift b/SJSAssetExportSessionTests/SJSAssetExportSessionTests.swift index 62316ab..8e580c3 100644 --- a/SJSAssetExportSessionTests/SJSAssetExportSessionTests.swift +++ b/SJSAssetExportSessionTests/SJSAssetExportSessionTests.swift @@ -10,58 +10,110 @@ import AVFoundation import Testing final class ExportSessionTests { - @Test func test_export_h264_720p_24fps() async throws { - let sourceURL = Bundle(for: Self.self).url(forResource: "test", withExtension: "mov")! - let sourceAsset = AVURLAsset(url: sourceURL) + private let defaultAudioSettings: [String: any Sendable] = [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVNumberOfChannelsKey: NSNumber(value: 2), + AVSampleRateKey: NSNumber(value: 44_100.0), + ] + + private func defaultVideoSettings(size: CGSize, bitrate: Int? = nil) -> [String: any Sendable] { + let compressionProperties: [String: any Sendable] = + if let bitrate { [AVVideoAverageBitRateKey: NSNumber(value: bitrate)] } else { [:] } + return [ + AVVideoCodecKey: AVVideoCodecType.h264.rawValue, + AVVideoWidthKey: NSNumber(value: Int(size.width)), + AVVideoHeightKey: NSNumber(value: Int(size.height)), + AVVideoCompressionPropertiesKey: compressionProperties, + ] + } + + private func resourceURL(named name: String, withExtension ext: String) -> URL { + Bundle(for: Self.self).url(forResource: name, withExtension: ext)! + } + + private func makeAsset(url: URL) -> sending AVAsset { + AVURLAsset(url: url, options: [ + AVURLAssetPreferPreciseDurationAndTimingKey: true, + ]) + } + + private func makeFilename(function: String = #function) -> String { let timestamp = Int(Date.now.timeIntervalSince1970) - let filename = "ExportSessionTests_testEncode_\(timestamp).mp4" - let destinationURL = URL.temporaryDirectory.appending(component: filename) - defer { _ = try? FileManager.default.removeItem(at: destinationURL) } + let f = function.replacing(/[\(\)]/, with: { _ in "" }) + let filename = "\(Self.self)_\(f)_\(timestamp).mp4" + return filename + } + + private func makeTemporaryURL(function: String = #function) -> AutoDestructingURL { + let filename = makeFilename(function: function) + let url = URL.temporaryDirectory.appending(component: filename) + return AutoDestructingURL(url: url) + } + + private func makeVideoComposition( + assetURL: URL, + size: CGSize? = nil, + fps: Int? = nil, + removeHDR: Bool = false + ) async throws -> sending AVMutableVideoComposition { + let asset = makeAsset(url: assetURL) + let videoComposition = try await AVMutableVideoComposition.videoComposition( + withPropertiesOf: asset + ) + if let size { + videoComposition.renderSize = size + } + if let fps { + let seconds = 1.0 / TimeInterval(fps) + videoComposition.sourceTrackIDForFrameTiming = kCMPersistentTrackID_Invalid + videoComposition.frameDuration = CMTime(seconds: seconds, preferredTimescale: 600) + } + if removeHDR { + videoComposition.colorPrimaries = AVVideoColorPrimaries_ITU_R_709_2 + videoComposition.colorTransferFunction = AVVideoTransferFunction_ITU_R_709_2 + videoComposition.colorYCbCrMatrix = AVVideoYCbCrMatrix_ITU_R_709_2 + } + return videoComposition + } + + @Test func test_export_720p_h264_24fps() async throws { + let sourceURL = resourceURL(named: "test-4k-hdr-hevc-30fps", withExtension: "mov") + let sourceAsset = makeAsset(url: sourceURL) let size = CGSize(width: 1280, height: 720) let duration = CMTime(seconds: 1, preferredTimescale: 600) - let videoComposition = try await AVMutableVideoComposition.videoComposition(withPropertiesOf: sourceAsset) - videoComposition.renderSize = size - videoComposition.sourceTrackIDForFrameTiming = kCMPersistentTrackID_Invalid - videoComposition.frameDuration = CMTime(seconds: 1 / 24, preferredTimescale: 600) - videoComposition.colorPrimaries = AVVideoColorPrimaries_ITU_R_709_2 - videoComposition.colorTransferFunction = AVVideoTransferFunction_ITU_R_709_2 - videoComposition.colorYCbCrMatrix = AVVideoYCbCrMatrix_ITU_R_709_2 + let videoComposition = try await makeVideoComposition( + assetURL: sourceURL, + size: size, + fps: 24, + removeHDR: true + ) + let destinationURL = makeTemporaryURL() - try await ExportSession.export( + let subject = ExportSession() + try await subject.export( asset: sourceAsset, audioMix: nil, - audioOutputSettings: [ - AVFormatIDKey: kAudioFormatMPEG4AAC, - AVNumberOfChannelsKey: NSNumber(value: 2), - AVSampleRateKey: NSNumber(value: 44_100.0), - ], + audioOutputSettings: defaultAudioSettings, videoComposition: videoComposition, - videoOutputSettings: [ - AVVideoCodecKey: AVVideoCodecType.h264.rawValue, - AVVideoWidthKey: NSNumber(value: Int(size.width)), - AVVideoHeightKey: NSNumber(value: Int(size.height)), - AVVideoCompressionPropertiesKey: [ - AVVideoAverageBitRateKey: NSNumber(value: 1_000_000), - ] as [String: any Sendable], - ], + videoOutputSettings: defaultVideoSettings(size: size, bitrate: 1_000_000), timeRange: CMTimeRange(start: .zero, duration: duration), - to: destinationURL, + to: destinationURL.url, as: .mp4 ) - let asset = AVURLAsset(url: destinationURL) - #expect(try await asset.load(.duration) == duration) + let exportedAsset = AVURLAsset(url: destinationURL.url) + #expect(try await exportedAsset.load(.duration) == duration) // Audio - try #require(try await asset.loadTracks(withMediaType: .audio).count == 1) - let audioTrack = try #require(await asset.loadTracks(withMediaType: .audio).first) + try #require(try await exportedAsset.sendTracks(withMediaType: .audio).count == 1) + let audioTrack = try #require(await exportedAsset.sendTracks(withMediaType: .audio).first) let audioFormat = try #require(await audioTrack.load(.formatDescriptions).first) #expect(audioFormat.mediaType == .audio) #expect(audioFormat.mediaSubType == .mpeg4AAC) #expect(audioFormat.audioChannelLayout?.numberOfChannels == 2) #expect(audioFormat.audioStreamBasicDescription?.mSampleRate == 44_100) // Video - try #require(await asset.loadTracks(withMediaType: .video).count == 1) - let videoTrack = try #require(await asset.loadTracks(withMediaType: .video).first) + try #require(await exportedAsset.sendTracks(withMediaType: .video).count == 1) + let videoTrack = try #require(await exportedAsset.sendTracks(withMediaType: .video).first) #expect(try await videoTrack.load(.naturalSize) == CGSize(width: 1280, height: 720)) #expect(try await videoTrack.load(.nominalFrameRate) == 24.0) #expect(try await videoTrack.load(.estimatedDataRate) == 1_036_128) @@ -72,4 +124,175 @@ final class ExportSessionTests { #expect(videoFormat.extensions[.transferFunction] == .transferFunction(.itu_R_709_2)) #expect(videoFormat.extensions[.yCbCrMatrix] == .yCbCrMatrix(.itu_R_709_2)) } + + @Test func test_export_default_timerange() async throws { + let sourceURL = resourceURL(named: "test-720p-h264-24fps", withExtension: "mov") + let sourceAsset = makeAsset(url: sourceURL) + let originalDuration = try await sourceAsset.load(.duration) + let videoComposition = try await makeVideoComposition(assetURL: sourceURL) + let destinationURL = makeTemporaryURL() + + let subject = ExportSession() + try await subject.export( + asset: sourceAsset, + audioMix: nil, + audioOutputSettings: defaultAudioSettings, + videoComposition: videoComposition, + videoOutputSettings: defaultVideoSettings(size: videoComposition.renderSize), + to: destinationURL.url, + as: .mov + ) + + let exportedAsset = AVURLAsset(url: destinationURL.url) + #expect(try await exportedAsset.load(.duration) == originalDuration) + } + + @Test func test_export_progress() async throws { + let sourceURL = resourceURL(named: "test-720p-h264-24fps", withExtension: "mov") + let sourceAsset = makeAsset(url: sourceURL) + let videoComposition = try await makeVideoComposition(assetURL: sourceURL) + let size = videoComposition.renderSize + let progressValues = SendableWrapper<[Float]>([]) + + let subject = ExportSession() + Task { + for await progress in subject.progressStream { + progressValues.value.append(progress) + } + } + try await subject.export( + asset: sourceAsset, + audioMix: nil, + audioOutputSettings: defaultAudioSettings, + videoComposition: videoComposition, + videoOutputSettings: defaultVideoSettings(size: size), + to: makeTemporaryURL().url, + as: .mov + ) + + #expect(progressValues.value.count > 2, "There should be intermediate progress updates") + #expect(progressValues.value.first == 0.0) + #expect(progressValues.value.last == 1.0) + } + + @Test func test_export_works_with_no_audio() async throws { + let sourceURL = resourceURL(named: "test-no-audio", withExtension: "mp4") + let sourceAsset = makeAsset(url: sourceURL) + let videoComposition = try await makeVideoComposition(assetURL: sourceURL) + + let subject = ExportSession() + try await subject.export( + asset: sourceAsset, + audioMix: nil, + audioOutputSettings: [:], + videoComposition: videoComposition, + videoOutputSettings: defaultVideoSettings(size: videoComposition.renderSize), + to: makeTemporaryURL().url, + as: .mov + ) + } + + @Test func test_export_throws_with_empty_audio_settings() async throws { + try await #require(throws: ExportSession.Error.setupFailure(.audioSettingsEmpty)) { + let sourceURL = resourceURL(named: "test-720p-h264-24fps", withExtension: "mov") + let sourceAsset = makeAsset(url: sourceURL) + let videoComposition = try await makeVideoComposition(assetURL: sourceURL) + + let subject = ExportSession() + try await subject.export( + asset: sourceAsset, + audioMix: nil, + audioOutputSettings: [:], + videoComposition: videoComposition, + videoOutputSettings: defaultVideoSettings(size: videoComposition.renderSize), + to: makeTemporaryURL().url, + as: .mov + ) + } + } + + @Test func test_export_throws_with_invalid_audio_settings() async throws { + try await #require(throws: ExportSession.Error.setupFailure(.audioSettingsInvalid)) { + let sourceURL = resourceURL(named: "test-720p-h264-24fps", withExtension: "mov") + let sourceAsset = makeAsset(url: sourceURL) + let videoComposition = try await makeVideoComposition(assetURL: sourceURL) + + let subject = ExportSession() + try await subject.export( + asset: sourceAsset, + audioMix: nil, + audioOutputSettings: [ + AVFormatIDKey: kAudioFormatMPEG4AAC, + AVNumberOfChannelsKey: NSNumber(value: -1), // invalid number of channels + ], + videoComposition: videoComposition, + videoOutputSettings: defaultVideoSettings(size: videoComposition.renderSize), + to: makeTemporaryURL().url, + as: .mov + ) + } + } + + @Test func test_export_throws_with_empty_video_settings() async throws { + try await #require(throws: ExportSession.Error.setupFailure(.videoSettingsEmpty)) { + let sourceURL = resourceURL(named: "test-720p-h264-24fps", withExtension: "mov") + let sourceAsset = makeAsset(url: sourceURL) + let videoComposition = try await makeVideoComposition(assetURL: sourceURL) + + let subject = ExportSession() + try await subject.export( + asset: sourceAsset, + audioMix: nil, + audioOutputSettings: defaultAudioSettings, + videoComposition: videoComposition, + videoOutputSettings: [:], + to: makeTemporaryURL().url, + as: .mov + ) + } + } + + @Test func test_export_throws_with_invalid_video_settings() async throws { + try await #require(throws: ExportSession.Error.setupFailure(.videoSettingsInvalid)) { + let sourceURL = resourceURL(named: "test-720p-h264-24fps", withExtension: "mov") + let sourceAsset = makeAsset(url: sourceURL) + let videoComposition = try await makeVideoComposition(assetURL: sourceURL) + let size = videoComposition.renderSize + + let subject = ExportSession() + try await subject.export( + asset: sourceAsset, + audioMix: nil, + audioOutputSettings: defaultAudioSettings, + videoComposition: videoComposition, + videoOutputSettings: [ + AVVideoCodecKey: AVVideoCodecType.h264.rawValue, + // missing video width + AVVideoHeightKey: NSNumber(value: Int(size.height)), + ], + to: makeTemporaryURL().url, + as: .mov + ) + } + } + + @Test func test_export_throws_with_no_video() async throws { + try await #require(throws: ExportSession.Error.setupFailure(.videoTracksEmpty)) { + let sourceURL = resourceURL(named: "test-no-video", withExtension: "m4a") + let sourceAsset = makeAsset(url: sourceURL) + let videoComposition = try await makeVideoComposition(assetURL: sourceURL) + let size = videoComposition.renderSize + + let subject = ExportSession() + try await subject.export( + asset: sourceAsset, + audioMix: nil, + audioOutputSettings: defaultAudioSettings, + videoComposition: videoComposition, + videoOutputSettings: defaultVideoSettings(size: size), + to: makeTemporaryURL().url, + as: .mov + ) + } + } } diff --git a/SJSAssetExportSessionTests/SendableWrapper.swift b/SJSAssetExportSessionTests/SendableWrapper.swift new file mode 100644 index 0000000..ef14c2c --- /dev/null +++ b/SJSAssetExportSessionTests/SendableWrapper.swift @@ -0,0 +1,27 @@ +// +// SendableWrapper.swift +// SJSAssetExportSessionTests +// +// Created by Sami Samhuri on 2024-07-07. +// + +import Foundation + +final class SendableWrapper: @unchecked Sendable { + private var unsafeValue: T + + private let lock = NSLock() + + var value: T { + get { + lock.withLock { unsafeValue } + } + set { + lock.withLock { unsafeValue = newValue } + } + } + + init(_ value: T) { + unsafeValue = value + } +}