視訊庫LFLiveKit分析

FindCrt發表於2018-10-25

整體架構

LFLiveSession為中心切分成3部分:

  • 前面是音視訊的資料採集
  • 後面是音視訊資料推送到伺服器
  • 中間是音視訊資料的編碼

整體架構.png

資料採集分為視訊和音訊:

  • 視訊由相機和一系列的濾鏡組成,最後輸出到預覽介面(preview)和LFLiveSession
  • 音訊使用AudioUnit讀取音訊,輸出到LFLiveSession

編碼部分:

  • 視訊提供軟編碼和硬編碼,硬編碼使用VideoToolBox。編碼h264
  • 音訊提供AudioToolBox的硬編碼,編碼AAC

推送部分:

  • 編碼後的音視訊按幀裝入佇列,迴圈推送
  • 容器採用FLV,按照FLV的資料格式組裝
  • 使用librtmp庫進行推送。

視訊採集

視訊採集部分內容比較多,可以分為幾點:

  • 相機
  • 濾鏡
  • 鏈式影象處理方案
  • opengl es

核心類,也是承擔控制器角色的是LFVideoCapture,負責組裝相機和濾鏡,管理視訊資料流。


1. 相機

相機的核心類是GPUImageVideoCamera

相機資料流程.png
視訊採集使用系統庫AVFoundationAVCaptureSession,所以就是常規性的幾步:

  1. 構建AVCaptureSession:_captureSession = [[AVCaptureSession alloc] init];
  2. 配置輸入和輸出,輸入是裝置,一般就有前後攝像頭的區別
NSArray *devices = [AVCaptureDevice devicesWithMediaType:AVMediaTypeVideo];
  for (AVCaptureDevice *device in devices) 
  {
  	if ([device position] == cameraPosition)
  	{
  		_inputCamera = device;
  	}
  }
  
  .....
  NSError *error = nil;
  videoInput = [[AVCaptureDeviceInput alloc] initWithDevice:_inputCamera error:&error];
  if ([_captureSession canAddInput:videoInput]) 
  {
  	[_captureSession addInput:videoInput];
  }
複製程式碼
  1. 輸出可以是檔案也可以是資料,這裡因為要推送到伺服器,而且也為了後續的影象處理,顯然要用資料輸出。
videoOutput = [[AVCaptureVideoDataOutput alloc] init];
  [videoOutput setAlwaysDiscardsLateVideoFrames:NO];
  ......
  [videoOutput setSampleBufferDelegate:self queue:cameraProcessingQueue];
  if ([_captureSession canAddOutput:videoOutput])
  {
  	[_captureSession addOutput:videoOutput];
  }
複製程式碼

中間還一大段captureAsYUV為YES時執行的程式碼,有兩種方式,一個是相機輸出YUV格式,然後轉成RGBA,還一種是直接輸出BGRA,然後轉成RGBA。前一種對應的是kCVPixelFormatType_420YpCbCr8BiPlanarFullRangekCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,後一種對應的是kCVPixelFormatType_32BGRA,相機資料輸出格式只接受這3種。中間的這一段的目的就是設定相機輸出YUV,然後再轉成RGBA。OpenGL和濾鏡的問題先略過。

這裡有個問題:h264編碼時用的是YUV格式的,這裡輸出RGB然後又轉回YUV不是浪費嗎?還有輸出YUV,然後自己轉成RGB,然後編碼時再轉成YUV不是傻?如果直接把輸出的YUV轉碼推送會怎麼樣?考慮到濾鏡的使用,濾鏡方便處理YUV格式的影象嗎? 這些問題以後再深入研究,先看預設的流程裡的處理原理。 更新:對於這個問題的一點猜測:硬體輸出的(包括攝像頭和硬體碼)的格式是yuv裡面的NV12,而一般常用的視訊裡yuv的顏色空間具體是yuv420,這兩種的區別只是uv資料是分成兩層還是交錯在一起。但NV12的格式也是可以直接用opengl(es)渲染的,所以還是有疑問。

配置完session以及輸入輸出,開啟session後,資料從裝置採集,然後呼叫dataOutput的委託方法:captureOutput:didOutputSampleBuffer:fromConnection

這裡還有針對audio的處理,但音訊不是在這採集的,這裡的audio沒啟用,可以直接忽略先。

然後到方法processVideoSampleBuffer:,程式碼不少,乾的就一件事:把相機輸出的視訊資料轉到RGBA的格式的texture裡。然後呼叫updateTargetsForVideoCameraUsingCacheTextureAtWidth這個方法把處理完的資料傳遞給下一個影象處理元件。

