整體架構
以LFLiveSession
為中心切分成3部分:
- 前面是音視訊的資料採集
- 後面是音視訊資料推送到伺服器
- 中間是音視訊資料的編碼
資料採集分為視訊和音訊:
- 視訊由相機和一系列的濾鏡組成,最後輸出到預覽介面(preview)和
LFLiveSession
- 音訊使用
AudioUnit
讀取音訊,輸出到LFLiveSession
編碼部分:
- 視訊提供軟編碼和硬編碼,硬編碼使用VideoToolBox。編碼h264
- 音訊提供AudioToolBox的硬編碼,編碼AAC
推送部分:
- 編碼後的音視訊按幀裝入佇列,迴圈推送
- 容器採用FLV,按照FLV的資料格式組裝
- 使用librtmp庫進行推送。
視訊採集
視訊採集部分內容比較多,可以分為幾點:
- 相機
- 濾鏡
- 鏈式影象處理方案
- opengl es
核心類,也是承擔控制器角色的是LFVideoCapture
,負責組裝相機和濾鏡,管理視訊資料流。
1. 相機
相機的核心類是GPUImageVideoCamera
AVFoundation
的AVCaptureSession
,所以就是常規性的幾步:
- 構建
AVCaptureSession
:_captureSession = [[AVCaptureSession alloc] init];
- 配置輸入和輸出,輸入是裝置,一般就有前後攝像頭的區別
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];
}
複製程式碼
- 輸出可以是檔案也可以是資料,這裡因為要推送到伺服器,而且也為了後續的影象處理,顯然要用資料輸出。
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_420YpCbCr8BiPlanarFullRange
或kCVPixelFormatType_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. 影象處理鏈
這裡有兩種處理元件:GPUImageOutput
和GPUImageInput
。
GPUImageOutput
有一個target的概念的東西,在它處理完一個影象後,把影象傳遞給它的target。而GPUImageInput
怎麼接受從其他物件那傳遞過來的影象。通過這兩個元件,就可以把一個影象從一個元件傳遞另一個元件,形成鏈條。有點像接水管?-_-
而且可以是交叉性的,如圖:
有些濾鏡是需要多個輸入源,比如水印效果、蒙版效果,就可能出現D+E --->F的情況。這樣的結構好處就是每個環節可以自由的處理自己的任務,而不需要管資料從哪來,要推到那裡去。有資料它就處理,處理完就推到自己的tagets裡去。
我比較好奇的是為什麼
GPUImageOutput
定義成了類,而GPUImageInput
卻是協議,這也是值得思考的問題。
有了這兩個元件的認識,再去到LFVideoCapture
的reloadFilter
方法。在這裡,它把視訊採集的處理鏈組裝起來了,在這可以很清晰的看到影象資料的流動路線。
相機元件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.gpuImageView
和self.output
。形成資料流大概:
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,會接收處理的影象,看GPUImageVideoCamera
的updateTargetsForVideoCameraUsingCacheTextureAtWidth
方法可以知道,傳遞給input的方法有兩個:
setInputFramebuffer:atIndex
: 這個是傳遞GPUImageFramebuffer
物件newFrameReadyAtTime:atIndex:
這個才是開啟下一環節的處理。
GPUImageFramebuffer
是LFLiveKit封裝的資料,用來在影象處理元件之間傳遞,包含了影象的大小、紋理、紋理型別、取樣格式等。在影象處理鏈裡傳遞影象,肯定需要一個統一的型別,除了影象本身,肯定還需要關於影象的資訊,這樣每個元件可以按相同的標準對待影象。GPUImageFramebuffer
就起的這個作用。
GPUImageFramebuffer
內部核心的東西是GLuint framebuffer
,即OpenGL裡的frameBufferObject(FBO).關於FBO我也不是很瞭解,只知道它像一個容器,可以掛載了render buffer、texture、depth buffer等,也就是原本渲染輸出到螢幕的東西,可以輸出到一個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個角的索引是這個樣子的:
紋理座標跟OpenGL座標方向是一樣的的:
但是影象座標卻是跟它們反的,一個圖片的資料是從左上角開始顯示的,跟UI的座標是一樣的。也就是,讀取一張圖片作為texture後,紋理座標(0, 0)讀到的資料時圖片左下角的。之前我搞暈了是:認為紋理座標和OpenGL座標是顛倒的,而沒有意識到紋理和影象的區別。當用圖片和用紋理做輸入源時就有區別了。
有了3種座標的認識,分析剪下效果的紋理座標前還要先看下preview(GPUImageView
)的紋理座標邏輯,因為你眼睛看到的是preview的處理結果,它並不等於corpFiter的結果,不搞清它可能就被欺騙了。
-
藍色的是影象/UI的座標方向,橙色的是texture的座標方向,綠色的是OpenGL的座標方向。
-
相機後置攝像頭預設輸出
landScapeRight
方向的視訊資料,這是麻煩的起源,雖然現在可以通過AVCaptureConnection
的videoOrientation
屬性修改了。圖裡就是以這種情景為例子分析。 -
landScapeRight
就是逆時針旋轉了,底邊轉到了右邊。所以就有了圖2。 -
然後影象和texture是上下顛倒的,所以有了圖3。
-
然後分析3種處理情況,左轉、右轉和不旋轉,就有了圖4、5、6。
-
有個關鍵點是:preview是按上下顛倒的方式顯示它接收的texture,因為:
- 視訊採集結束後把資料輸出給外界還是得通過影象的格式,這樣其他播放器就可以不依賴於你的格式邏輯,都按照影象來處理。
- 希望傳遞給外界的影象是正確的,那麼影象處理鏈結束輸出的texture格式就是顛倒的。因為影象和texture座標是上下顛倒的。
- preview它作為處理鏈輸出接受者之一,接受的texture也就是顛倒的。這就造成了preview的紋理座標是上下顛倒取的,這樣顯示出來才是對的。
- 所以在沒有旋轉的時候,preview的紋理座標是:
結合頂點座標資料,第1個頂點為(-1,-1)在左下角,紋理座標是(0,1),在左上角。第3個頂點(-1, 1)在左上角,紋理座標(0, 0),在左下角。static const GLfloat noRotationTextureCoordinates[] = { 0.0f, 1.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.0f, }; 複製程式碼
-
所以對於上圖裡的情景,正確顯示應該取向右旋轉的操作,即圖5。這樣顯示出來,上下顛倒正好是圖1。
-
所以如果不旋轉,而是直接顯示相機輸出的影象,也就是接受圖3的紋理,顯示出來的樣式就是圖2。修改
GPUImageVideoCamera
的updateOrientationSendToTargets
方法,讓outputRotation
為kGPUImageNoRotation
,就可以看到視訊是旋轉了90度的。當然事實是,我是眼睛看到了這個結果,再反推了裡面的這些邏輯的。
以紋理/影象的角度看流程是這樣:
藍色是影象,紅色是紋理。
就因為上面的原因,你眼睛看到的和紋理本身是上下相反的。直接顯示相機輸出的時候是landscapeRight
,要想變豎直,看起來應該是向左轉。但這個是影象顯示左轉,那麼就是紋理座標按右轉的取。說了那麼多,坑在這裡,影象的左轉效果需要紋理的右轉效果來實現。
switch(_outputImageOrientation)
{
case UIInterfaceOrientationPortrait:outputRotation = kGPUImageRotateRight; break;
case UIInterfaceOrientationPortraitUpsideDown:outputRotation = kGPUImageRotateLeft; break;
......
}
複製程式碼
cropFilter的紋理座標計算
在回到剪下效果,虛線是剪下的位置:
計算使用的資料:
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)說下濾鏡元件的,但是這個紋理座標的計算讓我陷入了糊塗,不搞清楚實在不舒服。
更輕鬆的解決方案?
- 把旋轉做成單獨的處理元件,不要和其他的濾鏡混在一起了,其他處理元件就按照當前不旋轉的樣式來。
- 這些旋轉+剪下的邏輯可能一個矩陣運算就直接搞定了,那樣會更好理解些。
值得學習的地方:
- 視訊影象處理
- 緩衝區/快取重用機制
- 鏈式影象處理
- 整個庫的封裝很好:從LFLiveSession,到LFVideoCapture+LFAudioCapture,到GPUImageVideoCamera,層次清晰,每層的存在恰到好處。