Привет, меня зовут Антон и я 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, что позволит быстро и просто разделить логику отрисовки того, что пользователь увидит на экране, и различные фоновые процессы (обработку видео, рендер и т.п.).