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

    Метки:
    Поделиться публикацией
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 4
    • 0
      красавчик
      • 0
        В противном случае видеопоток зафризится — я не нашел упоминаний об этом ограничении в документации, но, по-видимому, система трекает pixel buffers, которые отдает и просто не будет отдавать вам новые, пока старые висят в памяти.

        Судя по поведению, AVCaptureVideoDataOutput использует CVPixelBufferPool для отдачи новых буферов. На 6s позволяет одновременно удерживать около 20 буферов, потом фризится. А в качестве копирования я использую отрисовку в промежуточную OpenGL текстуру.
        • 0
          Спасибо за статью, а почему не использовали AVPlayerLooper для лупа?!, посути он внутри делает тоже самое, своего рода преролл дополнительного плеерр айтема для бесшовной перемотки в начало
          • 0
            AVPlayerLooper — только на iOS 10+, а нам нужно было чтобы приложение работало и на 9ке.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.