mirror of
https://github.com/samsonjs/SJSAssetExportSession.git
synced 2026-04-27 14:57:46 +00:00
Wire up progress and flesh out tests
This commit is contained in:
parent
ee61962f0c
commit
a44627b971
11 changed files with 484 additions and 73 deletions
|
|
@ -3,7 +3,7 @@
|
||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 60;
|
objectVersion = 70;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
|
@ -13,7 +13,9 @@
|
||||||
7B9BC0192C305D2C00C160C2 /* SJSAssetExportSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9BC0182C305D2C00C160C2 /* SJSAssetExportSessionTests.swift */; };
|
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, ); }; };
|
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 */; };
|
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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy 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; };
|
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 = "<group>"; };
|
7B9BC0182C305D2C00C160C2 /* SJSAssetExportSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SJSAssetExportSessionTests.swift; sourceTree = "<group>"; };
|
||||||
7B9BC0272C30612C00C160C2 /* ExportSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportSession.swift; sourceTree = "<group>"; };
|
7B9BC0272C30612C00C160C2 /* ExportSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportSession.swift; sourceTree = "<group>"; };
|
||||||
7BC5FC702C3A52B50090B757 /* test.mov */ = {isa = PBXFileReference; lastKnownFileType = video.quicktime; path = test.mov; sourceTree = "<group>"; };
|
7BC5FC762C3B8C5A0090B757 /* SendableWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendableWrapper.swift; sourceTree = "<group>"; };
|
||||||
|
7BC5FC782C3B90F70090B757 /* AutoDestructingURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoDestructingURL.swift; sourceTree = "<group>"; };
|
||||||
|
7BC5FC7A2C3B93270090B757 /* AVFoundation+sending.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AVFoundation+sending.swift"; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
7BC5FC812C3B9E3D0090B757 /* Resources */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Resources; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
7B9BC0062C305D2C00C160C2 /* Frameworks */ = {
|
7B9BC0062C305D2C00C160C2 /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
|
@ -88,20 +96,15 @@
|
||||||
7B9BC0172C305D2C00C160C2 /* SJSAssetExportSessionTests */ = {
|
7B9BC0172C305D2C00C160C2 /* SJSAssetExportSessionTests */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
7BC5FC6D2C3A525A0090B757 /* Resources */,
|
7BC5FC782C3B90F70090B757 /* AutoDestructingURL.swift */,
|
||||||
|
7BC5FC812C3B9E3D0090B757 /* Resources */,
|
||||||
|
7BC5FC762C3B8C5A0090B757 /* SendableWrapper.swift */,
|
||||||
7B9BC0182C305D2C00C160C2 /* SJSAssetExportSessionTests.swift */,
|
7B9BC0182C305D2C00C160C2 /* SJSAssetExportSessionTests.swift */,
|
||||||
|
7BC5FC7A2C3B93270090B757 /* AVFoundation+sending.swift */,
|
||||||
);
|
);
|
||||||
path = SJSAssetExportSessionTests;
|
path = SJSAssetExportSessionTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
7BC5FC6D2C3A525A0090B757 /* Resources */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
7BC5FC702C3A52B50090B757 /* test.mov */,
|
|
||||||
);
|
|
||||||
path = Resources;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXHeadersBuildPhase section */
|
/* Begin PBXHeadersBuildPhase section */
|
||||||
|
|
@ -147,6 +150,9 @@
|
||||||
dependencies = (
|
dependencies = (
|
||||||
7B9BC0162C305D2C00C160C2 /* PBXTargetDependency */,
|
7B9BC0162C305D2C00C160C2 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
7BC5FC812C3B9E3D0090B757 /* Resources */,
|
||||||
|
);
|
||||||
name = SJSAssetExportSessionTests;
|
name = SJSAssetExportSessionTests;
|
||||||
productName = SJSAssetExportSessionTests;
|
productName = SJSAssetExportSessionTests;
|
||||||
productReference = 7B9BC0132C305D2C00C160C2 /* SJSAssetExportSessionTests.xctest */;
|
productReference = 7B9BC0132C305D2C00C160C2 /* SJSAssetExportSessionTests.xctest */;
|
||||||
|
|
@ -201,7 +207,6 @@
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
7BC5FC712C3A52B50090B757 /* test.mov in Resources */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
@ -223,6 +228,9 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
7B9BC0192C305D2C00C160C2 /* SJSAssetExportSessionTests.swift in Sources */,
|
7B9BC0192C305D2C00C160C2 /* SJSAssetExportSessionTests.swift in Sources */,
|
||||||
|
7BC5FC792C3B90F70090B757 /* AutoDestructingURL.swift in Sources */,
|
||||||
|
7BC5FC772C3B8C5A0090B757 /* SendableWrapper.swift in Sources */,
|
||||||
|
7BC5FC7B2C3B93270090B757 /* AVFoundation+sending.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,25 +7,87 @@
|
||||||
|
|
||||||
public import AVFoundation
|
public import AVFoundation
|
||||||
|
|
||||||
public final class ExportSession {
|
|
||||||
public enum Error: LocalizedError {
|
// @unchecked Sendable because progress properties are mutable, it's safe though.
|
||||||
case setupFailure(reason: String)
|
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 readFailure((any Swift.Error)?)
|
||||||
case writeFailure((any Swift.Error)?)
|
case writeFailure((any Swift.Error)?)
|
||||||
|
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case let .setupFailure(reason):
|
case let .setupFailure(reason):
|
||||||
reason
|
reason.description
|
||||||
case let .readFailure(underlyingError):
|
case let .readFailure(underlyingError):
|
||||||
underlyingError?.localizedDescription ?? "Unknown read failure"
|
underlyingError?.localizedDescription ?? "Unknown read failure"
|
||||||
case let .writeFailure(underlyingError):
|
case let .writeFailure(underlyingError):
|
||||||
underlyingError?.localizedDescription ?? "Unknown write failure"
|
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<Float>
|
||||||
|
|
||||||
|
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,
|
asset: sending AVAsset,
|
||||||
audioMix: sending AVAudioMix?,
|
audioMix: sending AVAudioMix?,
|
||||||
audioOutputSettings: [String: (any Sendable)],
|
audioOutputSettings: [String: (any Sendable)],
|
||||||
|
|
@ -47,6 +109,11 @@ public final class ExportSession {
|
||||||
outputURL: outputURL,
|
outputURL: outputURL,
|
||||||
fileType: fileType
|
fileType: fileType
|
||||||
)
|
)
|
||||||
|
Task { [progressContinuation] in
|
||||||
|
for await progress in await sampleWriter.progressStream {
|
||||||
|
progressContinuation?.yield(progress)
|
||||||
|
}
|
||||||
|
}
|
||||||
try await sampleWriter.writeSamples()
|
try await sampleWriter.writeSamples()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ actor SampleWriter {
|
||||||
|
|
||||||
private var videoInput: AVAssetWriterInput?
|
private var videoInput: AVAssetWriterInput?
|
||||||
|
|
||||||
private lazy var progressStream: AsyncStream<Float> = AsyncStream { continuation in
|
lazy var progressStream: AsyncStream<Float> = AsyncStream { continuation in
|
||||||
progressContinuation = continuation
|
progressContinuation = continuation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,6 +70,10 @@ actor SampleWriter {
|
||||||
outputURL: URL,
|
outputURL: URL,
|
||||||
fileType: AVFileType
|
fileType: AVFileType
|
||||||
) async throws {
|
) async throws {
|
||||||
|
guard !videoOutputSettings.isEmpty else {
|
||||||
|
throw Error.setupFailure(.videoSettingsEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
let duration =
|
let duration =
|
||||||
if timeRange.duration.isValid && !timeRange.duration.isPositiveInfinity {
|
if timeRange.duration.isValid && !timeRange.duration.isPositiveInfinity {
|
||||||
timeRange.duration
|
timeRange.duration
|
||||||
|
|
@ -82,12 +86,25 @@ actor SampleWriter {
|
||||||
|
|
||||||
let writer = try AVAssetWriter(outputURL: outputURL, fileType: fileType)
|
let writer = try AVAssetWriter(outputURL: outputURL, fileType: fileType)
|
||||||
writer.shouldOptimizeForNetworkUse = optimizeForNetworkUse
|
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)
|
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)
|
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.audioMix = audioMix
|
||||||
self.audioOutputSettings = audioOutputSettings
|
self.audioOutputSettings = audioOutputSettings
|
||||||
|
|
@ -103,30 +120,40 @@ actor SampleWriter {
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeSamples() async throws {
|
func writeSamples() async throws {
|
||||||
progressContinuation?.yield(0)
|
progressContinuation?.yield(0.0)
|
||||||
|
|
||||||
writer.startWriting()
|
writer.startWriting()
|
||||||
reader.startReading()
|
reader.startReading()
|
||||||
writer.startSession(atSourceTime: timeRange.start)
|
writer.startSession(atSourceTime: timeRange.start)
|
||||||
|
|
||||||
await encodeAudioTracks()
|
|
||||||
await encodeVideoTracks()
|
await encodeVideoTracks()
|
||||||
|
await encodeAudioTracks()
|
||||||
|
|
||||||
if reader.status == .cancelled || writer.status == .cancelled {
|
try Task.checkCancellation()
|
||||||
|
|
||||||
|
guard reader.status != .cancelled && writer.status != .cancelled else {
|
||||||
throw CancellationError()
|
throw CancellationError()
|
||||||
} else if writer.status == .failed {
|
}
|
||||||
|
guard writer.status != .failed else {
|
||||||
reader.cancelReading()
|
reader.cancelReading()
|
||||||
throw Error.writeFailure(writer.error)
|
throw Error.writeFailure(writer.error)
|
||||||
} else if reader.status == .failed {
|
}
|
||||||
|
guard reader.status != .failed else {
|
||||||
writer.cancelWriting()
|
writer.cancelWriting()
|
||||||
throw Error.readFailure(reader.error)
|
throw Error.readFailure(reader.error)
|
||||||
} else {
|
}
|
||||||
await withCheckedContinuation { continuation in
|
|
||||||
writer.finishWriting {
|
await withCheckedContinuation { continuation in
|
||||||
continuation.resume(returning: ())
|
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 {
|
private func setUpAudio(audioTracks: [AVAssetTrack]) throws {
|
||||||
|
|
@ -136,7 +163,7 @@ actor SampleWriter {
|
||||||
audioOutput.alwaysCopiesSampleData = false
|
audioOutput.alwaysCopiesSampleData = false
|
||||||
audioOutput.audioMix = audioMix
|
audioOutput.audioMix = audioMix
|
||||||
guard reader.canAdd(audioOutput) else {
|
guard reader.canAdd(audioOutput) else {
|
||||||
throw Error.setupFailure(reason: "Can't add audio output to reader")
|
throw Error.setupFailure(.cannotAddAudioOutput)
|
||||||
}
|
}
|
||||||
reader.add(audioOutput)
|
reader.add(audioOutput)
|
||||||
self.audioOutput = audioOutput
|
self.audioOutput = audioOutput
|
||||||
|
|
@ -144,16 +171,14 @@ actor SampleWriter {
|
||||||
let audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioOutputSettings)
|
let audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioOutputSettings)
|
||||||
audioInput.expectsMediaDataInRealTime = false
|
audioInput.expectsMediaDataInRealTime = false
|
||||||
guard writer.canAdd(audioInput) else {
|
guard writer.canAdd(audioInput) else {
|
||||||
throw Error.setupFailure(reason: "Can't add audio input to writer")
|
throw Error.setupFailure(.cannotAddAudioInput)
|
||||||
}
|
}
|
||||||
writer.add(audioInput)
|
writer.add(audioInput)
|
||||||
self.audioInput = audioInput
|
self.audioInput = audioInput
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setUpVideo(videoTracks: [AVAssetTrack]) throws {
|
private func setUpVideo(videoTracks: [AVAssetTrack]) throws {
|
||||||
guard !videoTracks.isEmpty else {
|
precondition(!videoTracks.isEmpty, "Video tracks must be provided")
|
||||||
throw Error.setupFailure(reason: "No video tracks")
|
|
||||||
}
|
|
||||||
|
|
||||||
let videoOutput = AVAssetReaderVideoCompositionOutput(
|
let videoOutput = AVAssetReaderVideoCompositionOutput(
|
||||||
videoTracks: videoTracks,
|
videoTracks: videoTracks,
|
||||||
|
|
@ -162,7 +187,7 @@ actor SampleWriter {
|
||||||
videoOutput.alwaysCopiesSampleData = false
|
videoOutput.alwaysCopiesSampleData = false
|
||||||
videoOutput.videoComposition = videoComposition
|
videoOutput.videoComposition = videoComposition
|
||||||
guard reader.canAdd(videoOutput) else {
|
guard reader.canAdd(videoOutput) else {
|
||||||
throw Error.setupFailure(reason: "Can't add video output to reader")
|
throw Error.setupFailure(.cannotAddVideoOutput)
|
||||||
}
|
}
|
||||||
reader.add(videoOutput)
|
reader.add(videoOutput)
|
||||||
self.videoOutput = videoOutput
|
self.videoOutput = videoOutput
|
||||||
|
|
@ -170,7 +195,7 @@ actor SampleWriter {
|
||||||
let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoOutputSettings)
|
let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoOutputSettings)
|
||||||
videoInput.expectsMediaDataInRealTime = false
|
videoInput.expectsMediaDataInRealTime = false
|
||||||
guard writer.canAdd(videoInput) else {
|
guard writer.canAdd(videoInput) else {
|
||||||
throw Error.setupFailure(reason: "Can't add video input to writer")
|
throw Error.setupFailure(.cannotAddVideoInput)
|
||||||
}
|
}
|
||||||
writer.add(videoInput)
|
writer.add(videoInput)
|
||||||
self.videoInput = videoInput
|
self.videoInput = videoInput
|
||||||
|
|
@ -185,6 +210,10 @@ actor SampleWriter {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let samplePresentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) - timeRange.start
|
||||||
|
let progress = Float(samplePresentationTime.seconds / duration.seconds)
|
||||||
|
progressContinuation?.yield(progress)
|
||||||
|
|
||||||
guard input.append(sampleBuffer) else {
|
guard input.append(sampleBuffer) else {
|
||||||
log.error("""
|
log.error("""
|
||||||
Failed to append audio sample buffer \(String(describing: sampleBuffer)) to
|
Failed to append audio sample buffer \(String(describing: sampleBuffer)) to
|
||||||
|
|
@ -200,6 +229,8 @@ actor SampleWriter {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func encodeAudioTracks() async {
|
private func encodeAudioTracks() async {
|
||||||
|
guard audioInput != nil, audioOutput != nil else { return }
|
||||||
|
|
||||||
return await withCheckedContinuation { continuation in
|
return await withCheckedContinuation { continuation in
|
||||||
self.audioInput!.requestMediaDataWhenReady(on: queue) {
|
self.audioInput!.requestMediaDataWhenReady(on: queue) {
|
||||||
let hasMoreSamples = self.assumeIsolated { _self in
|
let hasMoreSamples = self.assumeIsolated { _self in
|
||||||
|
|
|
||||||
14
SJSAssetExportSessionTests/AVFoundation+sending.swift
Normal file
14
SJSAssetExportSessionTests/AVFoundation+sending.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
SJSAssetExportSessionTests/AutoDestructingURL.swift
Normal file
41
SJSAssetExportSessionTests/AutoDestructingURL.swift
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
SJSAssetExportSessionTests/Resources/test-720p-h264-24fps.mov
Normal file
BIN
SJSAssetExportSessionTests/Resources/test-720p-h264-24fps.mov
Normal file
Binary file not shown.
BIN
SJSAssetExportSessionTests/Resources/test-no-audio.mp4
Normal file
BIN
SJSAssetExportSessionTests/Resources/test-no-audio.mp4
Normal file
Binary file not shown.
BIN
SJSAssetExportSessionTests/Resources/test-no-video.m4a
Normal file
BIN
SJSAssetExportSessionTests/Resources/test-no-video.m4a
Normal file
Binary file not shown.
|
|
@ -10,58 +10,110 @@ import AVFoundation
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
final class ExportSessionTests {
|
final class ExportSessionTests {
|
||||||
@Test func test_export_h264_720p_24fps() async throws {
|
private let defaultAudioSettings: [String: any Sendable] = [
|
||||||
let sourceURL = Bundle(for: Self.self).url(forResource: "test", withExtension: "mov")!
|
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||||
let sourceAsset = AVURLAsset(url: sourceURL)
|
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 timestamp = Int(Date.now.timeIntervalSince1970)
|
||||||
let filename = "ExportSessionTests_testEncode_\(timestamp).mp4"
|
let f = function.replacing(/[\(\)]/, with: { _ in "" })
|
||||||
let destinationURL = URL.temporaryDirectory.appending(component: filename)
|
let filename = "\(Self.self)_\(f)_\(timestamp).mp4"
|
||||||
defer { _ = try? FileManager.default.removeItem(at: destinationURL) }
|
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 size = CGSize(width: 1280, height: 720)
|
||||||
let duration = CMTime(seconds: 1, preferredTimescale: 600)
|
let duration = CMTime(seconds: 1, preferredTimescale: 600)
|
||||||
let videoComposition = try await AVMutableVideoComposition.videoComposition(withPropertiesOf: sourceAsset)
|
let videoComposition = try await makeVideoComposition(
|
||||||
videoComposition.renderSize = size
|
assetURL: sourceURL,
|
||||||
videoComposition.sourceTrackIDForFrameTiming = kCMPersistentTrackID_Invalid
|
size: size,
|
||||||
videoComposition.frameDuration = CMTime(seconds: 1 / 24, preferredTimescale: 600)
|
fps: 24,
|
||||||
videoComposition.colorPrimaries = AVVideoColorPrimaries_ITU_R_709_2
|
removeHDR: true
|
||||||
videoComposition.colorTransferFunction = AVVideoTransferFunction_ITU_R_709_2
|
)
|
||||||
videoComposition.colorYCbCrMatrix = AVVideoYCbCrMatrix_ITU_R_709_2
|
let destinationURL = makeTemporaryURL()
|
||||||
|
|
||||||
try await ExportSession.export(
|
let subject = ExportSession()
|
||||||
|
try await subject.export(
|
||||||
asset: sourceAsset,
|
asset: sourceAsset,
|
||||||
audioMix: nil,
|
audioMix: nil,
|
||||||
audioOutputSettings: [
|
audioOutputSettings: defaultAudioSettings,
|
||||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
||||||
AVNumberOfChannelsKey: NSNumber(value: 2),
|
|
||||||
AVSampleRateKey: NSNumber(value: 44_100.0),
|
|
||||||
],
|
|
||||||
videoComposition: videoComposition,
|
videoComposition: videoComposition,
|
||||||
videoOutputSettings: [
|
videoOutputSettings: defaultVideoSettings(size: size, bitrate: 1_000_000),
|
||||||
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],
|
|
||||||
],
|
|
||||||
timeRange: CMTimeRange(start: .zero, duration: duration),
|
timeRange: CMTimeRange(start: .zero, duration: duration),
|
||||||
to: destinationURL,
|
to: destinationURL.url,
|
||||||
as: .mp4
|
as: .mp4
|
||||||
)
|
)
|
||||||
|
|
||||||
let asset = AVURLAsset(url: destinationURL)
|
let exportedAsset = AVURLAsset(url: destinationURL.url)
|
||||||
#expect(try await asset.load(.duration) == duration)
|
#expect(try await exportedAsset.load(.duration) == duration)
|
||||||
// Audio
|
// Audio
|
||||||
try #require(try await asset.loadTracks(withMediaType: .audio).count == 1)
|
try #require(try await exportedAsset.sendTracks(withMediaType: .audio).count == 1)
|
||||||
let audioTrack = try #require(await asset.loadTracks(withMediaType: .audio).first)
|
let audioTrack = try #require(await exportedAsset.sendTracks(withMediaType: .audio).first)
|
||||||
let audioFormat = try #require(await audioTrack.load(.formatDescriptions).first)
|
let audioFormat = try #require(await audioTrack.load(.formatDescriptions).first)
|
||||||
#expect(audioFormat.mediaType == .audio)
|
#expect(audioFormat.mediaType == .audio)
|
||||||
#expect(audioFormat.mediaSubType == .mpeg4AAC)
|
#expect(audioFormat.mediaSubType == .mpeg4AAC)
|
||||||
#expect(audioFormat.audioChannelLayout?.numberOfChannels == 2)
|
#expect(audioFormat.audioChannelLayout?.numberOfChannels == 2)
|
||||||
#expect(audioFormat.audioStreamBasicDescription?.mSampleRate == 44_100)
|
#expect(audioFormat.audioStreamBasicDescription?.mSampleRate == 44_100)
|
||||||
// Video
|
// Video
|
||||||
try #require(await asset.loadTracks(withMediaType: .video).count == 1)
|
try #require(await exportedAsset.sendTracks(withMediaType: .video).count == 1)
|
||||||
let videoTrack = try #require(await asset.loadTracks(withMediaType: .video).first)
|
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(.naturalSize) == CGSize(width: 1280, height: 720))
|
||||||
#expect(try await videoTrack.load(.nominalFrameRate) == 24.0)
|
#expect(try await videoTrack.load(.nominalFrameRate) == 24.0)
|
||||||
#expect(try await videoTrack.load(.estimatedDataRate) == 1_036_128)
|
#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[.transferFunction] == .transferFunction(.itu_R_709_2))
|
||||||
#expect(videoFormat.extensions[.yCbCrMatrix] == .yCbCrMatrix(.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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
SJSAssetExportSessionTests/SendableWrapper.swift
Normal file
27
SJSAssetExportSessionTests/SendableWrapper.swift
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
//
|
||||||
|
// SendableWrapper.swift
|
||||||
|
// SJSAssetExportSessionTests
|
||||||
|
//
|
||||||
|
// Created by Sami Samhuri on 2024-07-07.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class SendableWrapper<T>: @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
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue