Classes/MovieRecorder.m

 
/*
 <codex/>
 */
 
#import "MovieRecorder.h"
 
#import <AVFoundation/AVAssetWriter.h>
#import <AVFoundation/AVAssetWriterInput.h>
 
#import <AVFoundation/AVMediaFormat.h>
#import <AVFoundation/AVVideoSettings.h>
#import <AVFoundation/AVAudioSettings.h>
 
#include <objc/runtime.h> // for objc_loadWeak() and objc_storeWeak()
 
#define LOG_STATUS_TRANSITIONS 0
 
typedef NS_ENUM( NSInteger, MovieRecorderStatus ) {
    MovieRecorderStatusIdle = 0,
    MovieRecorderStatusPreparingToRecord,
    MovieRecorderStatusRecording,
    MovieRecorderStatusFinishingRecordingPart1, // waiting for inflight buffers to be appended
    MovieRecorderStatusFinishingRecordingPart2, // calling finish writing on the asset writer
    MovieRecorderStatusFinished,    // terminal state
    MovieRecorderStatusFailed       // terminal state
}; // internal state machine
 
 
@interface MovieRecorder ()
{
    MovieRecorderStatus _status;
 
    id <MovieRecorderDelegate> _delegate;
    dispatch_queue_t _delegateCallbackQueue;
    
    dispatch_queue_t _writingQueue;
    
    NSURL *_URL;
    
    AVAssetWriter *_assetWriter;
    BOOL _haveStartedSession;
    
    CMFormatDescriptionRef _audioTrackSourceFormatDescription;
    AVAssetWriterInput *_audioInput;
    
    CMFormatDescriptionRef _videoTrackSourceFormatDescription;
    CGAffineTransform _videoTrackTransform;
    AVAssetWriterInput *_videoInput;
}
@end
 
@implementation MovieRecorder
 
#pragma mark -
#pragma mark API
 
- (id)initWithURL:(NSURL *)URL
{
    if ( ! URL ) {
        [self release];
        return nil;
    }
    
    if ( self = [super init] ) {
        _writingQueue = dispatch_queue_create( "com.apple.sample.movierecorder.writing", DISPATCH_QUEUE_SERIAL );
        _videoTrackTransform = CGAffineTransformIdentity;
        _URL = [URL retain];
    }
    return self;
}
 
- (void)addVideoTrackWithSourceFormatDescription:(CMFormatDescriptionRef)formatDescription transform:(CGAffineTransform)transform
{
    if ( formatDescription == NULL ) {
        @throw [NSException exceptionWithName:NSInvalidArgumentException reason:@"NULL format description" userInfo:nil];
        return;         
    }
    
    @synchronized( self ) {
        if ( _status != MovieRecorderStatusIdle ) {
            @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Cannot add tracks while not idle" userInfo:nil];
            return;
        }
        
        if ( _videoTrackSourceFormatDescription ) {
            @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Cannot add more than one video track" userInfo:nil];
            return;
        }
        
        _videoTrackSourceFormatDescription = (CMFormatDescriptionRef)CFRetain( formatDescription );
        _videoTrackTransform = transform;
    }
}
 
- (void)addAudioTrackWithSourceFormatDescription:(CMFormatDescriptionRef)formatDescription
{
    if ( formatDescription == NULL ) {
        @throw [NSException exceptionWithName:NSInvalidArgumentException reason:@"NULL format description" userInfo:nil];
        return;         
    }
    
    @synchronized( self ) {
        if ( _status != MovieRecorderStatusIdle ) {
            @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Cannot add tracks while not idle" userInfo:nil];
            return;
        }
        
        if ( _audioTrackSourceFormatDescription ) {
            @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Cannot add more than one audio track" userInfo:nil];
            return;
        }
        
        _audioTrackSourceFormatDescription = (CMFormatDescriptionRef)CFRetain( formatDescription );
    }
}
 
- (id<MovieRecorderDelegate>)delegate
{
    id <MovieRecorderDelegate> delegate = nil;
    @synchronized( self ) {
        delegate = objc_loadWeak( &_delegate ); // unnecessary under ARC, just assign to delegate directly
    }
    return delegate;
}
 
