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:
letexporter=NextLevelSessionExporter(withAsset: asset)
exporter.outputFileType =.mp4
lettmpURL=URL(fileURLWithPath:NSTemporaryDirectory(), isDirectory:true).appendingPathComponent(ProcessInfo().globallyUniqueString).appendingPathExtension("mp4")
exporter.outputURL = tmpURL
letcompressionDict:[String:Any]=[
AVVideoAverageBitRateKey:NSNumber(integerLiteral:6000000),
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel asString,]
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{letoutputURL=tryawait exporter.export{ progress inprint("Progress: \(progress *100)%")}print("Export completed: \(outputURL)")}catch{print("Export failed: \(error)")}
// Option 2: AsyncSequence for real-time progress updates
Task{do{fortryawaiteventin exporter.exportAsync(){switch event {case.progress(let progress):awaitMainActor.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.
letexporter=NextLevelSessionExporter(withAsset: asset)
exporter.outputFileType =AVFileType.mp4
lettmpURL=URL(fileURLWithPath:NSTemporaryDirectory(), isDirectory:true).appendingPathComponent(ProcessInfo().globallyUniqueString).appendingPathExtension("mp4")
exporter.outputURL = tmpURL
letcompressionDict:[String:Any]=[
AVVideoAverageBitRateKey:NSNumber(integerLiteral:6000000),
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel asString,]
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)inprint(progress)}, completionHandler:{ result inswitch result {case.success(let status):switch status {case.completed:print("NextLevelSessionExporter, export completed, \(exporter.outputURL?.description ??"")")breakdefault:print("NextLevelSessionExporter, did not complete")break}breakcase.failure(let error):print("NextLevelSessionExporter, failed to export \(error)")break}})Use videoTransform to apply a custom affine transform to video content without building a full AVVideoComposition manually. The exporter applies it as the base orientation transform, then handles centering and scaling on top.
This is useful when the raw encoded pixels are in an unexpected orientation — for example, a video whose naturalSize.height > naturalSize.width that must be delivered in landscape:
// Rotate portrait-encoded video 90° clockwise to landscape
letisPortrait=tryawait videoTrack.load(.naturalSize).height > videoTrack.load(.naturalSize).width
if isPortrait {
exporter.videoTransform =CGAffineTransform(rotationAngle:-.pi /2)
exporter.videoOutputConfiguration =[
AVVideoCodecKey:AVVideoCodecType.h264,
AVVideoWidthKey:1920, // landscape width
AVVideoHeightKey:1080, // landscape height
]}Any CGAffineTransform works — rotations, flips, or combinations. When videoComposition is set directly, videoTransform is ignored.
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 inprint("Progress: \(progress)")}, completionHandler:{ result inswitch result {case.success:print("Export completed")case.failure(let error):print("Export failed: \(error)")}})After (1.0):
do{letoutputURL=tryawait exporter.export{ progress inprint("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
letcomposition=AVMutableVideoComposition()
composition.instructions =[/* your instructions */]
exporter.videoComposition = composition
// Custom audio mix
letaudioMix=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 inprint("Progress: \(progress)")}NextLevelSessionExporter automatically detects and preserves HDR content (HLG and HDR10) from source videos:
// Automatic HDR preservation (default behavior)
letexporter=NextLevelSessionExporter(withAsset: hdrAsset)
exporter.outputURL = outputURL
exporter.videoOutputConfiguration =[
AVVideoWidthKey:1920,
AVVideoHeightKey:1080]
// HDR properties automatically detected and preserved ✨
letresult=tryawait 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:
letstartTime=CMTime(seconds:10, preferredTimescale:600)letendTime=CMTime(seconds:30, preferredTimescale:600)
exporter.timeRange =CMTimeRange(start: startTime, end: endTime)Embed custom metadata in exported videos:
letmetadata:[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)
letexporter=NextLevelSessionExporter(withAsset: asset, qos:.userInitiated)
// Medium priority for background processing
letexporter=NextLevelSessionExporter(withAsset: asset, qos:.utility)
// Low priority for deferrable work
letexporter=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:
letexportTask=Task{tryawait exporter.export()}
// Cancel export
exportTask.cancel() // Properly stops export and cleans up resourcesFor optimal UI responsiveness, update progress on the main actor:
fortryawaiteventin exporter.exportAsync(){switch event {case.progress(let progress):awaitMainActor.run{
progressView.progress = progress
}case.completed(let url):awaithandleCompletion(url)}}For long exports, consider using background tasks:
lettaskID=awaitUIApplication.shared.beginBackgroundTask()defer{awaitUIApplication.shared.endBackgroundTask(taskID)}tryawait 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
letphAsset= // ... from photo library
let avAsset =AVAsset(url: phAsset.url) // May fail!
// ✅ RECOMMENDED: Copy to app directory first
lettempURL=FileManager.default.temporaryDirectory
.appendingPathComponent("video.mov")
// Export PHAsset to temp file, then create AVAsset
letavAsset=AVAsset(url: tempURL)letexporter=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)asyncthrows->URL{varlastError:Error?forattemptin1...maxAttempts {do{leturl=tryawait exporter.export()return url
}catchlet error as NSErrorwhere error.code ==-11819{
lastError = error
print("Attempt \(attempt) failed with -11819, retrying...")tryawaitTask.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)asyncthrows->URL{
// 1. Export to temporary file first
lettempURL=FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString).appendingPathExtension("mov")
// 2. Request video resource from Photos library
letoptions=PHVideoRequestOptions()
options.version =.current
options.deliveryMode =.highQualityFormat
tryawaitwithCheckedThrowingContinuation{ continuation inPHImageManager.default().requestExportSession(
forVideo: asset,
options: options,
exportPreset: AVAssetExportPresetPassthrough
){ exportSession, _ inguardlet 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
letavAsset=AVAsset(url: tempURL)letexporter=NextLevelSessionExporter(withAsset: avAsset)letoutputURL=FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString).appendingPathExtension("mp4")
exporter.outputURL = outputURL
exporter.videoOutputConfiguration =[/* your config */]
exporter.audioOutputConfiguration =[/* your config */]letresult=tryawait 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
letcacheURL=FileManager.default.urls(for:.cachesDirectory, in:.userDomainMask)[0].appendingPathComponent("video.mov")
// ... copy file to cacheURL ...
letasset=AVAsset(url: cacheURL)letexporter=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.