iOSでリアルタイムビデオシーケンサーを作成した経験

こんにちは、私の名前はアントンです。私はRosberryのiOS開発者です。 少し前に、私はたまたまHype Typeプロジェクトに取り組み、ビデオ、テキスト、アニメーションを扱う際のいくつかの興味深い問題を解決しました。 この記事では、iOSでリアルタイムビデオシーケンサーを作成する際の落とし穴と回避方法について説明します。







アプリケーション自体について少し...



Hype Typeを使用すると、ユーザーは合計15秒までのビデオおよび/または写真のいくつかの短い抜粋を記録し、結果のクリップにテキストを追加し、アニメーションの1つを選択して適用できます。







画像







この場合のビデオの主な機能は、ユーザーがビデオフラグメントを互いに独立して管理できることです。再生速度の変更、リバース、フリップ、および(おそらく将来のバージョンでは)パッセージをその場で交換します。







画像







既製のソリューション?



「なぜAVMutableCompositionを使用しないのですか?」-尋ねることができ、ほとんどの場合、

ケース、あなたは正しいでしょう-これは本当にかなり便利なシステムビデオシーケンサーですが、残念ながら、それを使用することができなかった制限があります。 まず第一に、これはオンザフライでトラックを変更および追加することは不可能です-変更されたビデオストリームを取得するには、 AVPlayerItemを再作成し、 AVPlayerを再初期化する必要があります。 また、画像の操作はAVMutableCompositionの理想からはほど遠いです -タイムラインに静的な画像を追加するには、 AVVideoCompositionCoreAnimationToolを使用する必要があります。これにより、かなりのオーバーヘッドが追加され、レンダリングが大幅に遅くなります。







インターネットでの短い検索では、タスクに多少なりとも適した他のソリューションは明らかにされなかったため、独自のシーケンサーを作成することにしました。







だから...



まず、プロジェクトのレンダーパイプラインの構造について少し説明します。 私はあまり詳細に説明せず、あなたがこのトピックに多少なりとも精通していると仮定します。 あなたが初心者の場合-かなりよく知られているGPUImageフレームワーク( Obj-CSwift )に注意することをお勧めします-これは、 OpenGLESを明確な例で理解するための素晴らしい出発点です。







Viewは、受信したビデオをタイマー( CADisplayLink )によって画面上にレンダリングすることに関与しており、シーケンサーからフレームを要求します。 アプリケーションは主にビデオで動作するため、 YCbCr色空間を使用して各フレームをCVPixelBufferRefとして転送することが最も論理的です 。 フレームを受け取った後、ルミナンスとクロミナンスのテクスチャが作成され、シェーダープログラムに転送されます。 出力は、ユーザーに表示されるRGBイメージです。 この場合の更新ループは次のようになります。







- (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]; }]; }
      
      





ここのほとんどすべてはラッパー( CVPixelBufferRefCVOpenGLESTextureなど)に基づいて構築されているため、メインの低レベルロジックを別のレイヤーに配置し、 OpenGLを使用する基本的なポイントを大幅に簡素化できます。 もちろん、これには欠点(主にパフォーマンスのわずかな損失と柔軟性の低下)がありますが、それほど重要ではありません。 説明する価値のあるもの: self.contextEAGLContextのかなり単純なラッパーで、 CVOpenGLESTextureCacheOpenGLのマルチスレッド呼び出しを簡単に処理できるようにします。 self.source-どのトラックからどのフレームを表示するかを決定するシーケンサー。







次に、レンダリングの担当者の領収書をどのように整理したかについて。 シーケンサはビデオと画像の両方で動作するはずなので、すべてを共通のプロトコルで閉じるのが最も論理的です。 したがって、シーケンサーのタスクは、再生ヘッドを追跡し、その位置に応じて、対応するトラックから新しいフレームを提供することです。







 @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は非常にシンプルになり、現在のトラックを特定し、すべてのトラックを単一のフレームレートにすることが主な難点です。







 - (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
      
      





私たちは静力学で作業します



ここで、フレームの取得に直接進みます。 最も単純な場合、つまり単一の画像を表示することを検討してください。 カメラから取得することができ、その後すぐにYCbCr形式のCVPixelBufferRefを取得できます。これは非常に簡単にコピーできます(重要な理由については後で説明します)。 またはメディアライブラリから-この場合、少しかわして、手動で画像を目的の形式に変換する必要があります。 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は十分に高速ではなく、ループに顕著な遅延があります。 パラメーターtoleranceBeforeおよびtoleranceAfterを変更しない場合、遅延に加えて位置の不正確さも追加されることを除いて、これはあまり変わりません。 これはシステムの制限であり、完全に解決することはできませんが、2つのAVPlayerItem 'aを準備して順番に使用するだけで十分です-一方の再生が終了するとすぐに、もう一方は最初に巻き戻しながらすぐに再生を開始します。 そして円で。







別の不快だが解決可能な問題は、 AVFoundationが(シームレスでスムーズに)すべてのファイルタイプの再生速度と逆の変更をサポートしていないことです。カメラから記録する場合に出力形式を制御し、ユーザーがロードした場合メディアライブラリからのビデオ、私たちはそのような贅沢を持っていません。 ビデオが変換されるまでユーザーを待たせることは、特にこれらの設定を使用しないため、悪い方法です。したがって、バックグラウンドでこれを行い、元のビデオを変換されたものに静かに置き換えることが決定されました。







 - (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は、リーダーからフレームとオーディオサンプルを受信し、ライターに提供するサービスです。 (実際、彼はGPUでリーダーから受け取ったフレームを処理する方法も知っていますが、これは完成したビデオにアニメーションフレームをオーバーレイするために、プロジェクト全体をレンダリングする場合にのみ使用されます)







そして今、もっと難しい



しかし、ユーザーが10〜15個のビデオクリップをすぐにプロジェクトに追加したい場合はどうでしょうか。 アプリケーションでは、ユーザーがアプリケーションで使用できるクリップの数に制限すべきではないため、このシナリオを提供する必要があります。







必要に応じて各パッセージを再生用に準備すると、あまりにも顕著な遅延が発生します。 また、同時に動作するh264デコーダーの数に関するiOSの制限により、すべてのクリップを一度に準備することもできません。 もちろん、この状況から抜け出す方法はあり、それは非常に簡単です。次に再生される予定のいくつかのトラックを事前に準備し、近い将来使用される予定のないトラックを「クリア」します。







 - (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; } }
      
      





このようなシンプルな方法で、連続再生とループを実現することができました。 はい、スクラブでは必然的に小さな遅延が発生しますが、これはそれほど重大ではありません。







落とし穴



最後に、このような問題を解決する際に発生する可能性のある落とし穴について少し説明します。







1つ目は、デバイスのカメラから受信したピクセルバッファーを使用している場合、すぐに解放するか、後で使用する場合はコピーすることです。 それ以外の場合、ビデオストリームはブロックされます-ドキュメントでこの制限について言及していませんが、明らかに、システムはピクセルバッファーを追跡します。







2番目は、 OpenGL使用する場合のマルチスレッドです。 OpenGL自体はあまり使いやすいものではありませんが、同じEAGLSharegroupにある異なるEAGLContextsを使用することで回避できます。これにより、ユーザーが画面に表示する描画ロジックとさまざまなバックグラウンドプロセス(ビデオ処理、レンダリング)をすばやく簡単に分離できますなど)。








All Articles