整體而言,相機就是收集裝置的視訊資料,然後倒入到影象處理鏈裡。所以要搞清楚視訊輸出怎麼傳遞到預覽介面和LFLiveSession的,需要先搞清楚濾鏡/影象處理鏈是怎麼傳遞資料的。


2. 影象處理鏈

這裡有兩種處理元件:GPUImageOutputGPUImageInput

GPUImageOutput有一個target的概念的東西,在它處理完一個影象後,把影象傳遞給它的target。而GPUImageInput怎麼接受從其他物件那傳遞過來的影象。通過這兩個元件,就可以把一個影象從一個元件傳遞另一個元件,形成鏈條。有點像接水管?-_-

而且可以是交叉性的,如圖:

影象處理鏈.png

有些濾鏡是需要多個輸入源,比如水印效果、蒙版效果,就可能出現D+E --->F的情況。這樣的結構好處就是每個環節可以自由的處理自己的任務,而不需要管資料從哪來,要推到那裡去。有資料它就處理,處理完就推到自己的tagets裡去。

我比較好奇的是為什麼GPUImageOutput定義成了類,而GPUImageInput卻是協議,這也是值得思考的問題。

有了這兩個元件的認識,再去到LFVideoCapturereloadFilter方法。在這裡,它把視訊採集的處理鏈組裝起來了,在這可以很清晰的看到影象資料的流動路線。

相機元件GPUImageVideoCamera繼承於GPUImageOutput,它會把資料輸出到它的target.

//< 480*640 比例為4:3  強制轉換為16:9
if([self.configuration.avSessionPreset isEqualToString:AVCaptureSessionPreset640x480]){
        CGRect cropRect = self.configuration.landscape ? CGRectMake(0, 0.125, 1, 0.75) : CGRectMake(0.125, 0, 0.75, 1);
        self.cropfilter = [[GPUImageCropFilter alloc] initWithCropRegion:cropRect];
        [self.videoCamera addTarget:self.cropfilter];
        [self.cropfilter addTarget:self.filter];
    }else{
        [self.videoCamera addTarget:self.filter];
    }
複製程式碼

如果是640x480的解析度,則路線是:videoCamera --> cropfilter --> filter,否則是videoCamera --> filter。

其他部分類似,就是條件判斷是否加入某個元件,最後都會輸出到:self.gpuImageViewself.output。形成資料流大概:

視訊採集基本資料流.png

self.gpuImageView是視訊預覽圖的內容檢視,設定preview的程式碼:

- (void)setPreView:(UIView *)preView {
    if (self.gpuImageView.superview) [self.gpuImageView removeFromSuperview];
    [preView insertSubview:self.gpuImageView atIndex:0];
    self.gpuImageView.frame = CGRectMake(0, 0, preView.frame.size.width, preView.frame.size.height);
}
複製程式碼

有了這個就可以看到經過一系列處理的視訊影象了,這個是給拍攝者自己看到。

self.output本身沒什麼內容,只是作為最後一個節點,把內容往外界傳遞出去:

    __weak typeof(self) _self = self;
    [self.output setFrameProcessingCompletionBlock:^(GPUImageOutput *output, CMTime time) {
       [_self processVideo:output];
    }];
    
    ......
    
    - (void)processVideo:(GPUImageOutput *)output {
    __weak typeof(self) _self = self;
    @autoreleasepool {
        GPUImageFramebuffer *imageFramebuffer = output.framebufferForOutput;
        CVPixelBufferRef pixelBuffer = [imageFramebuffer pixelBuffer];
        
        if (pixelBuffer && _self.delegate && [_self.delegate respondsToSelector:@selector(captureOutput:pixelBuffer:)]) {
            [_self.delegate captureOutput:_self pixelBuffer:pixelBuffer];
        }
    }
}
複製程式碼

self.delegate就是LFLiveSession物件,視訊資料就流到了session部分,進入編碼階段。


3. 濾鏡和OpenGL

濾鏡的實現部分,先看一個簡單的例子:GPUImageCropFilter。在上面也用到了,就是用來做裁剪的。

它繼承於GPUImageFilter,而GPUImageFilter繼承於GPUImageOutput <GPUImageInput>,它既是一個output也是input。

作為input,會接收處理的影象,看GPUImageVideoCameraupdateTargetsForVideoCameraUsingCacheTextureAtWidth方法可以知道,傳遞給input的方法有兩個:

  • setInputFramebuffer:atIndex: 這個是傳遞GPUImageFramebuffer物件
  • newFrameReadyAtTime:atIndex: 這個才是開啟下一環節的處理。

GPUImageFramebuffer是LFLiveKit封裝的資料,用來在影象處理元件之間傳遞,包含了影象的大小、紋理、紋理型別、取樣格式等。在影象處理鏈裡傳遞影象,肯定需要一個統一的型別,除了影象本身,肯定還需要關於影象的資訊,這樣每個元件可以按相同的標準對待影象。GPUImageFramebuffer就起的這個作用。