- (void)setDelegate:(id<MovieRecorderDelegate>)delegate callbackQueue:(dispatch_queue_t)delegateCallbackQueue; // delegate is weak referenced
{
    if ( delegate && ( delegateCallbackQueue == NULL ) )
        @throw [NSException exceptionWithName:NSInvalidArgumentException reason:@"Caller must provide a delegateCallbackQueue" userInfo:nil];
    
    @synchronized( self ) {
        objc_storeWeak( &_delegate, delegate ); // unnecessary under ARC, just assign to _delegate directly
        if ( delegateCallbackQueue != _delegateCallbackQueue  ) {
            if ( delegateCallbackQueue )
                [delegateCallbackQueue retain];
            if ( _delegateCallbackQueue )
                [_delegateCallbackQueue release];
            _delegateCallbackQueue = delegateCallbackQueue;
        }
    }
}
 
- (void)prepareToRecord
{
    @synchronized( self ) {
        if ( _status != MovieRecorderStatusIdle ) {
            @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Already prepared, cannot prepare again" userInfo:nil];
            return;
        }
        
        [self transitionToStatus:MovieRecorderStatusPreparingToRecord error:nil];
    }
    
    dispatch_async( dispatch_get_global_queue( DISPATCH_QUEUE_PRIORITY_LOW, 0 ), ^{
        @autoreleasepool {
            NSError *error = nil;
            // AVAssetWriter will not write over an existing file.
            [[NSFileManager defaultManager] removeItemAtURL:_URL error:NULL];
            
            _assetWriter = [[AVAssetWriter alloc] initWithURL:_URL fileType:AVFileTypeQuickTimeMovie error:&error];
            
            // Create and add inputs
            if ( ! error && _videoTrackSourceFormatDescription ) {
                [self setupAssetWriterVideoInput:_videoTrackSourceFormatDescription transform:_videoTrackTransform error:&error];
            }
            
            if ( ! error && _audioTrackSourceFormatDescription ) {
                [self setupAssetWriterAudioInput:_audioTrackSourceFormatDescription error:&error];
            }
            
            if ( ! error ) {
                BOOL success = [_assetWriter startWriting];
                if ( ! success )
                    error = _assetWriter.error;
            }
            
            @synchronized( self ) {
                if ( error )
                    [self transitionToStatus:MovieRecorderStatusFailed error:error];
                else
                    [self transitionToStatus:MovieRecorderStatusRecording error:nil];
            }
        }
    });
}
 
- (void)appendVideoSampleBuffer:(CMSampleBufferRef)sampleBuffer
{
    [self appendSampleBuffer:sampleBuffer ofMediaType:AVMediaTypeVideo];
}
 
- (void)appendVideoPixelBuffer:(CVPixelBufferRef)pixelBuffer withPresentationTime:(CMTime)presentationTime
{
    CMSampleBufferRef sampleBuffer = NULL;
    
    CMSampleTimingInfo timingInfo = {0,};
    timingInfo.duration = kCMTimeInvalid;
    timingInfo.decodeTimeStamp = kCMTimeInvalid;
    timingInfo.presentationTimeStamp = presentationTime;
    
    OSStatus err = CMSampleBufferCreateForImageBuffer( kCFAllocatorDefault, pixelBuffer, true, NULL, NULL, _videoTrackSourceFormatDescription, &timingInfo, &sampleBuffer );
    if ( sampleBuffer ) {
        [self appendSampleBuffer:sampleBuffer ofMediaType:AVMediaTypeVideo];
        CFRelease( sampleBuffer );
    }
    else {
        NSString *exceptionReason = [NSString stringWithFormat:@"sample buffer create failed (%i)", (int)err];
        @throw [NSException exceptionWithName:NSInvalidArgumentException reason:exceptionReason userInfo:nil];
        return;
    }
}
 
- (void)appendAudioSampleBuffer:(CMSampleBufferRef)sampleBuffer
{
    [self appendSampleBuffer:sampleBuffer ofMediaType:AVMediaTypeAudio];
}
 
