NextLevelSessionExporter is an export and transcode media library for iOS written in Swift.
The library provides customizable audio and video encoding options unlike AVAssetExportSession and without having to learn the intricacies of AVFoundation. It was a port of SDAVAssetExportSession with inspiration from SCAssetExportSession β which are great obj-c alternatives.
- π Modern Async/Await API - Native Swift concurrency support with
async/awaitandAsyncSequence - π HDR Video Support - Automatic detection and preservation of HLG and HDR10 content with 10-bit HEVC
- π Scaling Mode Fixes - AVVideoScalingModeKey now works correctly for aspect-fill and resize (#33)
- β‘ Better Performance - Proper memory management with autoreleasepool in encoding loop
- π― QoS Configuration - Control export priority to prevent thread priority inversion (PR #44)
- π Swift 6 Strict Concurrency - Full
Sendableconformance and thread-safety - π Enhanced Error Messages - Contextual error descriptions with recovery suggestions
- β»οΈ Task Cancellation - Proper cancellation support for modern Swift concurrency
- π‘οΈ Better Error Handling - Fixed silent failures causing audio-only exports (#38)
- π Backwards Compatible - Legacy completion handler API still works for iOS 13+
- iOS 15.0+ for async/await APIs (iOS 13.0+ for legacy completion handler API)
- Swift 6.0
- Xcode 16.0+
Add the following to your Package.swift:
dependencies: [
.package(url: "https://github.com/nextlevel/NextLevelSessionExporter", from: "1.0.1")
]Or add it directly in Xcode: File β Add Package Dependencies...
pod "NextLevelSessionExporter", "~> 1.0.1"Alternatively, drop the source files into your Xcode project.
The modern Swift 6 async/await API provides clean, cancellable exports with progress updates:
let exporter = NextLevelSessionExporter(withAsset: asset)
exporter.outputFileType = .mp4
let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent(ProcessInfo().globallyUniqueString)
.appendingPathExtension("mp4")
exporter.outputURL = tmpURL
let compressionDict: [String: Any] = [
AVVideoAverageBitRateKey: NSNumber(integerLiteral: 6000000),
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel as String,
]
exporter.videoOutputConfiguration = [
AVVideoCodecKey: AVVideoCodec.h264,
AVVideoWidthKey: NSNumber(integerLiteral: 1920),
AVVideoHeightKey: NSNumber(integerLiteral: 1080),
AVVideoScalingModeKey: AVVideoScalingModeResizeAspectFill,
AVVideoCompressionPropertiesKey: compressionDict
]
exporter.audioOutputConfiguration = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVEncoderBitRateKey: NSNumber(integerLiteral: 128000),
AVNumberOfChannelsKey: NSNumber(integerLiteral: 2),
AVSampleRateKey: NSNumber(value: Float(44100))
]
// Option 1: Simple async export with progress callback
do {
let outputURL = try await exporter.export { progress in
print("Progress: \(progress * 100)%")
}
print("Export completed: \(outputURL)")
} catch {
print("Export failed: \(error)")
}
// Option 2: AsyncSequence for real-time progress updates
Task {
do {
for try await event in exporter.exportAsync() {
switch event {
case .progress(let progress):
await MainActor.run {
progressBar.progress = progress
}
case .completed(let url):
print("Export completed: \(url)")
}
}
} catch {
print("Export failed: \(error)")
}
}For compatibility with older iOS versions, you can use the completion handler API.
let exporter = NextLevelSessionExporter(withAsset: asset)
exporter.outputFileType = AVFileType.mp4
let tmpURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent(ProcessInfo().globallyUniqueString)
.appendingPathExtension("mp4")
exporter.outputURL = tmpURL
let compressionDict: [String: Any] = [
AVVideoAverageBitRateKey: NSNumber(integerLiteral: 6000000),
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel as String,
]
exporter.videoOutputConfiguration = [
AVVideoCodecKey: AVVideoCodec.h264,
AVVideoWidthKey: NSNumber(integerLiteral: 1920),
AVVideoHeightKey: NSNumber(integerLiteral: 1080),
AVVideoScalingModeKey: AVVideoScalingModeResizeAspectFill,
AVVideoCompressionPropertiesKey: compressionDict
]
exporter.audioOutputConfiguration = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVEncoderBitRateKey: NSNumber(integerLiteral: 128000),
AVNumberOfChannelsKey: NSNumber(integerLiteral: 2),
AVSampleRateKey: NSNumber(value: Float(44100))
]
exporter.export(progressHandler: { (progress) in
print(progress)
}, completionHandler: { result in
switch result {
case .success(let status):
switch status {
case .completed:
print("NextLevelSessionExporter, export completed, \(exporter.outputURL?.description ?? "")")
break
default:
print("NextLevelSessionExporter, did not complete")
break
}
break
case .failure(let error):
print("NextLevelSessionExporter, failed to export \(error)")
break
}
})The 1.0 release introduces Swift 6 with modern async/await APIs while maintaining full backward compatibility. Here's how to migrate:
Before (0.x):
exporter.export(progressHandler: { progress in
print("Progress: \(progress)")
}, completionHandler: { result in
switch result {
case .success:
print("Export completed")
case .failure(let error):
print("Export failed: \(error)")
}
})After (1.0):
do {
let outputURL = try await exporter.export { progress in
print("Progress: \(progress)")
}
print("Export completed: \(outputURL)")
} catch {
print("Export failed: \(error)")
}No changes required! The completion handler API works exactly the same. However, note that error cases now include descriptive messages:
// Errors now have helpful context
case .failure(let error):
print(error.localizedDescription) // e.g., "Failed to read media: Asset is corrupted"
print(error.recoverySuggestion) // e.g., "Verify the source asset is not corrupted"None! The 1.0 release is fully backward compatible. New async/await APIs are additive.
- Memory Management - Fixed memory leak in long video exports (no code changes needed)
- Error Messages - Errors now include contextual information and recovery suggestions
- Safety - Removed force unwraps; fallback to safe defaults
Unlike AVAssetExportSession, NextLevelSessionExporter gives you complete control over encoding parameters:
exporter.videoOutputConfiguration = [
AVVideoCodecKey: AVVideoCodecType.hevc, // H.265 for better compression
AVVideoWidthKey: 1920,
AVVideoHeightKey: 1080,
AVVideoScalingModeKey: AVVideoScalingModeResizeAspectFill,
AVVideoCompressionPropertiesKey: [
AVVideoAverageBitRateKey: 6_000_000, // 6 Mbps
AVVideoMaxKeyFrameIntervalKey: 30, // Keyframe every 30 frames
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel
]
]Control how videos are scaled to target dimensions using AVVideoScalingModeKey (Fixed in 1.0.1 - Issue #33):
exporter.videoOutputConfiguration = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: 720,
AVVideoHeightKey: 1280,
AVVideoScalingModeKey: AVVideoScalingModeResizeAspectFill // Choose your scaling mode
]Available Scaling Modes:
-
AVVideoScalingModeResizeAspectFill(Recommended)- Scales video to fill the target dimensions while maintaining aspect ratio
- May crop content to fill the entire frame
- Ideal for converting landscape β portrait or vice versa
-
AVVideoScalingModeResize- Stretches video to exact target dimensions
- Does not maintain aspect ratio
- Use when you want non-uniform scaling
-
AVVideoScalingModeResizeAspect(Default if not specified)- Fits entire video within target dimensions while maintaining aspect ratio
- May add letterboxing/pillarboxing (black bars)
- Legacy behavior for backward compatibility
Example: Landscape β Portrait Conversion
// Convert 1920x1080 landscape video to 720x1280 portrait
exporter.videoOutputConfiguration = [
AVVideoCodecKey: AVVideoCodecType.h264,
AVVideoWidthKey: 720,
AVVideoHeightKey: 1280,
AVVideoScalingModeKey: AVVideoScalingModeResizeAspectFill // Crops sides, fills frame
]Fine-tune audio settings for optimal file size and quality:
exporter.audioOutputConfiguration = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVEncoderBitRateKey: 128_000, // 128 kbps
AVNumberOfChannelsKey: 2, // Stereo
AVSampleRateKey: 44100 // 44.1 kHz
]Apply complex video compositions and audio mixing:
// Custom video composition
let composition = AVMutableVideoComposition()
composition.instructions = [/* your instructions */]
exporter.videoComposition = composition
// Custom audio mix
let audioMix = AVMutableAudioMix()
audioMix.inputParameters = [/* your parameters */]
exporter.audioMix = audioMixProcess each video frame during export with a render handler:
exporter.export { renderFrame, presentationTime, resultBuffer in
// Apply custom effects, filters, overlays, etc.
// Process renderFrame and write to resultBuffer
applyWatermark(to: resultBuffer)
} progress: { progress in
print("Progress: \(progress)")
}NextLevelSessionExporter automatically detects and preserves HDR content (HLG and HDR10) from source videos:
// Automatic HDR preservation (default behavior)
let exporter = NextLevelSessionExporter(withAsset: hdrAsset)
exporter.outputURL = outputURL
exporter.videoOutputConfiguration = [
AVVideoWidthKey: 1920,
AVVideoHeightKey: 1080
]
// HDR properties automatically detected and preserved β¨
let result = try await exporter.export()
// Output maintains HDR color space, transfer function, and 10-bit encodingFeatures:
- Automatic Detection: Detects HLG (Hybrid Log-Gamma) and HDR10 (PQ) transfer functions
- 10-bit HEVC: Automatically configures Main10 profile for 10-bit encoding
- Color Properties: Preserves ITU-R BT.2020 color primaries and YCbCr matrix
- HDR Metadata: Automatically inserts and preserves HDR metadata (iOS 14+)
To convert HDR to SDR, disable HDR preservation:
exporter.preserveHDR = false
// Output will be 8-bit SDRForce HDR encoding even for SDR source, or override detected transfer function:
// Configure for HLG HDR
exporter.configureForHDR(transferFunction: .hlg)
// Or configure for HDR10 (PQ)
exporter.configureForHDR(transferFunction: .hdr10)
// Note: HEVC codec and appropriate dimensions required
exporter.videoOutputConfiguration = [
AVVideoCodecKey: AVVideoCodecType.hevc,
AVVideoWidthKey: 1920,
AVVideoHeightKey: 1080
]Requirements:
- iOS 15.0+ or macOS 12.0+
- HEVC (H.265) codec required for HDR
- Device must support 10-bit HEVC encoding
Supported HDR Formats:
- HLG (Hybrid Log-Gamma) - Broadcast standard, better for wide compatibility
- HDR10 (PQ/SMPTE ST 2084) - Consumer HDR standard with static metadata
Export only a portion of the video:
let startTime = CMTime(seconds: 10, preferredTimescale: 600)
let endTime = CMTime(seconds: 30, preferredTimescale: 600)
exporter.timeRange = CMTimeRange(start: startTime, end: endTime)Embed custom metadata in exported videos:
let metadata: [AVMetadataItem] = [
createMetadataItem(key: .commonKeyTitle, value: "My Video"),
createMetadataItem(key: .commonKeyDescription, value: "Exported with NextLevelSessionExporter"),
]
exporter.metadata = metadataControl the priority of export operations to prevent thread priority inversion and optimize performance:
// High priority for user-initiated exports (default)
let exporter = NextLevelSessionExporter(withAsset: asset, qos: .userInitiated)
// Medium priority for background processing
let exporter = NextLevelSessionExporter(withAsset: asset, qos: .utility)
// Low priority for deferrable work
let exporter = NextLevelSessionExporter(withAsset: asset, qos: .background)When to use different QoS levels:
.userInitiated(default) - User tapped export, expects quick results.utility- Background export that can take longer.background- Batch processing, lowest priority
This resolves thread priority inversion warnings (Issues #48, #41) and is especially important when calling from async/await contexts.
The library automatically manages memory during export using autoreleasepool, preventing memory accumulation during long exports. This fix resolved Issue #56 where exports would crash after ~10 minutes.
With the modern async API, exports are properly cancelled when the Task is cancelled:
let exportTask = Task {
try await exporter.export()
}
// Cancel export
exportTask.cancel() // Properly stops export and cleans up resourcesFor optimal UI responsiveness, update progress on the main actor:
for try await event in exporter.exportAsync() {
switch event {
case .progress(let progress):
await MainActor.run {
progressView.progress = progress
}
case .completed(let url):
await handleCompletion(url)
}
}For long exports, consider using background tasks:
let taskID = await UIApplication.shared.beginBackgroundTask()
defer { await UIApplication.shared.endBackgroundTask(taskID) }
try await exporter.export()When exporting videos from the user's photo library, copy the file to your app's directory first to avoid permission issues:
// β οΈ NOT RECOMMENDED: Direct PHAsset access may cause cancelled errors
let phAsset = // ... from photo library
let avAsset = AVAsset(url: phAsset.url) // May fail!
// β
RECOMMENDED: Copy to app directory first
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("video.mov")
// Export PHAsset to temp file, then create AVAsset
let avAsset = AVAsset(url: tempURL)
let exporter = NextLevelSessionExporter(withAsset: avAsset)See the Troubleshooting section for complete implementation.
Problem: Export fails with AVFoundationErrorDomain Code=-11819 "Cannot Complete Action", especially on iOS 14.5.
Cause: This is an iOS system-level bug where media daemons crash during export operations. It's not a library issue but an Apple bug that affects AVAssetReader/AVAssetWriter operations.
Solutions:
- Implement Retry Logic (recommended):
func exportWithRetry(maxAttempts: Int = 3) async throws -> URL {
var lastError: Error?
for attempt in 1...maxAttempts {
do {
let url = try await exporter.export()
return url
} catch let error as NSError where error.code == -11819 {
lastError = error
print("Attempt \(attempt) failed with -11819, retrying...")
try await Task.sleep(nanoseconds: 500_000_000) // 0.5s delay
continue
} catch {
throw error // Other errors, don't retry
}
}
throw lastError ?? NextLevelSessionExporterError.writingFailure("Export failed after \(maxAttempts) attempts")
}-
Reduce Complexity: Lower resolution, bitrate, or remove video composition if using CoreAnimation tools
-
Update iOS: The issue is less frequent on iOS 15+
-
Report to Apple: File a Feedback Assistant report with sysdiagnose if this occurs frequently
References:
Problem: Some videos fail to compress with a cancelled/canceled error message, especially when selecting videos directly from the photo library.
Cause: File access permissions or buffering issues when reading from certain storage locations.
Solution: Copy the video to your app's writable directory before exporting:
func exportVideoFromLibrary(asset: PHAsset) async throws -> URL {
// 1. Export to temporary file first
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("mov")
// 2. Request video resource from Photos library
let options = PHVideoRequestOptions()
options.version = .current
options.deliveryMode = .highQualityFormat
try await withCheckedThrowingContinuation { continuation in
PHImageManager.default().requestExportSession(
forVideo: asset,
options: options,
exportPreset: AVAssetExportPresetPassthrough
) { exportSession, _ in
guard let session = exportSession else {
continuation.resume(throwing: NSError(domain: "Export", code: -1))
return
}
session.outputURL = tempURL
session.outputFileType = .mov
session.exportAsynchronously {
if session.status == .completed {
continuation.resume(returning: ())
} else {
continuation.resume(throwing: session.error ?? NSError(domain: "Export", code: -1))
}
}
}
}
// 3. Now export with NextLevelSessionExporter
let avAsset = AVAsset(url: tempURL)
let exporter = NextLevelSessionExporter(withAsset: avAsset)
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("mp4")
exporter.outputURL = outputURL
exporter.videoOutputConfiguration = [/* your config */]
exporter.audioOutputConfiguration = [/* your config */]
let result = try await exporter.export()
// 4. Clean up temp file
try? FileManager.default.removeItem(at: tempURL)
return result
}Alternative (simpler): Use AVAsset(url:) with a file URL rather than PHAsset directly:
// Copy to caches directory first
let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appendingPathComponent("video.mov")
// ... copy file to cacheURL ...
let asset = AVAsset(url: cacheURL)
let exporter = NextLevelSessionExporter(withAsset: asset)Problem: Export fails when reading the source asset.
Solutions:
- Verify the source asset is not corrupted
- Check that the asset is a supported format (MP4, MOV, M4V, etc.)
- Ensure the asset is accessible and not protected by DRM
- If reading from Photos library, see "Cancelled Error" above
Fixed in 1.0! Previous versions had a memory leak causing crashes on videos longer than 10 minutes. Update to 1.0 or later.
Tips:
- Lower the video bitrate and resolution for faster exports
- Use H.264 instead of HEVC for better encoding speed
- Avoid frame-by-frame processing if not needed
- Test on a physical device (simulator performance varies)
The library automatically handles video orientation and transforms. If you're experiencing issues:
Orientation Problems:
- Let the library create the video composition automatically (don't set
videoComposition) - Ensure your video output configuration includes proper width/height
Scaling Not Working (Fixed in 1.0.1):
- If video doesn't fill the target dimensions as expected, use
AVVideoScalingModeKey - See the Video Scaling Modes section for details
- Common issue: landscape β portrait conversion with black bars
- Solution: Use
AVVideoScalingModeResizeAspectFill
- Solution: Use
Issue: Some videos export without audio.
Solution: This was fixed in 1.0. The library now properly filters APAC audio tracks that cause export failures. Update to the latest version.
You can find the docs here. Documentation is generated with jazzy and hosted on GitHub-Pages.
- Found a bug? Open an issue.
- Feature idea? Open an issue.
- Want to contribute? Submit a pull request.
- AV Foundation Programming Guide
- AV Foundation Framework Reference
- NextLevel, Rad Media Capture in Swift
- GPUImage2, image processing library in Swift
- SDAVAssetExportSession, media transcoding library in obj-c
NextLevelSessionExporter is available under the MIT license, see the LICENSE file for more information.