GPUImageFramebuffer內部核心的東西是GLuint framebuffer,即OpenGL裡的frameBufferObject(FBO).關於FBO我也不是很瞭解,只知道它像一個容器,可以掛載了render buffer、texture、depth buffer等,也就是原本渲染輸出到螢幕的東西,可以輸出到一個FBO,然後可以拿這個渲染的結果進行再一次的處理。

FBO的結構圖

在這個專案裡,就是在FBO上掛載紋理,一次影象處理就是經歷一次OpenGL渲染,處理前的影象用紋理的形式傳入OpenGL,經歷渲染流程輸出到FBO, 影象資料就輸出到FBO繫結的紋理上了。這樣做了一次處理後資料結構還是一樣,即繫結texture的FBO,可以再作為輸入源提供給下一個元件。

FBO的構建具體看GPUImageFramebuffer的方法generateFramebuffer

這裡有一個值得學習的是GPUImageFramebuffer使用了一個快取池,核心類GPUImageFramebufferCache。從流程裡可以看得出GPUImageFramebuffer它是一箇中間量,從元件A傳遞給元件B之後,B會使用這個framebuffer,B呼叫framebuffer的lock,使用完之後呼叫unlock。跟OC記憶體管理裡的引用計數原理類似,lock引用計數+1,unlock-1,引用計數小於1就回歸快取池。需要一個新的frameBuffer的時候從優先從快取池裡拿,沒有才構建。這一點又跟tableView的cell重用機制有點像。

緩衝區在資料流相關的程式是一個常用的功能,這種方案值得學習一下

說完GPUImageFramebuffer,再回到newFrameReadyAtTime:atIndex方法。

它裡面就兩個方法:renderToTextureWithVertices這個是執行OpenGL ES的渲染操作,informTargetsAboutNewFrameAtTime是通知它的target,把影象傳遞給下一環節處理。

上面的這些都是GPUImageFilter這個基類的,再回到GPUImageCropFilter這個裁剪功能的濾鏡裡。

它的貢獻是根據裁剪區域的不同,提供了不同的textureCoordinates,這個是紋理座標。它的init方法裡使用的shader是kGPUImageCropFragmentShaderString,核心也就一句話:gl_FragColor = texture2D(inputImageTexture, textureCoordinate);,使用紋理座標取樣紋理。所以對於輸出結果而言,textureCoordinates就是關鍵因素。

剪下和旋轉效果都是通過修改紋理座標的方式來達到的,vertext shader和fragment shader很簡單,就是繪製一個矩形,然後使用紋理貼圖


4. 紋理座標的計算

我本以為剪下效果很簡單,但是摸索到紋理座標後發現是個巨坑,不是一兩句解釋的清,必須畫圖 -_-

頂點資料是:

static const GLfloat cropSquareVertices[] = {
        -1.0f, -1.0f,  
        1.0f, -1.0f,
        -1.0f,  1.0f,
        1.0f,  1.0f,
    };
複製程式碼

只有4個頂點,因為繪製矩形時使用的是GL_TRIANGLE_STRIP圖元,關於這個圖元規則看這裡

OpenGL的座標是y向上,x向右,配合頂點資料可知4個角的索引是這個樣子的:

頂點位置.png

紋理座標跟OpenGL座標方向是一樣的的:

紋理座標.png

但是影象座標卻是跟它們反的,一個圖片的資料是從左上角開始顯示的,跟UI的座標是一樣的。也就是,讀取一張圖片作為texture後,紋理座標(0, 0)讀到的資料時圖片左下角的。之前我搞暈了是:認為紋理座標和OpenGL座標是顛倒的,而沒有意識到紋理和影象的區別。當用圖片和用紋理做輸入源時就有區別了。

有了3種座標的認識,分析剪下效果的紋理座標前還要先看下preview(GPUImageView)的紋理座標邏輯,因為你眼睛看到的是preview的處理結果,它並不等於corpFiter的結果,不搞清它可能就被欺騙了。