- (void)finishRecording
{
    @synchronized( self ) {
        BOOL shouldFinishRecording = NO;
        switch ( _status ) {
            case MovieRecorderStatusIdle:
            case MovieRecorderStatusPreparingToRecord:
            case MovieRecorderStatusFinishingRecordingPart1:
            case MovieRecorderStatusFinishingRecordingPart2:
            case MovieRecorderStatusFinished:
                @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Not recording" userInfo:nil];
                break;
            case MovieRecorderStatusFailed:
                // From the client's perspective the movie recorder can asynchronously transition to an error state as the result of an append.
                // Because of this we are lenient when finishRecording is called and we are in an error state.
                NSLog( @"Recording has failed, nothing to do" );
                break;
            case MovieRecorderStatusRecording:
                shouldFinishRecording = YES;
                break;
        }
        
        if ( shouldFinishRecording )
            [self transitionToStatus:MovieRecorderStatusFinishingRecordingPart1 error:nil];
        else
            return;
    }
    
    dispatch_async( _writingQueue, ^{
        @autoreleasepool {
            @synchronized( self ) {
                // We may have transitioned to an error state as we appended inflight buffers. In that case there is nothing to do now.
                if ( _status != MovieRecorderStatusFinishingRecordingPart1 )
                    return ;
                
                // It is not safe to call -[AVAssetWriter finishWriting*] concurrently with -[AVAssetWriterInput appendSampleBuffer:]
                // We transition to MovieRecorderStatusFinishingRecordingPart2 while on _writingQueue, which guarantees that no more buffers will be appended.
                [self transitionToStatus:MovieRecorderStatusFinishingRecordingPart2 error:nil];
            }
            
            dispatch_block_t completionHandler = ^{
                @synchronized( self ) {
                    NSError *error = _assetWriter.error;
                    if ( error )
                        [self transitionToStatus:MovieRecorderStatusFailed error:error];
                    else
                        [self transitionToStatus:MovieRecorderStatusFinished error:nil];
                }               
            };
 
            [_assetWriter finishWritingWithCompletionHandler:completionHandler];
        }
    });
}
 
- (void)dealloc
{
    objc_storeWeak( &_delegate, nil ); // unregister _delegate as a weak reference
    
    if ( _delegateCallbackQueue )
        [_delegateCallbackQueue release];
    
    if ( _writingQueue )
        [_writingQueue release];
    
    [self teardownAssetWriterAndInputs];
 
    if ( _audioTrackSourceFormatDescription )
        CFRelease( _audioTrackSourceFormatDescription );
    if ( _videoTrackSourceFormatDescription )
        CFRelease( _videoTrackSourceFormatDescription );
 
    [_URL release];
    
    [super dealloc];
}
 
#pragma mark -
#pragma mark Internal
 
- (void)appendSampleBuffer:(CMSampleBufferRef)sampleBuffer ofMediaType:(NSString *)mediaType
{
    if ( sampleBuffer == NULL ) {
        @throw [NSException exceptionWithName:NSInvalidArgumentException reason:@"NULL sample buffer" userInfo:nil];
        return;         
    }
    
    @synchronized( self ) {
        if ( _status < MovieRecorderStatusRecording ) {
            @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Not ready to record yet" userInfo:nil];
            return; 
        }
    }
    
    CFRetain( sampleBuffer );
    dispatch_async( _writingQueue, ^{
        @autoreleasepool {
            @synchronized( self ) {
                // From the client's perspective the movie recorder can asynchronously transition to an error state as the result of an append.
                // Because of this we are lenient when samples are appended and we are no longer recording.
                // Instead of throwing an exception we just release the sample buffers and return.
                if ( _status > MovieRecorderStatusFinishingRecordingPart1 ) {
                    CFRelease( sampleBuffer );
                    return;
                }
            }
            
            if ( ! _haveStartedSession ) {
                [_assetWriter startSessionAtSourceTime:CMSampleBufferGetPresentationTimeStamp(sampleBuffer)];
                _haveStartedSession = YES;
            }
            
            AVAssetWriterInput *input = ( mediaType == AVMediaTypeVideo ) ? _videoInput : _audioInput;
            
            if ( input.readyForMoreMediaData ) {
                BOOL success = [input appendSampleBuffer:sampleBuffer];
                if ( ! success ) {
                    NSError *error = _assetWriter.error;
                    @synchronized( self ) {
                        [self transitionToStatus:MovieRecorderStatusFailed error:error];
                    }
                }
            }
            else {
                NSLog( @"%@ input not ready for more media data, dropping buffer", mediaType );
            }
            CFRelease( sampleBuffer );
        }
    });
}
 
