Привет, меня зовут Антон и я iOS-разработчик в Rosberry. Не так давно мне довелось работать над проектом Hype Type и решить несколько интересных задач по работе с видео, текстом и анимациями. В этой статье я расскажу о подводных камнях и возможных путях их обхода при написании реалтайм видео-секвенсора на iOS.
Немного о самом приложении…
Hype Type позволяет пользователю записать несколько коротких отрывков видео и/или фотографий общей длительностью до 15 секунд, добавить к полученному ролику текст и применить к нему одну из анимаций на выбор.

Основная особенность работы с видео в данном случае состоит в том, что у юзера должна быть возможность управлять отрывками видео независимо друг от друга: изменять скорость воспроизведения, делать реверс, флип и (возможно в будущих версиях) на лету менять отрывки местами.

Готовые решения?
“Почему бы не использовать AVMutableComposition?” — можете спросить вы, и, в большинстве
случаев, будете правы — это действительно достаточно удобный системный видео-секвенсор, но, увы, у него есть ограничения, которые не позволили нам его использовать. В первую очередь, это невозможность изменения и добавления треков на лету — чтобы получить измененный видеопоток потребуется пересоздавать AVPlayerItem и переинициализировать AVPlayer. Также в AVMutableComposition далеко не идеальна работа с изображениями — для того, чтобы добавить в таймлайн статичное изображение, придется использовать AVVideoCompositionCoreAnimationTool, который добавит изрядное количество оверхеда и значительно замедлит рендер.
Недолгий поиск по просторам интернета не выявил никаких других более-менее подходящих под задачу решений, поэтому было решено написать свой секвенсор.
Итак…
Для начала — немного о структуре render pipeline в проекте. Сразу скажу, я не буду слишком вдаваться в детали и буду считать что вы уже более-менее знакомы с этой темой, иначе этот материал разрастется до невероятных масштабов. Если же вы новичок — советую обратить внимание на достаточно известный фреймворк GPUImage (Obj-C, Swift) — это отличная стартовая точка для того, чтобы на наглядном примере разобраться в OpenGLES.
View, которая занимается отрисовкой полученного видео на экране по таймеру (CADisplayLink), запрашивает кадры у секвенсора. Так как приложение работает преимущественно с видео, то логичнее всего использовать YCbCr colorspace и передавать каждый кадр как CVPixelBufferRef. После получения кадра создаются luminance и chrominance текстуры, которые передаются в shader program. На выходе получается RGB изображения, которое и видит пользователь. Refresh loop в данном случае будет выглядеть примерно так:
- (void)onDisplayRefresh:(CADisplayLink *)sender { // advance position of sequencer [self.source advanceBy:sender.duration]; // check for new pixel buffer if ([self.source hasNewPixelBuffer]) { // get one PixelBuffer *pixelBuffer = [self.source nextPixelBuffer]; // dispatch to gl processing queue [self.context performAsync:^{ // prepare textures self.luminanceTexture = [self.context.textureCache textureWithPixelBuffer:pixelBuffer planeIndex:0 glFormat:GL_LUMINANCE]; self.chrominanceTexture = [self.context.textureCache textureWithPixelBuffer:pixelBuffer planeIndex:1 glFormat:GL_LUMINANCE_ALPHA]; // prepare shader program, uniforms, etc self.program.orientation = pixelBuffer.orientation; // ... // signal to draw [self setNeedsRedraw]; }]; } if ([self.source isFinished]) { // rewind if needed [self.source rewind]; } } // ... - (void)draw { [self.context performSync:^{ // bind textures [self.luminanceTexture bind]; [self.chrominanceTexture bind]; // use shader program [self.program use]; // unbind textures [self.luminanceTexture unbind]; [self.chrominanceTexture unbind]; }]; }
Практически все здесь построено на обертках (для CVPixelBufferRef, CVOpenGLESTexture и т.д.), что позволяет вынести основную low-level логику в отдельный слой и значительно упростить базовые моменты работы с OpenGL. Конечно, у этого есть свои минусы (в основном — небольшая потеря производительности и меньшая гибкость), однако они не столь критичны. Что стоит пояcнить: self.context — достаточно простая обертка над EAGLContext, облегчающая работу с CVOpenGLESTextureCache и многопоточными обращениями к OpenGL. self.source — секвенсор, который решает, какой кадр из какого трека отдать во view.
Теперь о том, как организовано получение кадров для рендеринга. Так как секвенсор должен работать как с видео, так и картинками, логичнее всего закрыть все общим протоколом. Таким образом, задача секвенсора сведется к тому, чтобы следить за playhead и, в зависимости от ее позиции, отдавать новый кадр из соответствующего трека.
@protocol MovieSourceProtocol <NSObject> // start & stop reading methods - (void)startReading; - (void)cancelReading; // methods for getting frame rate & current offset - (float)frameRate; - (float)offset; // method to check if we already read everything... - (BOOL)isFinished; // ...and to rewind source if we did - (void)rewind; // method for scrubbing - (void)seekToOffset:(CGFloat)offset; // method for reading frames - (PixelBuffer *)nextPixelBuffer; @end
Логика того, как получать кадры, ложится на объекты, реализующие MovieSourceProtocol. Такая схема позволяет сделать систему универсальной и расширяемой, так как единственным отличием в обработке изображений и видео будет только способ получения кадров.
Таким образом, VideoSequencer становится совсем простым, и главной сложностью остается определение текущего трека и приведение всех треков к единому frame rate.
- (PixelBuffer *)nextPixelBuffer { // get current track VideoSequencerTrack *track = [self trackForPosition:self.position]; // get track source id<MovieSourceProtocol> source = track.source; // Here's our source // get pixel buffer return [source nextPixelBuffer]; }
VideoSequencerTrack здесь — обертка над объектом, реализующим MovieSourceProtocol, содержащая различную метадату.
@interface FCCGLVideoSequencerTrack : NSObject - (id) initWithSource:(id<MovieSourceProtocol>)source; @property (nonatomic, assign) BOOL editable; // ... and other metadata @end
Работаем со статикой
Теперь перейдем непосредственно к получению кадров. Рассмотрим простейший случай — отображение одной картинки. Получить ее возможно либо с камеры, и тогда мы сразу можем получить CVPixelBufferRef в формате YCbCr, который достаточно просто скопировать (почему это важно, я объясню чуть позже) и отдавать по запросу; либо из медиа-библиотеки — в этом случае придется немного извернуться и вручную конвертировать изображение в нужный формат. Операцию конвертирования из RGB в YCbCr можно было вынести на GPU, однако на современных девайсах и CPU справляется с этой задачей достаточно быстро, особенно учитывая тот факт, что приложение дополнительно кропает и сжимает изображение перед тем, как его использовать. В остальном же все достаточно просто, все что нужно делать — отдавать один и тот же кадр в течение отведенного промежутка времени.
@implementation ImageSource // init with pixel buffer from camera - (id)initWithPixelBuffer:(PixelBuffer *)pixelBuffer orientation:(AVCaptureVideoOrientation)orientation duration:(NSTimeInterval)duration { if (self = [super init]) { self.orientation = orientation; self.pixelBuffer = [pixelBuffer copy]; self.duration = duration; } return self; } // init with UIImage - (id)initWithImage:(UIImage *)image duration:(NSTimeInterval)duration { if (self = [super init]) { self.duration = duration; self.orientation = AVCaptureVideoOrientationPortrait; // prepare empty pixel buffer self.pixelBuffer = [[PixelBuffer alloc] initWithSize:image.size pixelFormat:kCVPixelFormatType_420YpCbCr8BiPlanarFullRange]; // get base addresses of image planes uint8_t *yBaseAddress = self.pixelBuffer.yPlane.baseAddress; size_t yPitch = self.pixelBuffer.yPlane.bytesPerRow; uint8_t *uvBaseAddress = self.pixelBuffer.uvPlane.baseAddress; size_t uvPitch = self.pixelBuffer.uvPlane.bytesPerRow; // get image data CFDataRef pixelData = CGDataProviderCopyData(CGImageGetDataProvider(image.CGImage)); uint8_t *data = (uint8_t *)CFDataGetBytePtr(pixelData); uint32_t imageWidth = image.size.width; uint32_t imageHeight = image.size.height; // do the magic (convert from RGB to YCbCr) for (int y = 0; y < imageHeight; ++y) { uint8_t *rgbBufferLine = &data[y * imageWidth * 4]; uint8_t *yBufferLine = &yBaseAddress[y * yPitch]; uint8_t *cbCrBufferLine = &uvBaseAddress[(y >> 1) * uvPitch]; for (int x = 0; x < imageWidth; ++x) { uint8_t *rgbOutput = &rgbBufferLine[x * 4]; int16_t red = rgbOutput[0]; int16_t green = rgbOutput[1]; int16_t blue = rgbOutput[2]; int16_t y = 0.299 * red + 0.587 * green + 0.114 * blue; int16_t u = -0.147 * red - 0.289 * green + 0.436 * blue; int16_t v = 0.615 * red - 0.515 * green - 0.1 * blue; yBufferLine[x] = CLAMP(y, 0, 255); cbCrBufferLine[x & ~1] = CLAMP(u + 128, 0, 255); cbCrBufferLine[x | 1] = CLAMP(v + 128, 0, 255); } } CFRelease(pixelData); } return self; } // ... - (BOOL)isFinished { return (self.offset > self.duration); } - (void)rewind { self.offset = 0.0; } - (PixelBuffer *)nextPixelBuffer { if ([self isFinished]) { return nil; } return self.pixelBuffer; } // ...
Работаем с видео
А теперь добавим видео. Для этого было решено использовать AVPlayer — в основном из-за того, что он имеет достаточно удобное API для получения кадров и полностью берет на себя работу со звуком. В общем, звучит достаточно просто, но есть и некоторые моменты, на которые стоит обратить внимание.
Начнем с очевидного:
- (void)setURL:(NSURL *)url withCompletion:(void(^)(BOOL success))completion { self.setupCompletion = completion; // prepare asset self.asset = [[AVURLAsset alloc] initWithURL:url options:@{ AVURLAssetPreferPreciseDurationAndTimingKey : @(YES), }]; // load asset tracks __weak VideoSource *weakSelf = self; [self.asset loadValuesAsynchronouslyForKeys:@[@"tracks"] completionHandler:^{ // prepare player item weakSelf.playerItem = [AVPlayerItem playerItemWithAsset:weakSelf.asset]; [weakSelf.playerItem addObserver:weakSelf forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:nil]; }]; } - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if(self.playerItem.status == AVPlayerItemStatusReadyToPlay) { // ready to play, prepare output NSDictionary *outputSettings = @{ (id)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_420YpCbCr8BiPlanarFullRange), (id)kCVPixelBufferOpenGLESCompatibilityKey: @(YES), (id)kCVPixelBufferOpenGLCompatibilityKey: @(YES), (id)kCVPixelBufferIOSurfacePropertiesKey: @{ @"IOSurfaceOpenGLESFBOCompatibility": @(YES), @"IOSurfaceOpenGLESTextureCompatibility": @(YES), }, }; self.videoOutput = [[AVPlayerItemVideoOutput alloc] initWithPixelBufferAttributes:outputSettings]; [self.playerItem addOutput:self.videoOutput]; if (self.setupCompletion) { self.setupCompletion(); } }; } // ... - (void) rewind { [self seekToOffset:0.0]; } - (void)seekToOffset:(CGFloat)offset { [self.playerItem seekToTime:[self timeForOffset:offset] toleranceBefore:kCMTimeZero toleranceAfter:kCMTimeZero]; } - (PixelBuffer *)nextPixelBuffer { // check for new pixel buffer... CMTime time = self.playerItem.currentTime; if(![self.videoOutput hasNewPixelBufferForItemTime:time]) { return nil; } // ... and grab it if there is one CVPixelBufferRef bufferRef = [self.videoOutput copyPixelBufferForItemTime:time itemTimeForDisplay:nil]; if (!bufferRef) { return nil; } PixelBuffer *pixelBuffer = [[FCCGLPixelBuffer alloc] initWithPixelBuffer:bufferRef]; CVBufferRelease(bufferRef); return pixelBuffer; }
Создаем AVURLAsset, подгружаем информацию о треках, создаем AVPlayerItem, дожидаемся нотификации о том, что он готов к воспроизведению и создаем AVPlayerItemVideoOutput с подходящими для рендера параметрами — все по-прежнему достаточно просто.
Однако тут же кроется и первая проблема — seekToTime работает недостаточно быстро, и при loop’е есть заметные задержки. Если же не изменять параметры toleranceBefore и toleranceAfter, то это мало что меняет, за исключением того, что, кроме задержки, добавляется еще и неточность позиционирования. Это ограничение системы и полностью его не решить, но можно обойти, для чего достаточно готовить 2 AVPlayerItem’a и использовать их по очереди — как только один из них заканчивает воспроизведение, тут же начинает играть другой, в то время как первый перематывается на начало. И так по кругу.
Еще одна неприятная, но решаемая проблема — AVFoundation как следует (seamless & smooth) поддерживает изменение скорости воспроизведения и reverse далеко не для всех типов файлов, и, если в случае с записи с камеры выходной формат мы контролируем, то в случае, если пользователь загружает видео из медиа-библиотеки, такой роскоши у нас нет. Заставлять пользователей ждать, пока видео сконвертируется — выход плохой, тем более далеко не факт, что они будут использовать эти настройки, поэтому было решено делать это в бэкграунде и незаметно подменять оригинальное видео на сконвертированное.
- (void)processAndReplace:(NSURL *)inputURL outputURL:(NSURL *)outputURL { [[NSFileManager defaultManager] removeItemAtURL:outputURL error:nil]; // prepare reader MovieReader *reader = [[MovieReader alloc] initWithInputURL:inputURL]; reader.timeRange = self.timeRange; // prepare writer MovieWriter *writer = [[FCCGLMovieWriter alloc] initWithOutputURL:outputURL]; writer.videoSettings = @{ AVVideoCodecKey: AVVideoCodecH264, AVVideoWidthKey: @(1280.0), AVVideoHeightKey: @(720.0), }; writer.audioSettings = @{ AVFormatIDKey: @(kAudioFormatMPEG4AAC), AVNumberOfChannelsKey: @(1), AVSampleRateKey: @(44100), AVEncoderBitRateStrategyKey: AVAudioBitRateStrategy_Variable, AVEncoderAudioQualityForVBRKey: @(90), }; // fire up reencoding MovieProcessor *processor = [[MovieProcessor alloc] initWithReader:reader writer:writer]; processor.processingSize = (CGSize){ .width = 1280.0, .height = 720.0 }; __weak FCCGLMovieStreamer *weakSelf = self; [processor processWithProgressBlock:nil andCompletion:^(NSError *error) { if(!error) { weakSelf.replacementURL = outputURL; } }]; }
MovieProcessor здесь — сервис, который получает кадры и аудио сэмплы от reader’а и отдает их writer’у. (На самом деле он также умеет и обрабатывать полученные от reader’а кадры на GPU, но это используется только при рендере всего проекта, для того, чтобы наложить на готовое видео кадры анимации)
А теперь посложнее
А что, если юзер захочет добавить в проект сразу 10-15 видеоклипов? Так как приложение не должно ограничивать пользователя в количестве клипов, которые он может использовать в приложении, нужно предусмотреть этот сценарий.
Если готовить каждый отрывок к воспроизведению по мере надобности, возникнут слишком заметные задержки. Подготавливать к воспроизведению все клипы сразу тоже не получится — из-за ограничения iOS на количество h264 декодеров, работающих одновременно. Выход из этой ситуации, разумеется, есть и он достаточно прост — готовить заранее пару треков, которые будут проигрываться следующими, “очищая” те, которые использовать в ближайшее время не планируется.
- (void) cleanupTrackSourcesIfNeeded { const NSUInteger cleanupDelta = 1; NSUInteger trackCount = [self.tracks count]; NSUInteger currentIndex = [self.tracks indexOfObject:self.currentTrack]; if (currentIndex == NSNotFound) { currentIndex = 0; } NSUInteger index = 0; for (FCCGLVideoSequencerTrack *track in self.tracks) { NSUInteger currentDelta = MAX(currentIndex, index) - MIN(currentIndex, index); currentDelta = MIN(currentDelta, index + (trackCount - currentIndex - 1)); if (currentDelta > cleanupDelta) { track.playheadPosition = 0.0; [track.source cancelReading]; [track.source cleanup]; } else { [track.source startReading]; } ++index; } }
Таким нехитрым способом удалось добиться непрерывного воспроизведения и loop’а. Да, при scrubbing’е неизбежно будет небольшой лаг, но это не столь критично.
Подводные камни
Напоследок расскажу немного о подводных камнях, которые могут встретиться при решении подобных задач.
Первое — если вы работаете с pixel buffers, полученными с камеры девайса — либо сразу освобождайте их, либо копируйте, если хотите использовать их позже. В противном случае видеопоток зафризится — я не нашел упоминаний об этом ограничении в документации, но, по-видимому, система трекает pixel buffers, которые отдает и просто не будет отдавать вам новые, пока старые висят в памяти.
Второе — многопоточность при работе с OpenGL. Сам по себе OpenGL с ней не очень и дружит, однако это можно обойти, используя разные EAGLContext, находящиеся в одной EAGLSharegroup, что позволит быстро и просто разделить логику отрисовки того, что пользователь увидит на экране, и различные фоновые процессы (обработку видео, рендер и т.п.).