視訊影象、紋理座標變換.png

  • 藍色的是影象/UI的座標方向,橙色的是texture的座標方向,綠色的是OpenGL的座標方向。

  • 相機後置攝像頭預設輸出landScapeRight方向的視訊資料,這是麻煩的起源,雖然現在可以通過AVCaptureConnectionvideoOrientation屬性修改了。圖裡就是以這種情景為例子分析。

  • landScapeRight就是逆時針旋轉了,底邊轉到了右邊。所以就有了圖2。

  • 然後影象和texture是上下顛倒的,所以有了圖3。

  • 然後分析3種處理情況,左轉、右轉和不旋轉,就有了圖4、5、6。

  • 有個關鍵點是:preview是按上下顛倒的方式顯示它接收的texture,因為:

    • 視訊採集結束後把資料輸出給外界還是得通過影象的格式,這樣其他播放器就可以不依賴於你的格式邏輯,都按照影象來處理。
    • 希望傳遞給外界的影象是正確的,那麼影象處理鏈結束輸出的texture格式就是顛倒的。因為影象和texture座標是上下顛倒的。
    • preview它作為處理鏈輸出接受者之一,接受的texture也就是顛倒的。這就造成了preview的紋理座標是上下顛倒取的,這樣顯示出來才是對的。
    • 所以在沒有旋轉的時候,preview的紋理座標是:
      static const GLfloat noRotationTextureCoordinates[] = {
          0.0f, 1.0f,    
          1.0f, 1.0f,
          0.0f, 0.0f,
          1.0f, 0.0f,
      };
      複製程式碼
      結合頂點座標資料,第1個頂點為(-1,-1)在左下角,紋理座標是(0,1),在左上角。第3個頂點(-1, 1)在左上角,紋理座標(0, 0),在左下角。
  • 所以對於上圖裡的情景,正確顯示應該取向右旋轉的操作,即圖5。這樣顯示出來,上下顛倒正好是圖1。

  • 所以如果不旋轉,而是直接顯示相機輸出的影象,也就是接受圖3的紋理,顯示出來的樣式就是圖2。修改GPUImageVideoCameraupdateOrientationSendToTargets方法,讓outputRotationkGPUImageNoRotation,就可以看到視訊是旋轉了90度的。當然事實是,我是眼睛看到了這個結果,再反推了裡面的這些邏輯的。

以紋理/影象的角度看流程是這樣:

座標變換.png

藍色是影象,紅色是紋理。

就因為上面的原因,你眼睛看到的和紋理本身是上下相反的。直接顯示相機輸出的時候是landscapeRight,要想變豎直,看起來應該是向左轉。但這個是影象顯示左轉,那麼就是紋理座標按右轉的取。說了那麼多,坑在這裡,影象的左轉效果需要紋理的右轉效果來實現

switch(_outputImageOrientation)
{
    case UIInterfaceOrientationPortrait:outputRotation = kGPUImageRotateRight; break;
    case UIInterfaceOrientationPortraitUpsideDown:outputRotation = kGPUImageRotateLeft; break;
    ......
}
複製程式碼
cropFilter的紋理座標計算

在回到剪下效果,虛線是剪下的位置:

紋理旋轉+剪下的邏輯示意圖.png

計算使用的資料:

    CGFloat minX = _cropRegion.origin.x;
    CGFloat minY = _cropRegion.origin.y;
    CGFloat maxX = CGRectGetMaxX(_cropRegion);
    CGFloat maxY = CGRectGetMaxY(_cropRegion);
複製程式碼

就是剪下區域的上下左右邊界,看剪下+右轉的情形。圖6是最終期望的結果,但剪下是影象處理之一,它的輸出是texture,所以它的輸出是圖3。第1個頂點,也就是左下角(-1, -1),對應的內容位置是1附近的虛線框頂點,1在輸入的texture裡是左上角,紋理座標的x是距離邊1-2的距離,紋理座標y是距離距離邊2-3的距離。

minX、minY這些資料是在哪個圖的?圖6。因為我們傳入的資料是根據自己眼睛看到的樣子來的,這個才是最終人需要的結果:

  • minX是虛線框邊1-4距離外框邊1-4的距離
  • minY是虛線框邊1-2距離外框邊1-2的距離
  • maxX是虛線框邊2-3距離外框邊1-4的距離
  • maxY是虛線框邊4-3距離外框邊1-2的距離

所以左下角的紋理座標應該是(minY, 1-minX)。


最後

花了很多的篇幅去說紋理座標的問題,一開始本來想挑個簡單例子(cropFiler)說下濾鏡元件的,但是這個紋理座標的計算讓我陷入了糊塗,不搞清楚實在不舒服。

更輕鬆的解決方案?

  1. 把旋轉做成單獨的處理元件,不要和其他的濾鏡混在一起了,其他處理元件就按照當前不旋轉的樣式來。
  2. 這些旋轉+剪下的邏輯可能一個矩陣運算就直接搞定了,那樣會更好理解些。

值得學習的地方:

  • 視訊影象處理
  • 緩衝區/快取重用機制
  • 鏈式影象處理
  • 整個庫的封裝很好:從LFLiveSession,到LFVideoCapture+LFAudioCapture,到GPUImageVideoCamera,層次清晰,每層的存在恰到好處。

相關文章