// call under @synchonized( self )
- (void)transitionToStatus:(MovieRecorderStatus)newStatus error:(NSError *)error
{
    BOOL shouldNotifyDelegate = NO;
    
#if LOG_STATUS_TRANSITIONS
    NSLog( @"MovieRecorder state transition: %@->%@", [self stringForStatus:_status], [self stringForStatus:newStatus] );
#endif
    
    if ( newStatus != _status ) {
        // terminal states
        if ( ( newStatus == MovieRecorderStatusFinished ) || ( newStatus == MovieRecorderStatusFailed ) ) {
            shouldNotifyDelegate = YES;
            // make sure there are no more sample buffers in flight before we tear down the asset writer and inputs
            
            dispatch_async( _writingQueue, ^{
                [self teardownAssetWriterAndInputs];
                if ( newStatus == MovieRecorderStatusFailed ) {
                    [[NSFileManager defaultManager] removeItemAtURL:_URL error:NULL];
                }
            });
 
#if LOG_STATUS_TRANSITIONS
            if ( error )
                NSLog( @"MovieRecorder error: %@, code: %i", error, (int)error.code );
#endif
        }
        else if ( newStatus == MovieRecorderStatusRecording ) {
            shouldNotifyDelegate = YES;
        }
        
        _status = newStatus;
    }
 
    if ( shouldNotifyDelegate && [self delegate] ) {
        dispatch_async( _delegateCallbackQueue, ^{
            @autoreleasepool {
                switch ( newStatus ) {
                    case MovieRecorderStatusRecording:
                        [[self delegate] movieRecorderDidFinishPreparing:self];
                        break;
                    case MovieRecorderStatusFinished:
                        [[self delegate] movieRecorderDidFinishRecording:self];
                        break;
                    case MovieRecorderStatusFailed:
                        [[self delegate] movieRecorder:self didFailWithError:error];
                        break;
                    default:
                        break;
                }
            }
        });
    }
}
 
#if LOG_STATUS_TRANSITIONS
 
- (NSString *)stringForStatus:(MovieRecorderStatus)status
{
    NSString *statusString = nil;
    
    switch ( status ) {
        case MovieRecorderStatusIdle:
            statusString = @"Idle";
            break;
        case MovieRecorderStatusPreparingToRecord:
            statusString = @"PreparingToRecord";
            break;
        case MovieRecorderStatusRecording:
            statusString = @"Recording";
            break;
        case MovieRecorderStatusFinishingRecordingPart1:
            statusString = @"FinishingRecordingPart1";
            break;
        case MovieRecorderStatusFinishingRecordingPart2:
            statusString = @"FinishingRecordingPart2";
            break;
        case MovieRecorderStatusFinished:
            statusString = @"Finished";
            break;
        case MovieRecorderStatusFailed:
            statusString = @"Failed";
            break;
        default:
            statusString = @"Unknown";
            break;
    }
    return statusString;
    
}
 
#endif // LOG_STATUS_TRANSITIONS
 
- (BOOL)setupAssetWriterAudioInput:(CMFormatDescriptionRef)audioFormatDescription error:(NSError **)errorOut
{
    BOOL supportsFormatHint = [AVAssetWriterInput instancesRespondToSelector:@selector(initWithMediaType:outputSettings:sourceFormatHint:)]; // supported on iOS 6 and later
    
    NSDictionary *audioCompressionSettings = nil;
    
    if ( supportsFormatHint ) {
        audioCompressionSettings = [NSDictionary dictionaryWithObjectsAndKeys:
                                    [NSNumber numberWithInteger:kAudioFormatMPEG4AAC], AVFormatIDKey,
                                    nil];
    }
    else {
        const AudioStreamBasicDescription *currentASBD = CMAudioFormatDescriptionGetStreamBasicDescription(audioFormatDescription);
        
        size_t aclSize = 0;
        const AudioChannelLayout *currentChannelLayout = CMAudioFormatDescriptionGetChannelLayout(audioFormatDescription, &aclSize);
        NSData *currentChannelLayoutData = nil;
        
        // AVChannelLayoutKey must be specified, but if we don't know any better give an empty data and let AVAssetWriter decide.
        if ( currentChannelLayout && aclSize > 0 )
            currentChannelLayoutData = [NSData dataWithBytes:currentChannelLayout length:aclSize];
        else
            currentChannelLayoutData = [NSData data];
        
        audioCompressionSettings = [NSDictionary dictionaryWithObjectsAndKeys:
                                    [NSNumber numberWithInteger:kAudioFormatMPEG4AAC], AVFormatIDKey,
                                    [NSNumber numberWithFloat:currentASBD->mSampleRate], AVSampleRateKey,
                                    [NSNumber numberWithInt:64000], AVEncoderBitRatePerChannelKey,
                                    [NSNumber numberWithInteger:currentASBD->mChannelsPerFrame], AVNumberOfChannelsKey,
                                    currentChannelLayoutData, AVChannelLayoutKey,
                                    nil];
    }
    if ( [_assetWriter canApplyOutputSettings:audioCompressionSettings forMediaType:AVMediaTypeAudio] ) {
        if ( supportsFormatHint )
            _audioInput = [[AVAssetWriterInput alloc] initWithMediaType:AVMediaTypeAudio outputSettings:audioCompressionSettings sourceFormatHint:audioFormatDescription];
        else
            _audioInput = [[AVAssetWriterInput alloc] initWithMediaType:AVMediaTypeAudio outputSettings:audioCompressionSettings];
        _audioInput.expectsMediaDataInRealTime = YES;
        if ( [_assetWriter canAddInput:_audioInput] )
            [_assetWriter addInput:_audioInput];
        else {
            if ( errorOut )
                *errorOut = [[self class] cannotSetupInputError];
            return NO;
        }
    }
    else {
        if ( errorOut )
            *errorOut = [[self class] cannotSetupInputError];
        return NO;
    }
    
    return YES;
}
 
