Опыт создания реалтайм видео-секвенсора на iOS

в 14:05, , рубрики: iOS, obj-c, видео, разработка мобильных приложений, разработка под iOS, секвенсор, метки:

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

Немного о самом приложении…

Hype Type позволяет пользователю записать несколько коротких отрывков видео и/или фотографий общей длительностью до 15 секунд, добавить к полученному ролику текст и применить к нему одну из анимаций на выбор.

image

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

image

Готовые решения?

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

Автор: Narayan

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js