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 |
Copyright © 2016 Apple Inc. All Rights Reserved. Terms of Use | Privacy Policy | Updated: 2016-09-28