- (BOOL)setupAssetWriterVideoInput:(CMFormatDescriptionRef)videoFormatDescription transform:(CGAffineTransform)transform error:(NSError **)errorOut
{
    BOOL supportsFormatHint = [AVAssetWriterInput instancesRespondToSelector:@selector(initWithMediaType:outputSettings:sourceFormatHint:)]; // supported on iOS 6 and later
    
    float bitsPerPixel;
    CMVideoDimensions dimensions = CMVideoFormatDescriptionGetDimensions(videoFormatDescription);
    int numPixels = dimensions.width * dimensions.height;
    int bitsPerSecond;
    
    // Assume that lower-than-SD resolutions are intended for streaming, and use a lower bitrate
    if ( numPixels < (640 * 480) )
        bitsPerPixel = 4.05; // This bitrate approximately matches the quality produced by AVCaptureSessionPresetMedium or Low.
    else
        bitsPerPixel = 10.1; // This bitrate approximately matches the quality produced by AVCaptureSessionPresetHigh.
    
    bitsPerSecond = numPixels * bitsPerPixel;
    
    NSDictionary *videoCompressionSettings = [NSDictionary dictionaryWithObjectsAndKeys:
                                              AVVideoCodecH264, AVVideoCodecKey,
                                              [NSNumber numberWithInteger:dimensions.width], AVVideoWidthKey,
                                              [NSNumber numberWithInteger:dimensions.height], AVVideoHeightKey,
                                              [NSDictionary dictionaryWithObjectsAndKeys:
                                               [NSNumber numberWithInteger:bitsPerSecond], AVVideoAverageBitRateKey,
                                               [NSNumber numberWithInteger:30], AVVideoMaxKeyFrameIntervalKey,
                                               nil], AVVideoCompressionPropertiesKey,
                                              nil];
    if ( [_assetWriter canApplyOutputSettings:videoCompressionSettings forMediaType:AVMediaTypeVideo] ) {
        if ( supportsFormatHint )
            _videoInput = [[AVAssetWriterInput alloc] initWithMediaType:AVMediaTypeVideo outputSettings:videoCompressionSettings sourceFormatHint:videoFormatDescription];
        else
            _videoInput = [[AVAssetWriterInput alloc] initWithMediaType:AVMediaTypeVideo outputSettings:videoCompressionSettings];
        _videoInput.expectsMediaDataInRealTime = YES;
        _videoInput.transform = transform;
        if ( [_assetWriter canAddInput:_videoInput] )
            [_assetWriter addInput:_videoInput];
        else {
            if ( errorOut )
                *errorOut = [[self class] cannotSetupInputError];
            return NO;
        }
    }
    else {
        if ( errorOut )
            *errorOut = [[self class] cannotSetupInputError];
        return NO;
    }
    
    return YES;
}
 
+ (NSError *)cannotSetupInputError
{
    NSString *localizedDescription = NSLocalizedString( @"Recording cannot be started", nil );
    NSString *localizedFailureReason = NSLocalizedString( @"Cannot setup asset writer input.", nil );
    NSDictionary *errorDict = [NSDictionary dictionaryWithObjectsAndKeys:
                               localizedDescription, NSLocalizedDescriptionKey,
                               localizedFailureReason, NSLocalizedFailureReasonErrorKey,
                               nil];
    return [NSError errorWithDomain:@"com.apple.dts.samplecode" code:0 userInfo:errorDict];
}
 
- (void)teardownAssetWriterAndInputs
{
    [_videoInput release];
    _videoInput = nil;
    [_audioInput release];
    _audioInput = nil;
    [_assetWriter release];
    _assetWriter = nil;
}
 
@end