1.需求來源。
最近有一個用戶反饋,發出去的視頻有點不清楚。由于視頻壓縮模塊是在幾年前寫的,當時的已經滿足不了現在的需求了,所以需要重新設計壓縮的實現。
2.現狀
使用AVAssetExportSession
作為導出工具,指定壓縮質量AVAssetExportPresetMediumQuality
,這樣能有效的減少視頻體積,但是視頻畫面清晰度比較差,舉個例子:一個25秒的1080p視頻,經過壓縮后從1080p變為320p,大小從34m變成2.6m。
AVAssetExportSession *exportSession = [[AVAssetExportSession alloc] initWithAsset:avAsset presetName:AVAssetExportPresetMediumQuality];
exportSession.outputURL= url;
exportSession.shouldOptimizeForNetworkUse = YES;
exportSession.outputFileType = AVFileTypeMPEG4;
[exportSessionexportAsynchronouslyWithCompletionHandler:^{
switch([exportSessionstatus]) {
case AVAssetExportSessionStatusFailed:
NSLog(@"Export canceled");
break;
case AVAssetExportSessionStatusCancelled:
NSLog(@"Export canceled");
break;
case AVAssetExportSessionStatusCompleted:{
NSLog(@"Successful!");
break;
}
default:break;
}
重新梳理下我們的需求,我們的場景對視頻質量要求稍高,對視頻的大小容忍比較高,所以將最大分辨率設為720p。
所以我們的壓縮設置改為AVAssetExportPreset1280x720
,壓縮后大小幾乎沒變,從34m變成32.5m。我們可以用mideaInfo來查看下兩個視頻文件到底有什么區別,上圖為1080p,下圖為720p:
由上圖可以看到,兩個分辨率差別巨大的視頻,大小居然差不多,要分析其中的原因首先要了解H264編碼。
3.H264編碼
關于H264編碼的原理可以參考(這篇文章),本文不詳細展開,只說明幾個參數。
Bit Rate
:
比特率是指每秒傳送的比特(bit)數。單位為 bps(Bit Per Second),比特率越高,每秒傳送數據就越多,畫質就越清晰。聲音中的比特率是指將模擬聲音信號轉換成數字聲音信號后,單位時間內的二進制數據量,是間接衡量音頻質量的一個指標。 視頻中的比特率(碼率)原理與聲音中的相同,都是指由模擬信號轉換為數字信號后,單位時間內的二進制數據量。
所以選擇適合的比特率是壓縮視頻大小的關鍵,比特率設置太小的話,視頻會變得模糊,失真。比特率太高的話,視頻數據太大,又達不到我們壓縮的要求。
Format profile
:
作為行業標準,H.264編碼體系定義了4種不同的Profile(類):Baseline(基線類),Main(主要類), Extended(擴展類)和High Profile(高端類)(它們各自下分成許多個層):
Baseline Profile 提供I/P幀,僅支持progressive(逐行掃描)和CAVLC;
Extended Profile 提供I/P/B/SP/SI幀,僅支持progressive(逐行掃描)和CAVLC;
Main Profile 提供I/P/B幀,支持progressive(逐行掃描)和interlaced(隔行掃描),提供CAVLC或CABAC;
High Profile (也就是FRExt)在Main Profile基礎上新增:8x8 intra prediction(8x8 幀內預測), custom quant(自定義量化), lossless video coding(無損視頻編碼), 更多的yuv格式(4:4:4...);
從壓縮比例來說 從壓縮比例來說,baseline< main < high,由于上圖中720p是Main@L3.1
,1080p是High@L4
,這就是明明分辨率不一樣,但是壓縮后的大小卻差不多的原因。
關于iPhone設備對的支持
iPhone 3GS 和更早的設備支持 Baseline Profile level 3.0 及更低的級別
iPhone 4S 支持 High Profile level 4.1 及更低的級別
iPhone 5C 支持 High Profile level 4.1 及更低的級別
iPhone 5S 支持 High Profile level 4.1 及更低的級別
iPad 1 支持 Main Profile level 3.1 及更低的級別
iPad 2 支持 Main Profile level 3.1 及更低的級別
iPad with Retina display 支持 High Profile level 4.1 及更低的級別
iPad mini 支持 High Profile level 4.1 及更低的級別
GOP
:
GOP 指的就是兩個I幀之間的間隔。
在視頻編碼序列中,主要有三種編碼幀:I幀、P幀、B幀。
- I幀即Intra-coded picture(幀內編碼圖像幀),不參考其他圖像幀,只利用本幀的信息進行編碼
- P幀即Predictive-codedPicture(預測編碼圖像幀),利用之前的I幀或P幀,采用運動預測的方式進行幀間預測編碼
- B幀即Bidirectionallypredicted picture(雙向預測編碼圖像幀),提供最高的壓縮比,它既需要之前的圖
像幀(I幀或P幀),也需要后來的圖像幀(P幀),采用運動預測的方式進行幀間雙向預測編碼
在視頻編碼序列中,GOP即Group of picture(圖像組),指兩個I幀之間的距離,Reference(參考周期)指兩個P幀之間的距離。一個I幀所占用的字節數大于一個P幀,一個P幀所占用的字節數大于一個B幀。
所以在碼率不變的前提下,GOP值越大,P、B幀的數量會越多,平均每個I、P、B幀所占用的字節數就越多,也就更容易獲取較好的圖像質量;Reference越大,B幀的數量越多,同理也更容易獲得較好的圖像質量。
需要說明的是,通過提高GOP值來提高圖像質量是有限度的,在遇到場景切換的情況時,H.264編碼器會自動強制插入一個I幀,此時實際的GOP值被縮短了。另一方面,在一個GOP中,P、B幀是由I幀預測得到的,當I幀的圖像質量比較差時,會影響到一個GOP中后續P、B幀的圖像質量,直到下一個GOP開始才有可能得以恢復,所以GOP值也不宜設置過大。
同時,由于P、B幀的復雜度大于I幀,所以過多的P、B幀會影響編碼效率,使編碼效率降低。另外,過長的GOP還會影響Seek操作的響應速度,由于P、B幀是由前面的I或P幀預測得到的,所以Seek操作需要直接定位,解碼某一個P或B幀時,需要先解碼得到本GOP內的I幀及之前的N個預測幀才可以,GOP值越長,需要解碼的預測幀就越多,seek響應的時間也越長。
M 和 N :M值表示I幀或者P幀之間的幀數目,N值表示GOP的長度。N的至越大,代表壓縮率越大。因為圖2中N=15遠小于圖一中N=30。這也是720p尺寸壓縮不理想的原因。
4.解決思路
由上可知壓縮視頻主要可以采用以下幾種手段:
- 降低分辨率
- 降低碼率
- 指定高的
Format profile
由于業務指定分辨率為720p,所以我們只能嘗試另外兩種方法。
降低碼率
根據這篇文章Video Encoding Settings for H.264 Excellence,推薦了適合720p的推薦碼率為2400~3700
之間。之前壓縮的文件碼率為9979
,所以碼率還是有很大的優化空間的。
指定高的 Format profile
由于現在大部分的設備都支持High Profile level
,所以我們可以把Format profile
從Main Profile level
改為High Profile level
。
現在我們已經知道要做什么了,那么怎么做呢?
5.解決方法
由于之前的AVAssetExportSession
不能指定碼率和Format profile
,我們這里需要使用AVAssetReader
和AVAssetWriter
。
AVAssetReader
負責將數據從asset里拿出來,AVAssetWriter
負責將得到的數據存成文件。
核心代碼如下:
//生成reader 和 writer
self.reader = [AVAssetReader.alloc initWithAsset:self.asset error:&readerError];
self.writer = [AVAssetWriter assetWriterWithURL:self.outputURL fileType:self.outputFileType error:&writerError];
//視頻
if (videoTracks.count > 0) {
self.videoOutput = [AVAssetReaderVideoCompositionOutput assetReaderVideoCompositionOutputWithVideoTracks:videoTracks videoSettings:self.videoInputSettings];
self.videoOutput.alwaysCopiesSampleData = NO;
if (self.videoComposition)
{
self.videoOutput.videoComposition = self.videoComposition;
}
else
{
self.videoOutput.videoComposition = [self buildDefaultVideoComposition];
}
if ([self.reader canAddOutput:self.videoOutput])
{
[self.reader addOutput:self.videoOutput];
}
//
// Video input
//
self.videoInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo outputSettings:self.videoSettings];
self.videoInput.expectsMediaDataInRealTime = NO;
if ([self.writer canAddInput:self.videoInput])
{
[self.writer addInput:self.videoInput];
}
NSDictionary *pixelBufferAttributes = @
{
(id)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA),
(id)kCVPixelBufferWidthKey: @(self.videoOutput.videoComposition.renderSize.width),
(id)kCVPixelBufferHeightKey: @(self.videoOutput.videoComposition.renderSize.height),
@"IOSurfaceOpenGLESTextureCompatibility": @YES,
@"IOSurfaceOpenGLESFBOCompatibility": @YES,
};
self.videoPixelBufferAdaptor = [AVAssetWriterInputPixelBufferAdaptor assetWriterInputPixelBufferAdaptorWithAssetWriterInput:self.videoInput sourcePixelBufferAttributes:pixelBufferAttributes];
}
//音頻
NSArray *audioTracks = [self.asset tracksWithMediaType:AVMediaTypeAudio];
if (audioTracks.count > 0) {
self.audioOutput = [AVAssetReaderAudioMixOutput assetReaderAudioMixOutputWithAudioTracks:audioTracks audioSettings:nil];
self.audioOutput.alwaysCopiesSampleData = NO;
self.audioOutput.audioMix = self.audioMix;
if ([self.reader canAddOutput:self.audioOutput])
{
[self.reader addOutput:self.audioOutput];
}
} else {
// Just in case this gets reused
self.audioOutput = nil;
}
//
// Audio input
//
if (self.audioOutput) {
self.audioInput = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeAudio outputSettings:self.audioSettings];
self.audioInput.expectsMediaDataInRealTime = NO;
if ([self.writer canAddInput:self.audioInput])
{
[self.writer addInput:self.audioInput];
}
}
//開始讀寫
[self.writer startWriting];
[self.reader startReading];
[self.writer startSessionAtSourceTime:self.timeRange.start];
//壓縮完成的回調
__block BOOL videoCompleted = NO;
__block BOOL audioCompleted = NO;
__weak typeof(self) wself = self;
self.inputQueue = dispatch_queue_create("VideoEncoderInputQueue", DISPATCH_QUEUE_SERIAL);
if (videoTracks.count > 0) {
[self.videoInput requestMediaDataWhenReadyOnQueue:self.inputQueue usingBlock:^
{
if (![wself encodeReadySamplesFromOutput:wself.videoOutput toInput:wself.videoInput])
{
@synchronized(wself)
{
videoCompleted = YES;
if (audioCompleted)
{
[wself finish];
}
}
}
}];
}
else {
videoCompleted = YES;
}
if (!self.audioOutput) {
audioCompleted = YES;
} else {
[self.audioInput requestMediaDataWhenReadyOnQueue:self.inputQueue usingBlock:^
{
if (![wself encodeReadySamplesFromOutput:wself.audioOutput toInput:wself.audioInput])
{
@synchronized(wself)
{
audioCompleted = YES;
if (videoCompleted)
{
[wself finish];
}
}
}
}];
}
其中self.videoInput
里的self.videoSettings
我們需要對視頻壓縮參數做設置
self.videoSettings = @
{
AVVideoCodecKey: AVVideoCodecH264,
AVVideoWidthKey: @1280,
AVVideoHeightKey: @720,
AVVideoCompressionPropertiesKey: @
{
AVVideoAverageBitRateKey: @3000000,
AVVideoProfileLevelKey: AVVideoProfileLevelH264High40,
},
};
封裝好的控件可以參考https://github.com/rs/SDAVAssetExportSession。
6.最終效果
通過下圖我們可以看到,視頻已經成功被壓縮成10m左右。結合視頻效果,這個壓縮成果我們還是很滿意的。
下面是視頻部分畫面截圖的效果:
7.視頻轉碼時遇到的坑
使用 SDAVAssetExportSession
時遇到一個坑,大部分視頻轉碼沒問題,部分視頻轉碼會有黑屏問題,最后定位出現問題的代碼如下:
- (AVMutableVideoComposition *)buildDefaultVideoComposition
{
AVMutableVideoComposition *videoComposition = [AVMutableVideoComposition videoComposition];
AVAssetTrack *videoTrack = [[self.asset tracksWithMediaType:AVMediaTypeVideo] objectAtIndex:0];
// get the frame rate from videoSettings, if not set then try to get it from the video track,
// if not set (mainly when asset is AVComposition) then use the default frame rate of 30
float trackFrameRate = 0;
if (self.videoSettings)
{
NSDictionary *videoCompressionProperties = [self.videoSettings objectForKey:AVVideoCompressionPropertiesKey];
if (videoCompressionProperties)
{
NSNumber *frameRate = [videoCompressionProperties objectForKey:AVVideoAverageNonDroppableFrameRateKey];
if (frameRate)
{
trackFrameRate = frameRate.floatValue;
}
}
}
else
{
trackFrameRate = [videoTrack nominalFrameRate];
}
if (trackFrameRate == 0)
{
trackFrameRate = 30;
}
videoComposition.frameDuration = CMTimeMake(1, trackFrameRate);
CGSize targetSize = CGSizeMake([self.videoSettings[AVVideoWidthKey] floatValue], [self.videoSettings[AVVideoHeightKey] floatValue]);
CGSize naturalSize = [videoTrack naturalSize];
CGAffineTransform transform = videoTrack.preferredTransform;
// Workaround radar 31928389, see https://github.com/rs/SDAVAssetExportSession/pull/70 for more info
if (transform.ty == -560) {
transform.ty = 0;
}
if (transform.tx == -560) {
transform.tx = 0;
}
CGFloat videoAngleInDegree = atan2(transform.b, transform.a) * 180 / M_PI;
if (videoAngleInDegree == 90 || videoAngleInDegree == -90) {
CGFloat width = naturalSize.width;
naturalSize.width = naturalSize.height;
naturalSize.height = width;
}
videoComposition.renderSize = naturalSize;
// center inside
{
float ratio;
float xratio = targetSize.width / naturalSize.width;
float yratio = targetSize.height / naturalSize.height;
ratio = MIN(xratio, yratio);
float postWidth = naturalSize.width * ratio;
float postHeight = naturalSize.height * ratio;
float transx = (targetSize.width - postWidth) / 2;
float transy = (targetSize.height - postHeight) / 2;
CGAffineTransform matrix = CGAffineTransformMakeTranslation(transx / xratio, transy / yratio);
matrix = CGAffineTransformScale(matrix, ratio / xratio, ratio / yratio);
transform = CGAffineTransformConcat(transform, matrix);
}
// Make a "pass through video track" video composition.
AVMutableVideoCompositionInstruction *passThroughInstruction = [AVMutableVideoCompositionInstruction videoCompositionInstruction];
passThroughInstruction.timeRange = CMTimeRangeMake(kCMTimeZero, self.asset.duration);
AVMutableVideoCompositionLayerInstruction *passThroughLayer = [AVMutableVideoCompositionLayerInstruction videoCompositionLayerInstructionWithAssetTrack:videoTrack];
[passThroughLayer setTransform:transform atTime:kCMTimeZero];
passThroughInstruction.layerInstructions = @[passThroughLayer];
videoComposition.instructions = @[passThroughInstruction];
return videoComposition;
}
1. transform 不正確引起的黑屏
CGAffineTransform transform = videoTrack.preferredTransform;
1.參考評論區 @baopanpan同學的說法,TZImagePickerController
可以解決,找到代碼試了一下,確實不會出現黑屏的問題了,代碼如下
/// 獲取優化后的視頻轉向信息
- (AVMutableVideoComposition *)fixedCompositionWithAsset:(AVAsset *)videoAsset {
AVMutableVideoComposition *videoComposition = [AVMutableVideoComposition videoComposition];
// 視頻轉向
int degrees = [self degressFromVideoFileWithAsset:videoAsset];
if (degrees != 0) {
CGAffineTransform translateToCenter;
CGAffineTransform mixedTransform;
videoComposition.frameDuration = CMTimeMake(1, 30);
NSArray *tracks = [videoAsset tracksWithMediaType:AVMediaTypeVideo];
AVAssetTrack *videoTrack = [tracks objectAtIndex:0];
AVMutableVideoCompositionInstruction *roateInstruction = [AVMutableVideoCompositionInstruction videoCompositionInstruction];
roateInstruction.timeRange = CMTimeRangeMake(kCMTimeZero, [videoAsset duration]);
AVMutableVideoCompositionLayerInstruction *roateLayerInstruction = [AVMutableVideoCompositionLayerInstruction videoCompositionLayerInstructionWithAssetTrack:videoTrack];
if (degrees == 90) {
// 順時針旋轉90°
translateToCenter = CGAffineTransformMakeTranslation(videoTrack.naturalSize.height, 0.0);
mixedTransform = CGAffineTransformRotate(translateToCenter,M_PI_2);
videoComposition.renderSize = CGSizeMake(videoTrack.naturalSize.height,videoTrack.naturalSize.width);
[roateLayerInstruction setTransform:mixedTransform atTime:kCMTimeZero];
} else if(degrees == 180){
// 順時針旋轉180°
translateToCenter = CGAffineTransformMakeTranslation(videoTrack.naturalSize.width, videoTrack.naturalSize.height);
mixedTransform = CGAffineTransformRotate(translateToCenter,M_PI);
videoComposition.renderSize = CGSizeMake(videoTrack.naturalSize.width,videoTrack.naturalSize.height);
[roateLayerInstruction setTransform:mixedTransform atTime:kCMTimeZero];
} else if(degrees == 270){
// 順時針旋轉270°
translateToCenter = CGAffineTransformMakeTranslation(0.0, videoTrack.naturalSize.width);
mixedTransform = CGAffineTransformRotate(translateToCenter,M_PI_2*3.0);
videoComposition.renderSize = CGSizeMake(videoTrack.naturalSize.height,videoTrack.naturalSize.width);
[roateLayerInstruction setTransform:mixedTransform atTime:kCMTimeZero];
}else {//增加異常處理
videoComposition.renderSize = CGSizeMake(videoTrack.naturalSize.width,videoTrack.naturalSize.height);
}
roateInstruction.layerInstructions = @[roateLayerInstruction];
// 加入視頻方向信息
videoComposition.instructions = @[roateInstruction];
}
return videoComposition;
}
/// 獲取視頻角度
- (int)degressFromVideoFileWithAsset:(AVAsset *)asset {
int degress = 0;
NSArray *tracks = [asset tracksWithMediaType:AVMediaTypeVideo];
if([tracks count] > 0) {
AVAssetTrack *videoTrack = [tracks objectAtIndex:0];
CGAffineTransform t = videoTrack.preferredTransform;
if(t.a == 0 && t.b == 1.0 && t.c == -1.0 && t.d == 0){
// Portrait
degress = 90;
} else if(t.a == 0 && t.b == -1.0 && t.c == 1.0 && t.d == 0){
// PortraitUpsideDown
degress = 270;
} else if(t.a == 1.0 && t.b == 0 && t.c == 0 && t.d == 1.0){
// LandscapeRight
degress = 0;
} else if(t.a == -1.0 && t.b == 0 && t.c == 0 && t.d == -1.0){
// LandscapeLeft
degress = 180;
}
}
return degress;
}
2. naturalSize 不正確的坑
之前用模擬器錄屏得到一個視頻,調用[videoTrack naturalSize]
的時候,得到的size為 (CGSize) naturalSize = (width = 828, height = 0.02734375)
,明顯是不正確的,這個暫時沒有找到解決辦法,知道的同學可以在下面評論一下。
3. 視頻黑邊問題
視頻黑邊應該是視頻源尺寸和目標尺寸比例不一致造成的,需要根據原尺寸的比例算出目標尺寸
CGSize targetSize = CGSizeMake(videoAsset.pixelWidth, videoAsset.pixelHeight);
//尺寸過大才壓縮,否則不更改targetSize
if (targetSize.width * targetSize.height > 1280 * 720) {
int width = 0,height = 0;
if (targetSize.width > targetSize.height) {
width = 1280;
height = 1280 * targetSize.height/targetSize.width;
}else {
width = 720;
height = 720 * targetSize.height/targetSize.width;
}
targetSize = CGSizeMake(width, height);
}else if (targetSize.width == 0 || targetSize.height == 0) {//異常情況處理
targetSize = CGSizeMake(720, 1280);
}