Add a test and make it actually work

This commit is contained in:
Sami Samhuri 2024-07-06 22:08:29 -07:00
parent 36f055d36f
commit 750b77210c
No known key found for this signature in database
5 changed files with 140 additions and 117 deletions

View file

@ -13,6 +13,7 @@
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 */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -33,6 +34,7 @@
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>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -86,11 +88,20 @@
7B9BC0172C305D2C00C160C2 /* SJSAssetExportSessionTests */ = { 7B9BC0172C305D2C00C160C2 /* SJSAssetExportSessionTests */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
7BC5FC6D2C3A525A0090B757 /* Resources */,
7B9BC0182C305D2C00C160C2 /* SJSAssetExportSessionTests.swift */, 7B9BC0182C305D2C00C160C2 /* SJSAssetExportSessionTests.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 */
@ -190,6 +201,7 @@
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
7BC5FC712C3A52B50090B757 /* test.mov in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View file

@ -25,11 +25,11 @@ public final class ExportSession {
} }
} }
public func export( public static func export(
asset: sending AVAsset, asset: sending AVAsset,
audioMix: sending AVAudioMix?, audioMix: sending AVAudioMix?,
audioOutputSettings: [String: (any Sendable)], audioOutputSettings: [String: (any Sendable)],
videoComposition: sending AVVideoComposition?, videoComposition: sending AVVideoComposition,
videoOutputSettings: [String: (any Sendable)], videoOutputSettings: [String: (any Sendable)],
timeRange: CMTimeRange? = nil, timeRange: CMTimeRange? = nil,
optimizeForNetworkUse: Bool = false, optimizeForNetworkUse: Bool = false,

View file

@ -6,6 +6,9 @@
// //
import AVFoundation.AVAsset import AVFoundation.AVAsset
import OSLog
private let log = Logger(subsystem: "SJSAssetExportSession", category: "SampleWriter")
private extension AVAsset { private extension AVAsset {
func sendTracks(withMediaType mediaType: AVMediaType) async throws -> sending [AVAssetTrack] { func sendTracks(withMediaType mediaType: AVMediaType) async throws -> sending [AVAssetTrack] {
@ -24,25 +27,21 @@ actor SampleWriter {
queue.asUnownedSerialExecutor() queue.asUnownedSerialExecutor()
} }
let audioTracks: [AVAssetTrack] private let audioMix: AVAudioMix?
let audioMix: AVAudioMix? private let audioOutputSettings: [String: (any Sendable)]
let audioOutputSettings: [String: (any Sendable)] private let videoComposition: AVVideoComposition?
let videoTracks: [AVAssetTrack] private let videoOutputSettings: [String: (any Sendable)]
let videoComposition: AVVideoComposition? private let reader: AVAssetReader
let videoOutputSettings: [String: (any Sendable)] private let writer: AVAssetWriter
let reader: AVAssetReader private let duration: CMTime
let writer: AVAssetWriter private let timeRange: CMTimeRange
let duration: CMTime
let timeRange: CMTimeRange
private var audioOutput: AVAssetReaderAudioMixOutput? private var audioOutput: AVAssetReaderAudioMixOutput?
@ -52,14 +51,18 @@ actor SampleWriter {
private var videoInput: AVAssetWriterInput? private var videoInput: AVAssetWriterInput?
private var pixelBufferAdaptor: AVAssetWriterInputPixelBufferAdaptor? private lazy var progressStream: AsyncStream<Float> = AsyncStream { continuation in
progressContinuation = continuation
}
private var progressContinuation: AsyncStream<Float>.Continuation?
init( init(
asset: sending AVAsset, asset: sending AVAsset,
timeRange: CMTimeRange, timeRange: CMTimeRange,
audioMix: AVAudioMix?, audioMix: AVAudioMix?,
audioOutputSettings: sending [String: (any Sendable)], audioOutputSettings: sending [String: (any Sendable)],
videoComposition: AVVideoComposition?, videoComposition: AVVideoComposition,
videoOutputSettings: sending [String: (any Sendable)], videoOutputSettings: sending [String: (any Sendable)],
optimizeForNetworkUse: Bool, optimizeForNetworkUse: Bool,
outputURL: URL, outputURL: URL,
@ -81,26 +84,31 @@ actor SampleWriter {
throw ExportSession.Error.setupFailure(reason: "Cannot apply video output settings") throw ExportSession.Error.setupFailure(reason: "Cannot apply video output settings")
} }
self.audioTracks = try await asset.sendTracks(withMediaType: .audio) let audioTracks = try await asset.sendTracks(withMediaType: .audio)
let videoTracks = try await asset.sendTracks(withMediaType: .video)
self.audioMix = audioMix self.audioMix = audioMix
self.audioOutputSettings = audioOutputSettings self.audioOutputSettings = audioOutputSettings
self.videoTracks = try await asset.sendTracks(withMediaType: .video)
self.videoComposition = videoComposition self.videoComposition = videoComposition
self.videoOutputSettings = videoOutputSettings self.videoOutputSettings = videoOutputSettings
self.reader = reader self.reader = reader
self.writer = writer self.writer = writer
self.duration = duration self.duration = duration
self.timeRange = timeRange self.timeRange = timeRange
try setUpAudio(audioTracks: audioTracks)
try setUpVideo(videoTracks: videoTracks)
} }
func writeSamples() async throws { func writeSamples() async throws {
progressContinuation?.yield(0)
writer.startWriting() writer.startWriting()
reader.startReading() reader.startReading()
writer.startSession(atSourceTime: timeRange.start) writer.startSession(atSourceTime: timeRange.start)
async let audioResult = try encodeAudioTracks(audioTracks) await encodeAudioTracks()
async let videoResult = try encodeVideoTracks(videoTracks) await encodeVideoTracks()
_ = try await (audioResult, videoResult)
if reader.status == .cancelled || writer.status == .cancelled { if reader.status == .cancelled || writer.status == .cancelled {
throw CancellationError() throw CancellationError()
@ -119,10 +127,12 @@ actor SampleWriter {
} }
} }
private func encodeAudioTracks(_ audioTracks: [AVAssetTrack]) async throws -> Bool { private func setUpAudio(audioTracks: [AVAssetTrack]) throws {
guard !audioTracks.isEmpty else { return false } guard !audioTracks.isEmpty else { return }
let audioOutput = AVAssetReaderAudioMixOutput(audioTracks: audioTracks, audioSettings: nil) let audioOutput = AVAssetReaderAudioMixOutput(audioTracks: audioTracks, audioSettings: nil)
audioOutput.alwaysCopiesSampleData = false
audioOutput.audioMix = audioMix
guard reader.canAdd(audioOutput) else { guard reader.canAdd(audioOutput) else {
throw ExportSession.Error.setupFailure(reason: "Can't add audio output to reader") throw ExportSession.Error.setupFailure(reason: "Can't add audio output to reader")
} }
@ -130,55 +140,23 @@ actor SampleWriter {
self.audioOutput = audioOutput self.audioOutput = audioOutput
let audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioOutputSettings) let audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioOutputSettings)
audioInput.expectsMediaDataInRealTime = false
guard writer.canAdd(audioInput) else { guard writer.canAdd(audioInput) else {
throw ExportSession.Error.setupFailure(reason: "Can't add audio input to writer") throw ExportSession.Error.setupFailure(reason: "Can't add audio input to writer")
} }
writer.add(audioInput) writer.add(audioInput)
self.audioInput = audioInput self.audioInput = audioInput
return await withCheckedContinuation { continuation in
self.audioInput?.requestMediaDataWhenReady(on: queue) {
let hasMoreSamples = self.assumeIsolated { $0.writeReadyAudioSamples() }
if !hasMoreSamples {
continuation.resume(returning: true)
}
}
}
} }
private func writeReadyAudioSamples() -> Bool { private func setUpVideo(videoTracks: [AVAssetTrack]) throws {
guard let audioOutput, let audioInput else { return true } guard !videoTracks.isEmpty else {
throw ExportSession.Error.setupFailure(reason: "No video tracks")
while audioInput.isReadyForMoreMediaData {
guard reader.status == .reading && writer.status == .writing,
let sampleBuffer = audioOutput.copyNextSampleBuffer() else {
audioInput.markAsFinished()
NSLog("Finished encoding ready audio samples from \(audioOutput)")
return false
}
guard audioInput.append(sampleBuffer) else {
NSLog("Failed to append audio sample buffer \(sampleBuffer) to input \(audioInput)")
return false
}
} }
// Everything was appended successfully, return true indicating there's more to do. let videoOutput = AVAssetReaderVideoCompositionOutput(
NSLog("Completed encoding ready audio samples, more to come...") videoTracks: videoTracks,
return true videoSettings: nil
} )
private func encodeVideoTracks(_ videoTracks: [AVAssetTrack]) async throws -> Bool {
guard !videoTracks.isEmpty else { return false }
guard let width = videoComposition.map({ Int($0.renderSize.width) })
?? (videoOutputSettings[AVVideoWidthKey] as? NSNumber)?.intValue,
let height = videoComposition.map({ Int($0.renderSize.height) })
?? (videoOutputSettings[AVVideoHeightKey] as? NSNumber)?.intValue else {
throw ExportSession.Error.setupFailure(reason: "Export dimensions must be provided in a video composition or video output settings")
}
let videoOutput = AVAssetReaderVideoCompositionOutput(videoTracks: videoTracks, videoSettings: nil)
videoOutput.alwaysCopiesSampleData = false videoOutput.alwaysCopiesSampleData = false
videoOutput.videoComposition = videoComposition videoOutput.videoComposition = videoComposition
guard reader.canAdd(videoOutput) else { guard reader.canAdd(videoOutput) else {
@ -188,72 +166,60 @@ actor SampleWriter {
self.videoOutput = videoOutput self.videoOutput = videoOutput
let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoOutputSettings) let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoOutputSettings)
videoInput.expectsMediaDataInRealTime = false
guard writer.canAdd(videoInput) else { guard writer.canAdd(videoInput) else {
throw ExportSession.Error.setupFailure(reason: "Can't add video input to writer") throw ExportSession.Error.setupFailure(reason: "Can't add video input to writer")
} }
writer.add(videoInput) writer.add(videoInput)
self.videoInput = videoInput self.videoInput = videoInput
let pixelBufferAttributes: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: NSNumber(integerLiteral: Int(kCVPixelFormatType_32RGBA)),
kCVPixelBufferWidthKey as String: NSNumber(integerLiteral: width),
kCVPixelBufferHeightKey as String: NSNumber(integerLiteral: height),
"IOSurfaceOpenGLESTextureCompatibility": NSNumber(booleanLiteral: true),
"IOSurfaceOpenGLESFBOCompatibility": NSNumber(booleanLiteral: true),
]
pixelBufferAdaptor = AVAssetWriterInputPixelBufferAdaptor(
assetWriterInput: videoInput,
sourcePixelBufferAttributes: pixelBufferAttributes
)
return await withCheckedContinuation { continuation in
self.videoInput?.requestMediaDataWhenReady(on: queue) {
let hasMoreSamples = self.assumeIsolated { $0.writeReadyVideoSamples() }
if !hasMoreSamples {
continuation.resume(returning: true)
}
}
}
} }
private func writeReadyVideoSamples() -> Bool { private func writeReadySamples(output: AVAssetReaderOutput, input: AVAssetWriterInput) -> Bool {
guard let videoOutput, let videoInput, let pixelBufferAdaptor else { return true } while input.isReadyForMoreMediaData {
while videoInput.isReadyForMoreMediaData {
guard reader.status == .reading && writer.status == .writing, guard reader.status == .reading && writer.status == .writing,
let sampleBuffer = videoOutput.copyNextSampleBuffer() else { let sampleBuffer = output.copyNextSampleBuffer() else {
videoInput.markAsFinished() input.markAsFinished()
NSLog("Finished encoding ready video samples from \(videoOutput)") log.debug("Finished encoding ready audio samples from \(output)")
return false return false
} }
let samplePresentationTime = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) - timeRange.start guard input.append(sampleBuffer) else {
let progress = Float(samplePresentationTime.seconds / duration.seconds) log.error("""
#warning("TODO: publish progress to an AsyncStream") Failed to append audio sample buffer \(String(describing: sampleBuffer)) to
input \(input.debugDescription)
guard let pixelBufferPool = pixelBufferAdaptor.pixelBufferPool else { """)
NSLog("No pixel buffer pool available on adaptor \(pixelBufferAdaptor)")
return false return false
} }
var toRenderBuffer: CVPixelBuffer?
let result = CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pixelBufferPool, &toRenderBuffer)
var handled = false
if result == kCVReturnSuccess, let toBuffer = toRenderBuffer {
handled = pixelBufferAdaptor.append(toBuffer, withPresentationTime: samplePresentationTime)
if !handled { return false }
}
if !handled {
#warning("is this really necessary?! seems like a failure scenario...")
guard videoInput.append(sampleBuffer) else {
NSLog("Failed to append video sample buffer \(sampleBuffer) to input \(videoInput)")
return false
}
}
} }
// Everything was appended successfully, return true indicating there's more to do. // Everything was appended successfully, return true indicating there's more to do.
NSLog("Completed encoding ready video samples, more to come...") log.debug("Completed encoding ready audio samples, more to come...")
return true return true
} }
private func encodeAudioTracks() async {
return await withCheckedContinuation { continuation in
self.audioInput!.requestMediaDataWhenReady(on: queue) {
let hasMoreSamples = self.assumeIsolated { _self in
_self.writeReadySamples(output: _self.audioOutput!, input: _self.audioInput!)
}
if !hasMoreSamples {
continuation.resume()
}
}
}
}
private func encodeVideoTracks() async {
return await withCheckedContinuation { continuation in
self.videoInput!.requestMediaDataWhenReady(on: queue) {
let hasMoreSamples = self.assumeIsolated { _self in
_self.writeReadySamples(output: _self.videoOutput!, input: _self.videoInput!)
}
if !hasMoreSamples {
continuation.resume()
}
}
}
}
} }

Binary file not shown.

View file

@ -5,13 +5,58 @@
// Created by Sami Samhuri on 2024-06-29. // Created by Sami Samhuri on 2024-06-29.
// //
import Testing import AVFoundation
@testable import SJSAssetExportSession @testable import SJSAssetExportSession
import Testing
struct SJSAssetExportSessionTests { final class ExportSessionTests {
@Test func test_encode_h264_720p_30fps() async throws {
let sourceURL = Bundle(for: Self.self).url(forResource: "test", withExtension: "mov")!
let sourceAsset = AVURLAsset(url: sourceURL)
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) }
@Test func testExample() async throws { let size = CGSize(width: 1280, height: 720)
// Write your test here and use APIs like `#expect(...)` to check expected conditions. let duration = CMTime(seconds: 1, preferredTimescale: 600)
let videoComposition = try await AVMutableVideoComposition.videoComposition(withPropertiesOf: sourceAsset)
videoComposition.renderSize = size
videoComposition.renderScale = 1
videoComposition.frameDuration = CMTime(value: 1, timescale: 30)
try await ExportSession.export(
asset: sourceAsset,
audioMix: nil,
audioOutputSettings: [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVNumberOfChannelsKey: NSNumber(value: 2),
AVSampleRateKey: NSNumber(value: 44_100.0),
],
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),
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel as String,
] as [String: any Sendable],
AVVideoColorPropertiesKey: [
AVVideoColorPrimariesKey: AVVideoColorPrimaries_ITU_R_709_2,
AVVideoTransferFunctionKey: AVVideoTransferFunction_ITU_R_709_2,
AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2,
],
],
timeRange: CMTimeRange(start: .zero, duration: duration),
to: destinationURL,
as: .mp4
)
let asset = AVURLAsset(url: destinationURL)
#expect(try await asset.load(.duration) == duration)
try #require(await asset.loadTracks(withMediaType: .video).count == 1)
let videoTrack = try #require(try await asset.loadTracks(withMediaType: .video).first)
#expect(try await videoTrack.load(.naturalSize) == size)
try #require(try await asset.loadTracks(withMediaType: .audio).count == 1)
} }
} }