iOS AVDemo(8):視頻編碼,H.264 和 H.265 都支持丨音視頻工程示例

vx 搜索『gjzkeyframe』 關(guān)注『關(guān)鍵幀Keyframe』來及時(shí)獲得最新的音視頻技術(shù)文章巴比。

莫奈《草垛》

這個(gè)公眾號(hào)會(huì)路線圖 式的遍歷分享音視頻技術(shù)音視頻基礎(chǔ)(完成)音視頻工具(完成)音視頻工程示例(進(jìn)行中) → 音視頻工業(yè)實(shí)戰(zhàn)(準(zhǔn)備)即横。

iOS/Android 客戶端開發(fā)同學(xué)如果想要開始學(xué)習(xí)音視頻開發(fā),最絲滑的方式是對(duì)音視頻基礎(chǔ)概念知識(shí)有一定了解后苹享,再借助 iOS/Android 平臺(tái)的音視頻能力上手去實(shí)踐音視頻的采集 → 編碼 → 封裝 → 解封裝 → 解碼 → 渲染過程,并借助音視頻工具來分析和理解對(duì)應(yīng)的音視頻數(shù)據(jù)浴麻。

音視頻工程示例這個(gè)欄目得问,我們將通過拆解采集 → 編碼 → 封裝 → 解封裝 → 解碼 → 渲染流程并實(shí)現(xiàn) Demo 來向大家介紹如何在 iOS/Android 平臺(tái)上手音視頻開發(fā)。

這里是第八篇:iOS 視頻編碼 Demo软免。這個(gè) Demo 里包含以下內(nèi)容:

  • 1)實(shí)現(xiàn)一個(gè)視頻采集模塊宫纬;
  • 2)實(shí)現(xiàn)一個(gè)視頻編碼模塊,支持 H.264/H.265膏萧;
  • 3)串聯(lián)視頻采集和編碼模塊漓骚,將采集到的視頻數(shù)據(jù)輸入給編碼模塊進(jìn)行編碼蝌衔,并存儲(chǔ)為文件;
  • 4)詳盡的代碼注釋蝌蹂,幫你理解代碼邏輯和原理噩斟。

如果你想獲得全部源碼和參與音視頻技術(shù)討論,vx 搜索『gjzkeyframe』 關(guān)注『關(guān)鍵幀Keyframe』咨詢叉信,或知識(shí)星球搜『關(guān)鍵幀的音視頻開發(fā)圈』加入(早加入還有優(yōu)惠券)亩冬。

關(guān)于社群,可以了解一下:《是的硼身,我建了一個(gè)進(jìn)階百萬年薪的社群》硅急。用一份下午茶的成本,換一個(gè)百萬年薪的可能佳遂。

想要了解視頻編碼营袜,可以看看這幾篇:

1、視頻采集模塊

在這個(gè) Demo 中丑罪,視頻采集模塊 KFVideoCapture 的實(shí)現(xiàn)與 《iOS 視頻采集 Demo》 中一樣荚板,這里就不再重復(fù)介紹了,其接口如下:

KFVideoCapture.h

#import <Foundation/Foundation.h>
#import "KFVideoCaptureConfig.h"

NS_ASSUME_NONNULL_BEGIN

@interface KFVideoCapture : NSObject
+ (instancetype)new NS_UNAVAILABLE;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithConfig:(KFVideoCaptureConfig *)config;

@property (nonatomic, strong, readonly) KFVideoCaptureConfig *config;
@property (nonatomic, strong, readonly) AVCaptureVideoPreviewLayer *previewLayer; // 視頻預(yù)覽渲染 layer吩屹。
@property (nonatomic, copy) void (^sampleBufferOutputCallBack)(CMSampleBufferRef sample); // 視頻采集數(shù)據(jù)回調(diào)跪另。
@property (nonatomic, copy) void (^sessionErrorCallBack)(NSError *error); // 視頻采集會(huì)話錯(cuò)誤回調(diào)。
@property (nonatomic, copy) void (^sessionInitSuccessCallBack)(void); // 視頻采集會(huì)話初始化成功回調(diào)煤搜。

- (void)startRunning; // 開始采集免绿。
- (void)stopRunning; // 停止采集。
- (void)changeDevicePosition:(AVCaptureDevicePosition)position; // 切換攝像頭擦盾。
@end

NS_ASSUME_NONNULL_END

2嘲驾、視頻編碼模塊

在實(shí)現(xiàn)視頻編碼模塊之前,我們先實(shí)現(xiàn)一個(gè)視頻編碼配置類 KFVideoEncoderConfig

KFVideoEncoderConfig.h

#import <Foundation/Foundation.h>
#import <AVFoundation/AVFoundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface KFVideoEncoderConfig : NSObject
@property (nonatomic, assign) CGSize size; // 分辨率迹卢。
@property (nonatomic, assign) NSInteger bitrate; // 碼率辽故。
@property (nonatomic, assign) NSInteger fps; // 幀率。
@property (nonatomic, assign) NSInteger gopSize; // GOP 幀數(shù)腐碱。
@property (nonatomic, assign) BOOL openBFrame; // 編碼是否使用 B 幀誊垢。
@property (nonatomic, assign) CMVideoCodecType codecType; // 編碼器類型。
@property (nonatomic, assign) NSString *profile; // 編碼 profile症见。
@end

NS_ASSUME_NONNULL_END

KFVideoEncoderConfig.m

#import "KFVideoEncoderConfig.h"
#import <VideoToolBox/VideoToolBox.h>

@implementation KFVideoEncoderConfig

- (instancetype)init {
    self = [super init];
    if (self) {
        _size = CGSizeMake(1080, 1920);
        _bitrate = 5000 * 1024;
        _fps = 30;
        _gopSize = _fps * 5;
        _openBFrame = YES;
        
        BOOL supportHEVC = NO;
        if (@available(iOS 11.0, *)) {
            if (&VTIsHardwareDecodeSupported) {
                supportHEVC = VTIsHardwareDecodeSupported(kCMVideoCodecType_HEVC);
            }
        }
        
        _codecType = supportHEVC ? kCMVideoCodecType_HEVC : kCMVideoCodecType_H264;
        _profile = supportHEVC ? (__bridge NSString *) kVTProfileLevel_HEVC_Main_AutoLevel : AVVideoProfileLevelH264HighAutoLevel;
    }
    
    return self;
}

@end

這里實(shí)現(xiàn)了在設(shè)備支持 H.265 時(shí)喂走,默認(rèn)選擇 H.265 編碼。

接下來筒饰,我們來實(shí)現(xiàn)一個(gè)視頻編碼模塊 KFVideoEncoder缴啡,在這里輸入采集后的數(shù)據(jù)壁晒,輸出編碼后的數(shù)據(jù)瓷们。

KFVideoEncoder.h

#import <Foundation/Foundation.h>
#import "KFVideoEncoderConfig.h"

NS_ASSUME_NONNULL_BEGIN

@interface KFVideoEncoder : NSObject
+ (instancetype)new NS_UNAVAILABLE;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithConfig:(KFVideoEncoderConfig*)config;

@property (nonatomic, strong, readonly) KFVideoEncoderConfig *config; // 視頻編碼配置參數(shù)。
@property (nonatomic, copy) void (^sampleBufferOutputCallBack)(CMSampleBufferRef sampleBuffer); // 視頻編碼數(shù)據(jù)回調(diào)。
@property (nonatomic, copy) void (^errorCallBack)(NSError *error); // 視頻編碼錯(cuò)誤回調(diào)谬晕。

- (void)encodePixelBuffer:(CVPixelBufferRef)pixelBuffer ptsTime:(CMTime)timeStamp; // 編碼碘裕。
- (void)refresh; // 刷新重建編碼器。
- (void)flush; // 清空編碼緩沖區(qū)攒钳。
- (void)flushWithCompleteHandler:(void (^)(void))completeHandler; // 清空編碼緩沖區(qū)并回調(diào)完成帮孔。
@end

NS_ASSUME_NONNULL_END

上面是 KFVideoEncoder 接口的設(shè)計(jì),除了初始化方法不撑,主要是有獲取視頻編碼配置以及視頻編碼數(shù)據(jù)回調(diào)錯(cuò)誤回調(diào)的接口文兢,另外就是編碼刷新重建編碼器焕檬、清空編碼緩沖區(qū)的接口姆坚。

其中編碼接口對(duì)應(yīng)著視頻編碼模塊輸入,數(shù)據(jù)回調(diào)接口則對(duì)應(yīng)著輸出实愚〖婧牵可以看到這里輸出參數(shù)我們依然用的是 CMSampleBufferRef[1] 這個(gè)數(shù)據(jù)結(jié)構(gòu)。不過輸入的參數(shù)換成了 CVPixelBufferRef[2]這個(gè)數(shù)據(jù)結(jié)構(gòu)腊敲。它是對(duì) CVPixelBuffer 的一個(gè)引用击喂。

之前我們介紹過,CMSampleBuffer 中包含著零個(gè)或多個(gè)某一類型(audio碰辅、video懂昂、muxed 等)的采樣數(shù)據(jù)。比如:

  • 要么是一個(gè)或多個(gè)媒體采樣的 CMBlockBuffer[3]乎赴。其中可以封裝:音頻采集后忍法、編碼后、解碼后的數(shù)據(jù)(如:PCM 數(shù)據(jù)榕吼、AAC 數(shù)據(jù))饿序;視頻編碼后的數(shù)據(jù)(如:H.264/H.265 數(shù)據(jù))。
  • 要么是一個(gè) CVImageBuffer[4](也作 CVPixelBuffer[5])羹蚣。其中包含媒體流中 CMSampleBuffers 的格式描述原探、每個(gè)采樣的寬高和時(shí)序信息、緩沖級(jí)別和采樣級(jí)別的附屬信息顽素。緩沖級(jí)別的附屬信息是指緩沖區(qū)整體的信息咽弦,比如播放速度、對(duì)后續(xù)緩沖數(shù)據(jù)的操作等胁出。采樣級(jí)別的附屬信息是指單個(gè)采樣的信息型型,比如視頻幀的時(shí)間戳、是否關(guān)鍵幀等全蝶。其中可以封裝:視頻采集后闹蒜、解碼后等未經(jīng)編碼的數(shù)據(jù)(如:YCbCr 數(shù)據(jù)寺枉、RGBA 數(shù)據(jù))。

所以绷落,因?yàn)槭且曨l編碼的接口姥闪,這里用 CVPixelBufferRef 也就是圖一個(gè)方便,其實(shí)也可以用 CMSampleBufferRef砌烁,只要編碼用 CMSampleBufferGetImageBuffer(...) 取出對(duì)應(yīng)的 CVPixelBufferRef 即可筐喳。

KFVideoEncoder.m

#import "KFVideoEncoder.h"
#import <VideoToolBox/VideoToolBox.h>
#import <UIKit/UIKit.h>

#define KFEncoderRetrySessionMaxCount 5
#define KFEncoderEncodeFrameFailedMaxCount 20

@interface KFVideoEncoder ()
@property (nonatomic, assign) VTCompressionSessionRef compressionSession;
@property (nonatomic, strong, readwrite) KFVideoEncoderConfig *config; // 視頻編碼配置參數(shù)。
@property (nonatomic, strong) dispatch_queue_t encoderQueue;
@property (nonatomic, strong) dispatch_semaphore_t semaphore;
@property (nonatomic, assign) BOOL needRefreshSession; // 是否需要刷新重建編碼器函喉。
@property (nonatomic, assign) NSInteger retrySessionCount; // 刷新重建編碼器的次數(shù)避归。
@property (nonatomic, assign) NSInteger encodeFrameFailedCount; // 編碼失敗次數(shù)。
@end

@implementation KFVideoEncoder
#pragma mark - LifeCycle
- (instancetype)initWithConfig:(KFVideoEncoderConfig *)config {
    self = [super init];
    if (self) {
        _config = config;
        _encoderQueue = dispatch_queue_create("com.KeyFrameKit.videoEncoder", DISPATCH_QUEUE_SERIAL);
        _semaphore = dispatch_semaphore_create(1);
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(didEnterBackground:) name:UIApplicationDidEnterBackgroundNotification object:nil];
    }
    
    return self;
}

- (void)dealloc {
    [[NSNotificationCenter defaultCenter] removeObserver:self];
    dispatch_semaphore_wait(_semaphore, DISPATCH_TIME_FOREVER);
    [self _releaseCompressionSession];
    dispatch_semaphore_signal(_semaphore);
}

#pragma mark - Public Method
- (void)refresh {
    self.needRefreshSession = YES; // 標(biāo)記位待刷新重建編碼器管呵。
}

- (void)flush {
    // 清空編碼緩沖區(qū)槐脏。
    __weak typeof(self) weakSelf = self;
    dispatch_async(self.encoderQueue, ^{
        dispatch_semaphore_wait(weakSelf.semaphore, DISPATCH_TIME_FOREVER);
        [weakSelf _flush];
        dispatch_semaphore_signal(weakSelf.semaphore);
    });
}

- (void)flushWithCompleteHandler:(void (^)(void))completeHandler {
    // 清空編碼緩沖區(qū)并回調(diào)完成。
    __weak typeof(self) weakSelf = self;
    dispatch_async(self.encoderQueue, ^{
        dispatch_semaphore_wait(weakSelf.semaphore, DISPATCH_TIME_FOREVER);
        [weakSelf _flush];
        dispatch_semaphore_signal(weakSelf.semaphore);
        if (completeHandler) {
            completeHandler();
        }
    });
}

- (void)encodePixelBuffer:(CVPixelBufferRef)pixelBuffer ptsTime:(CMTime)timeStamp {
    // 編碼撇寞。
    if (!pixelBuffer || self.retrySessionCount >= KFEncoderRetrySessionMaxCount || self.encodeFrameFailedCount >= KFEncoderEncodeFrameFailedMaxCount) {
        return;
    }
    
    CFRetain(pixelBuffer);
    __weak typeof(self) weakSelf = self;
    dispatch_async(self.encoderQueue, ^{
        dispatch_semaphore_wait(weakSelf.semaphore, DISPATCH_TIME_FOREVER);
        OSStatus setupStatus = noErr;
        // 1顿天、如果還沒創(chuàng)建過編碼器或者需要刷新重建編碼器,就創(chuàng)建編碼器蔑担。
        if (!weakSelf.compressionSession || weakSelf.needRefreshSession) {
            [weakSelf _releaseCompressionSession];
            setupStatus = [weakSelf _setupCompressionSession];
            // 支持重試牌废,記錄重試次數(shù)。
            weakSelf.retrySessionCount = setupStatus == noErr ? 0 : (weakSelf.retrySessionCount + 1);
            if (setupStatus != noErr) {
                [weakSelf _releaseCompressionSession];
                NSLog(@"KFVideoEncoder setupCompressionSession error:%d", setupStatus);
            } else {
                weakSelf.needRefreshSession = NO;
            }
        }
        
        // 重試超過 KFEncoderRetrySessionMaxCount 次仍然失敗則認(rèn)為創(chuàng)建失敗啤握,報(bào)錯(cuò)鸟缕。
        if (!weakSelf.compressionSession) {
            CFRelease(pixelBuffer);
            dispatch_semaphore_signal(weakSelf.semaphore);
            if (weakSelf.retrySessionCount >= KFEncoderRetrySessionMaxCount && weakSelf.errorCallBack) {
                dispatch_async(dispatch_get_main_queue(), ^{
                    weakSelf.errorCallBack([NSError errorWithDomain:NSStringFromClass([KFVideoEncoder class]) code:setupStatus userInfo:nil]);
                });
            }
            return;
        }
        
        // 2、對(duì) pixelBuffer 進(jìn)行編碼排抬。
        VTEncodeInfoFlags flags;
        OSStatus encodeStatus = VTCompressionSessionEncodeFrame(weakSelf.compressionSession, pixelBuffer, timeStamp, CMTimeMake(1, (int32_t) weakSelf.config.fps), NULL, NULL, &flags);
        if (encodeStatus == kVTInvalidSessionErr) {
            // 編碼失敗進(jìn)行重建編碼器重試懂从。
            [weakSelf _releaseCompressionSession];
            setupStatus = [weakSelf _setupCompressionSession];
            weakSelf.retrySessionCount = setupStatus == noErr ? 0 : (weakSelf.retrySessionCount + 1);
            if (setupStatus == noErr) {
                encodeStatus = VTCompressionSessionEncodeFrame(weakSelf.compressionSession, pixelBuffer, timeStamp, CMTimeMake(1, (int32_t) weakSelf.config.fps), NULL, NULL, &flags);
            } else {
                [weakSelf _releaseCompressionSession];
            }
            
            NSLog(@"KFVideoEncoder kVTInvalidSessionErr");
        }
        // 記錄編碼失敗次數(shù)。
        if (encodeStatus != noErr) {
            NSLog(@"KFVideoEncoder VTCompressionSessionEncodeFrame error:%d", encodeStatus);
        }
        weakSelf.encodeFrameFailedCount = encodeStatus == noErr ? 0 : (weakSelf.encodeFrameFailedCount + 1);
        
        CFRelease(pixelBuffer);
        dispatch_semaphore_signal(weakSelf.semaphore);
        
        // 編碼失敗次數(shù)超過 KFEncoderEncodeFrameFailedMaxCount 次蹲蒲,報(bào)錯(cuò)番甩。
        if (weakSelf.encodeFrameFailedCount >= KFEncoderEncodeFrameFailedMaxCount && weakSelf.errorCallBack) {
            dispatch_async(dispatch_get_main_queue(), ^{
                weakSelf.errorCallBack([NSError errorWithDomain:NSStringFromClass([KFVideoEncoder class]) code:encodeStatus userInfo:nil]);
            });
        }
    });
}

#pragma mark - Privte Method
- (OSStatus)_setupCompressionSession {
    if (_compressionSession) {
        return noErr;
    }
    
    // 1、創(chuàng)建視頻編碼器實(shí)例届搁。
    // 這里要設(shè)置畫面尺寸缘薛、編碼器類型、編碼數(shù)據(jù)回調(diào)卡睦。
    OSStatus status = VTCompressionSessionCreate(NULL, _config.size.width, _config.size.height, _config.codecType, NULL, NULL, NULL, encoderOutputCallback, (__bridge void *) self, &_compressionSession);
    if (status != noErr) {
        return status;
    }
    
    // 2宴胧、設(shè)置編碼器屬性:實(shí)時(shí)編碼。
    VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_RealTime, (__bridge CFTypeRef) @(YES));
    
    // 3表锻、設(shè)置編碼器屬性:編碼 profile恕齐。
    status = VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_ProfileLevel, (__bridge CFStringRef) self.config.profile);
    if (status != noErr) {
        return status;
    }
    
    // 4、設(shè)置編碼器屬性:是否支持 B 幀瞬逊。
    status = VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_AllowFrameReordering, (__bridge CFTypeRef) @(self.config.openBFrame));
    if (status != noErr) {
        return status;
    }
    
    if (self.config.codecType == kCMVideoCodecType_H264) {
        // 5显歧、如果是 H.264 編碼补胚,設(shè)置編碼器屬性:熵編碼類型為 CABAC,上下文自適應(yīng)的二進(jìn)制算術(shù)編碼追迟。
        status = VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_H264EntropyMode, kVTH264EntropyMode_CABAC);
        if (status != noErr) {
            return status;
        }
    }
    
    // 6、設(shè)置編碼器屬性:畫面填充模式骚腥。
    NSDictionary *transferDic= @{
        (__bridge NSString *) kVTPixelTransferPropertyKey_ScalingMode: (__bridge NSString *) kVTScalingMode_Letterbox,
    };
    status = VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_PixelTransferProperties, (__bridge CFTypeRef) (transferDic));
    if (status != noErr) {
        return status;
    }
    
    // 7敦间、設(shè)置編碼器屬性:平均碼率。
    status = VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_AverageBitRate, (__bridge CFTypeRef) @(self.config.bitrate));
    if (status != noErr) {
        return status;
    }
    
    // 8束铭、設(shè)置編碼器屬性:碼率上限廓块。
    if (!self.config.openBFrame && self.config.codecType == kCMVideoCodecType_H264) {
        NSArray *limit = @[@(self.config.bitrate * 1.5 / 8), @(1)];
        status = VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_DataRateLimits, (__bridge CFArrayRef) limit);
        if (status != noErr) {
            return status;
        }
    }
    
    // 9、設(shè)置編碼器屬性:期望幀率契沫。
    status = VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_ExpectedFrameRate, (__bridge CFTypeRef) @(self.config.fps));
    if (status != noErr) {
        return status;
    }
    
    // 10带猴、設(shè)置編碼器屬性:最大關(guān)鍵幀間隔幀數(shù),也就是 GOP 幀數(shù)懈万。
    status = VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_MaxKeyFrameInterval, (__bridge CFTypeRef) @(self.config.gopSize));
    if (status != noErr) {
        return status;
    }
    
    // 11拴清、設(shè)置編碼器屬性:最大關(guān)鍵幀間隔時(shí)長。
    status = VTSessionSetProperty(_compressionSession, kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration, (__bridge CFTypeRef)@(_config.gopSize / _config.fps));
    if (status != noErr) {
        return status;
    }
    
    // 12会通、預(yù)備編碼口予。
    status = VTCompressionSessionPrepareToEncodeFrames(_compressionSession);
    return status;
}

- (void)_releaseCompressionSession {
    if (_compressionSession) {
        // 強(qiáng)制處理完所有待編碼的幀。
        VTCompressionSessionCompleteFrames(_compressionSession, kCMTimeInvalid);
        // 銷毀編碼器涕侈。
        VTCompressionSessionInvalidate(_compressionSession);
        CFRelease(_compressionSession);
        _compressionSession = NULL;
    }
}

- (void)_flush {
    // 清空編碼緩沖區(qū)沪停。
    if (_compressionSession) {
        // 傳入 kCMTimeInvalid 時(shí),強(qiáng)制處理完所有待編碼的幀裳涛,清空緩沖區(qū)。
        VTCompressionSessionCompleteFrames(_compressionSession, kCMTimeInvalid);
    }
}

#pragma mark - NSNotification
- (void)didEnterBackground:(NSNotification *)notification {
    self.needRefreshSession = YES; // 退后臺(tái)回來后需要刷新重建編碼器。
}

#pragma mark - EncoderOutputCallback
static void encoderOutputCallback(void * CM_NULLABLE outputCallbackRefCon,
                                  void * CM_NULLABLE sourceFrameRefCon,
                                  OSStatus status,
                                  VTEncodeInfoFlags infoFlags,
                                  CMSampleBufferRef sampleBuffer) {
    if (!sampleBuffer) {
        if (infoFlags & kVTEncodeInfo_FrameDropped) {
            NSLog(@"VideoToolboxEncoder kVTEncodeInfo_FrameDropped");
        }
        return;
    }
    
    // 向外層回調(diào)編碼數(shù)據(jù)豪嚎。
    KFVideoEncoder *videoEncoder = (__bridge KFVideoEncoder *) outputCallbackRefCon;
    if (videoEncoder && videoEncoder.sampleBufferOutputCallBack) {
        videoEncoder.sampleBufferOutputCallBack(sampleBuffer);
    }
}

@end

上面是 KFVideoEncoder 的實(shí)現(xiàn)细卧,從代碼上可以看到主要有這幾個(gè)部分:

  • 1)創(chuàng)建視頻編碼實(shí)例。
    • -_setupCompressionSession 方法中實(shí)現(xiàn)郊闯。
  • 2)實(shí)現(xiàn)視頻編碼邏輯且轨,并在編碼實(shí)例的數(shù)據(jù)回調(diào)中接收編碼后的數(shù)據(jù),拋給對(duì)外數(shù)據(jù)回調(diào)接口虚婿。
    • -encodePixelBuffer:ptsTime: 方法中實(shí)現(xiàn)旋奢。
    • 回調(diào)在 encoderOutputCallback 中實(shí)現(xiàn)。
  • 3)實(shí)現(xiàn)清空編碼緩沖區(qū)功能然痊。
    • -_flush 方法中實(shí)現(xiàn)至朗。
  • 4)刷新重建編碼器功能。
    • -refresh 方法中標(biāo)記需要刷新重建剧浸,在 -encodePixelBuffer:ptsTime: 方法檢查標(biāo)記并重建編碼器實(shí)例锹引。
  • 5)捕捉視頻編碼過程中的錯(cuò)誤矗钟,拋給對(duì)外錯(cuò)誤回調(diào)接口。
    • 主要在 -encodePixelBuffer:ptsTime: 方法捕捉錯(cuò)誤嫌变。
  • 6)清理視頻編碼器實(shí)例吨艇。
    • -_releaseCompressionSession 方法中實(shí)現(xiàn)。

更具體細(xì)節(jié)見上述代碼及其注釋腾啥。

3东涡、采集視頻數(shù)據(jù)進(jìn)行 H.264/H.265 編碼和存儲(chǔ)

我們?cè)谝粋€(gè) ViewController 中來實(shí)現(xiàn)視頻采集及編碼邏輯,并且示范了將 iOS 編碼的 AVCC/HVCC 碼流格式轉(zhuǎn)換為 AnnexB 碼流格式后再存儲(chǔ)倘待。

我們先來簡單介紹一下這兩種格式的區(qū)別:

AVCC/HVCC 碼流格式類似:

[extradata]|[length][NALU]|[length][NALU]|...
  • VPS疮跑、SPS、PPS 不用 NALU 來存儲(chǔ)凸舵,而是存儲(chǔ)在 extradata 中祖娘;
  • 每個(gè) NALU 前有個(gè) length 字段表示這個(gè) NALU 的長度(不包含 length 字段),length字段通常是 4 字節(jié)啊奄。

AnnexB 碼流格式:

[startcode][NALU]|[startcode][NALU]|...

需要注意的是:

  • 每個(gè) NALU 前要添加起始碼:0x00000001渐苏;
  • VPS、SPS菇夸、PPS 也都用這樣的 NALU 來存儲(chǔ)整以,一般在碼流最前面。

iOS 的 VideoToolbox 編碼和解碼只支持 AVCC/HVCC 的碼流格式峻仇。但是 Android 的 MediaCodec 只支持 AnnexB 的碼流格式公黑。

KFVideoEncoderViewController.m


#import "KFVideoEncoderViewController.h"
#import "KFVideoCapture.h"
#import "KFVideoEncoder.h"

@interface KFVideoPacketExtraData : NSObject
@property (nonatomic, strong) NSData *sps;
@property (nonatomic, strong) NSData *pps;
@property (nonatomic, strong) NSData *vps;
@end

@implementation KFVideoPacketExtraData
@end


@interface KFVideoEncoderViewController ()
@property (nonatomic, strong) KFVideoCaptureConfig *videoCaptureConfig;
@property (nonatomic, strong) KFVideoCapture *videoCapture;
@property (nonatomic, strong) KFVideoEncoderConfig *videoEncoderConfig;
@property (nonatomic, strong) KFVideoEncoder *videoEncoder;
@property (nonatomic, assign) BOOL isEncoding;
@property (nonatomic, strong) NSFileHandle *fileHandle;
@end

@implementation KFVideoEncoderViewController
#pragma mark - Property
- (KFVideoCaptureConfig *)videoCaptureConfig {
    if (!_videoCaptureConfig) {
        _videoCaptureConfig = [[KFVideoCaptureConfig alloc] init];
        // 這里我們采集數(shù)據(jù)用于編碼,顏色格式用了默認(rèn)的:kCVPixelFormatType_420YpCbCr8BiPlanarFullRange摄咆。
    }
    return _videoCaptureConfig;
}

- (KFVideoCapture *)videoCapture {
    if (!_videoCapture) {
        _videoCapture = [[KFVideoCapture alloc] initWithConfig:self.videoCaptureConfig];
        __weak typeof(self) weakSelf = self;
        _videoCapture.sessionInitSuccessCallBack = ^() {
            dispatch_async(dispatch_get_main_queue(), ^{
                // 預(yù)覽渲染凡蚜。
                [weakSelf.view.layer insertSublayer:weakSelf.videoCapture.previewLayer atIndex:0];
                weakSelf.videoCapture.previewLayer.backgroundColor = [UIColor blackColor].CGColor;
                weakSelf.videoCapture.previewLayer.frame = weakSelf.view.bounds;
            });
        };
        _videoCapture.sampleBufferOutputCallBack = ^(CMSampleBufferRef sampleBuffer) {
            if (weakSelf.isEncoding && sampleBuffer) {
                // 編碼。
                [weakSelf.videoEncoder encodePixelBuffer:CMSampleBufferGetImageBuffer(sampleBuffer) ptsTime:CMSampleBufferGetPresentationTimeStamp(sampleBuffer)];
            }
        };
        _videoCapture.sessionErrorCallBack = ^(NSError* error) {
            NSLog(@"KFVideoCapture Error:%zi %@", error.code, error.localizedDescription);
        };
    }
    
    return _videoCapture;
}

- (KFVideoEncoderConfig *)videoEncoderConfig {
    if (!_videoEncoderConfig) {
        _videoEncoderConfig = [[KFVideoEncoderConfig alloc] init];
    }
    
    return _videoEncoderConfig;
}

- (KFVideoEncoder *)videoEncoder {
    if (!_videoEncoder) {
        _videoEncoder = [[KFVideoEncoder alloc] initWithConfig:self.videoEncoderConfig];
        __weak typeof(self) weakSelf = self;
        _videoEncoder.sampleBufferOutputCallBack = ^(CMSampleBufferRef sampleBuffer) {
            // 保存編碼后的數(shù)據(jù)吭从。
            [weakSelf saveSampleBuffer:sampleBuffer];
        };
    }
    return _videoEncoder;
}

- (NSFileHandle *)fileHandle {
    if (!_fileHandle) {
        NSString *fileName = @"test.h264";
        if (self.videoEncoderConfig.codecType == kCMVideoCodecType_HEVC) {
            fileName = @"test.h265";
        }
        NSString *videoPath = [[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject] stringByAppendingPathComponent:fileName];
        [[NSFileManager defaultManager] removeItemAtPath:videoPath error:nil];
        [[NSFileManager defaultManager] createFileAtPath:videoPath contents:nil attributes:nil];
        _fileHandle = [NSFileHandle fileHandleForWritingAtPath:videoPath];
    }

    return _fileHandle;
}

#pragma mark - Lifecycle
- (void)viewDidLoad {
    [super viewDidLoad];
    
    // Navigation item.
    UIBarButtonItem *startBarButton = [[UIBarButtonItem alloc] initWithTitle:@"Start" style:UIBarButtonItemStylePlain target:self action:@selector(start)];
    UIBarButtonItem *stopBarButton = [[UIBarButtonItem alloc] initWithTitle:@"Stop" style:UIBarButtonItemStylePlain target:self action:@selector(stop)];
    UIBarButtonItem *cameraBarButton = [[UIBarButtonItem alloc] initWithTitle:@"Camera" style:UIBarButtonItemStylePlain target:self action:@selector(changeCamera)];
    self.navigationItem.rightBarButtonItems = @[stopBarButton,startBarButton,cameraBarButton];
    
    [self requestAccessForVideo];
    
    UITapGestureRecognizer *doubleTapGesture = [[UITapGestureRecognizer alloc]initWithTarget:self action:@selector(handleDoubleTap:)];
    doubleTapGesture.numberOfTapsRequired = 2;
    doubleTapGesture.numberOfTouchesRequired = 1;
    [self.view addGestureRecognizer:doubleTapGesture];
}

- (void)viewWillLayoutSubviews {
    [super viewWillLayoutSubviews];
    self.videoCapture.previewLayer.frame = self.view.bounds;
}

- (void)dealloc {
    
}

#pragma mark - Action
- (void)start {
    if (!self.isEncoding) {
        self.isEncoding = YES;
        [self.videoEncoder refresh];
    }
}

- (void)stop {
    if (self.isEncoding) {
        self.isEncoding = NO;
        [self.videoEncoder flush];
    }
}

- (void)onCameraSwitchButtonClicked:(UIButton *)button {
    [self.videoCapture changeDevicePosition:self.videoCapture.config.position == AVCaptureDevicePositionBack ? AVCaptureDevicePositionFront : AVCaptureDevicePositionBack];
}

- (void)changeCamera {
    [self.videoCapture changeDevicePosition:self.videoCapture.config.position == AVCaptureDevicePositionBack ? AVCaptureDevicePositionFront : AVCaptureDevicePositionBack];
}

-(void)handleDoubleTap:(UIGestureRecognizer *)sender {
    [self.videoCapture changeDevicePosition:self.videoCapture.config.position == AVCaptureDevicePositionBack ? AVCaptureDevicePositionFront : AVCaptureDevicePositionBack];
}

#pragma mark - Private Method
- (void)requestAccessForVideo{
    __weak typeof(self) weakSelf = self;
    AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
    switch (status) {
        case AVAuthorizationStatusNotDetermined: {
            // 許可對(duì)話沒有出現(xiàn)朝蜘,發(fā)起授權(quán)許可。
            [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) {
                if (granted) {
                    [weakSelf.videoCapture startRunning];
                } else {
                    // 用戶拒絕涩金。
                }
            }];
            break;
        }
        case AVAuthorizationStatusAuthorized: {
            // 已經(jīng)開啟授權(quán)谱醇,可繼續(xù)。
            [weakSelf.videoCapture startRunning];
            break;
        }
        default:
            break;
    }
}

- (KFVideoPacketExtraData *)getPacketExtraData:(CMSampleBufferRef)sampleBuffer {
    // 從 CMSampleBuffer 中獲取 extra data步做。
    if (!sampleBuffer) {
        return nil;
    }
    
    // 獲取編碼類型副渴。
    CMVideoCodecType codecType = CMVideoFormatDescriptionGetCodecType(CMSampleBufferGetFormatDescription(sampleBuffer));
    
    KFVideoPacketExtraData *extraData = nil;
    if (codecType == kCMVideoCodecType_H264) {
        // 獲取 H.264 的 extra data:sps、pps全度。
        CMFormatDescriptionRef format = CMSampleBufferGetFormatDescription(sampleBuffer);
        size_t sparameterSetSize, sparameterSetCount;
        const uint8_t *sparameterSet;
        OSStatus statusCode = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, 0, &sparameterSet, &sparameterSetSize, &sparameterSetCount, 0);
        if (statusCode == noErr) {
            size_t pparameterSetSize, pparameterSetCount;
            const uint8_t *pparameterSet;
            OSStatus statusCode = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, 1, &pparameterSet, &pparameterSetSize, &pparameterSetCount, 0);
            if (statusCode == noErr) {
                extraData = [[KFVideoPacketExtraData alloc] init];
                extraData.sps = [NSData dataWithBytes:sparameterSet length:sparameterSetSize];
                extraData.pps = [NSData dataWithBytes:pparameterSet length:pparameterSetSize];
            }
        }
    } else if (codecType == kCMVideoCodecType_HEVC) {
        // 獲取 H.265 的 extra data:vps煮剧、sps、pps。
        CMFormatDescriptionRef format = CMSampleBufferGetFormatDescription(sampleBuffer);
        size_t vparameterSetSize, vparameterSetCount;
        const uint8_t *vparameterSet;
        if (@available(iOS 11.0, *)) {
            OSStatus statusCode = CMVideoFormatDescriptionGetHEVCParameterSetAtIndex(format, 0, &vparameterSet, &vparameterSetSize, &vparameterSetCount, 0);
            if (statusCode == noErr) {
                size_t sparameterSetSize, sparameterSetCount;
                const uint8_t *sparameterSet;
                OSStatus statusCode = CMVideoFormatDescriptionGetHEVCParameterSetAtIndex(format, 1, &sparameterSet, &sparameterSetSize, &sparameterSetCount, 0);
                if (statusCode == noErr) {
                    size_t pparameterSetSize, pparameterSetCount;
                    const uint8_t *pparameterSet;
                    OSStatus statusCode = CMVideoFormatDescriptionGetHEVCParameterSetAtIndex(format, 2, &pparameterSet, &pparameterSetSize, &pparameterSetCount, 0);
                    if (statusCode == noErr) {
                        extraData = [[KFVideoPacketExtraData alloc] init];
                        extraData.vps = [NSData dataWithBytes:vparameterSet length:vparameterSetSize];
                        extraData.sps = [NSData dataWithBytes:sparameterSet length:sparameterSetSize];
                        extraData.pps = [NSData dataWithBytes:pparameterSet length:pparameterSetSize];
                    }
                }
            }
        } else {
            // 其他編碼格式勉盅。
        }
    }
    
    return extraData;
}

- (BOOL)isKeyFrame:(CMSampleBufferRef)sampleBuffer {
    CFArrayRef array = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, true);
    if (!array) {
        return NO;
    }
    
    CFDictionaryRef dic = (CFDictionaryRef)CFArrayGetValueAtIndex(array, 0);
    if (!dic) {
        return NO;
    }
    
    // 檢測(cè) sampleBuffer 是否是關(guān)鍵幀佑颇。
    BOOL keyframe = !CFDictionaryContainsKey(dic, kCMSampleAttachmentKey_NotSync);
    
    return keyframe;
}

- (void)saveSampleBuffer:(CMSampleBufferRef)sampleBuffer {
    // 將編碼數(shù)據(jù)存儲(chǔ)為文件。
    // iOS 的 VideoToolbox 編碼和解碼只支持 AVCC/HVCC 的碼流格式草娜。但是 Android 的 MediaCodec 只支持 AnnexB 的碼流格式挑胸。這里我們做一下兩種格式的轉(zhuǎn)換示范,將 AVCC/HVCC 格式的碼流轉(zhuǎn)換為 AnnexB 再存儲(chǔ)宰闰。
    // 1茬贵、AVCC/HVCC 碼流格式:[extradata]|[length][NALU]|[length][NALU]|...
    // VPS、SPS议蟆、PPS 不用 NALU 來存儲(chǔ),而是存儲(chǔ)在 extradata 中萎战;每個(gè) NALU 前有個(gè) length 字段表示這個(gè) NALU 的長度(不包含 length 字段)咐容,length 字段通常是 4 字節(jié)。
    // 2蚂维、AnnexB 碼流格式:[startcode][NALU]|[startcode][NALU]|...
    // 每個(gè) NAL 前要添加起始碼:0x00000001戳粒;VPS、SPS虫啥、PPS 也都用這樣的 NALU 來存儲(chǔ)蔚约,一般在碼流最前面。
    if (sampleBuffer) {
        NSMutableData *resultData = [NSMutableData new];
        uint8_t nalPartition[] = {0x00, 0x00, 0x00, 0x01};
        
        // 關(guān)鍵幀前添加 vps(H.265)涂籽、sps苹祟、pps。這里要注意順序別亂了评雌。
        if ([self isKeyFrame:sampleBuffer]) {
            KFVideoPacketExtraData *extraData = [self getPacketExtraData:sampleBuffer];
            if (extraData.vps) {
                [resultData appendBytes:nalPartition length:4];
                [resultData appendData:extraData.vps];
            }
            [resultData appendBytes:nalPartition length:4];
            [resultData appendData:extraData.sps];
            [resultData appendBytes:nalPartition length:4];
            [resultData appendData:extraData.pps];
        }
        
        // 獲取編碼數(shù)據(jù)树枫。這里的數(shù)據(jù)是 AVCC/HVCC 格式的。
        CMBlockBufferRef dataBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
        size_t length, totalLength;
        char *dataPointer;
        OSStatus statusCodeRet = CMBlockBufferGetDataPointer(dataBuffer, 0, &length, &totalLength, &dataPointer);
        if (statusCodeRet == noErr) {
            size_t bufferOffset = 0;
            static const int NALULengthHeaderLength = 4;
            // 拷貝編碼數(shù)據(jù)景东。
            while (bufferOffset < totalLength - NALULengthHeaderLength) {
                // 通過 length 字段獲取當(dāng)前這個(gè) NALU 的長度砂轻。
                uint32_t NALUnitLength = 0;
                memcpy(&NALUnitLength, dataPointer + bufferOffset, NALULengthHeaderLength);
                NALUnitLength = CFSwapInt32BigToHost(NALUnitLength);
                
                // 拷貝 AnnexB 起始碼字節(jié)。
                [resultData appendData:[NSData dataWithBytes:nalPartition length:4]];
                // 拷貝這個(gè) NALU 的字節(jié)斤吐。
                [resultData appendData:[NSData dataWithBytes:(dataPointer + bufferOffset + NALULengthHeaderLength) length:NALUnitLength]];
                
                // 步進(jìn)搔涝。
                bufferOffset += NALULengthHeaderLength + NALUnitLength;
            }
        }
        
        [self.fileHandle writeData:resultData];
    }
}

@end

上面是 KFVideoEncoderViewController 的實(shí)現(xiàn),主要分為以下幾個(gè)部分:

  • 1)在 -videoCaptureConfig 中初始化采集配置參數(shù)和措,在 -videoEncoderConfig 中初始化編碼配置參數(shù)庄呈。
    • 這里需要注意的是,由于采集的數(shù)據(jù)后續(xù)用于編碼派阱,我們?cè)O(shè)置了采集的顏色空間格式為默認(rèn)的 kCVPixelFormatType_420YpCbCr8BiPlanarFullRange抒痒。
    • 編碼參數(shù)配置這里,默認(rèn)是在設(shè)備支持 H.265 時(shí),選擇 H.265 編碼故响。
  • 2)在 -videoCapture 中初始化采集器傀广,并實(shí)現(xiàn)了采集會(huì)話初始化成功的回調(diào)、采集數(shù)據(jù)回調(diào)彩届、采集錯(cuò)誤回調(diào)伪冰。
  • 3)在采集會(huì)話初始化成功的回調(diào) sessionInitSuccessCallBack 中,對(duì)采集預(yù)覽渲染視圖層進(jìn)行布局樟蠕。
  • 4)在采集數(shù)據(jù)回調(diào) sampleBufferOutputCallBack 中贮聂,從 CMSampleBufferRef 中取出 CVPixelBufferRef 送給編碼器編碼。
  • 5)在編碼數(shù)據(jù)回調(diào) sampleBufferOutputCallBack 中寨辩,調(diào)用 -saveSampleBuffer: 將編碼數(shù)據(jù)存儲(chǔ)為 H.264/H.265 文件吓懈。
    • 這里示范了將 AVCC/HVCC 格式的碼流轉(zhuǎn)換為 AnnexB 再存儲(chǔ)的過程。

4靡狞、用工具播放 H.264/H.265 文件

完成視頻采集和編碼后耻警,可以將 App Document 文件夾下面的 test.h264test.h265 文件拷貝到電腦上,使用 ffplay 播放來驗(yàn)證一下視頻采集是效果是否符合預(yù)期:

$ ffplay -I test.h264
$ ffplay -I test.h265

關(guān)于播放 H.264/H.265 文件的工具甸怕,可以參考《FFmpeg 工具》第 2 節(jié) ffplay 命令行工具《可視化音視頻分析工具》第 2.1 節(jié) StreamEye甘穿。

參考資料

[1] CMSampleBufferRef: https://developer.apple.com/documentation/coremedia/cmsamplebufferref/

[2] CVPixelBufferRef: https://developer.apple.com/documentation/corevideo/cvpixelbufferref/

[3] CMBlockBuffer: https://developer.apple.com/documentation/coremedia/cmblockbuffer-u9i

[4] CVImageBuffer: https://developer.apple.com/documentation/corevideo/cvimagebuffer-q40

[5] CVPixelBuffer: https://developer.apple.com/documentation/corevideo/cvpixelbuffer-q2e

- 完 -

推薦閱讀

《iOS AVDemo(7):視頻采集》

《iOS 音頻處理框架及重點(diǎn) API 合集》

《iOS AVDemo(6):音頻渲染》

《iOS AVDemo(5):音頻解碼》

《iOS AVDemo(4):音頻解封裝》

《iOS AVDemo(3):音頻封裝》

《iOS AVDemo(2):音頻編碼》

《iOS AVDemo(1):音頻采集》

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市梢杭,隨后出現(xiàn)的幾起案子温兼,更是在濱河造成了極大的恐慌,老刑警劉巖武契,帶你破解...
    沈念sama閱讀 212,454評(píng)論 6 493
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件募判,死亡現(xiàn)場離奇詭異,居然都是意外死亡咒唆,警方通過查閱死者的電腦和手機(jī)兰伤,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,553評(píng)論 3 385
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來钧排,“玉大人敦腔,你說我怎么就攤上這事『蘖铮” “怎么了符衔?”我有些...
    開封第一講書人閱讀 157,921評(píng)論 0 348
  • 文/不壞的土叔 我叫張陵,是天一觀的道長糟袁。 經(jīng)常有香客問我判族,道長,這世上最難降的妖魔是什么项戴? 我笑而不...
    開封第一講書人閱讀 56,648評(píng)論 1 284
  • 正文 為了忘掉前任形帮,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘辩撑。我一直安慰自己界斜,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,770評(píng)論 6 386
  • 文/花漫 我一把揭開白布合冀。 她就那樣靜靜地躺著各薇,像睡著了一般。 火紅的嫁衣襯著肌膚如雪君躺。 梳的紋絲不亂的頭發(fā)上峭判,一...
    開封第一講書人閱讀 49,950評(píng)論 1 291
  • 那天,我揣著相機(jī)與錄音棕叫,去河邊找鬼林螃。 笑死,一個(gè)胖子當(dāng)著我的面吹牛俺泣,可吹牛的內(nèi)容都是我干的疗认。 我是一名探鬼主播,決...
    沈念sama閱讀 39,090評(píng)論 3 410
  • 文/蒼蘭香墨 我猛地睜開眼砌滞,長吁一口氣:“原來是場噩夢(mèng)啊……” “哼侮邀!你這毒婦竟也來了坏怪?” 一聲冷哼從身側(cè)響起贝润,我...
    開封第一講書人閱讀 37,817評(píng)論 0 268
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎铝宵,沒想到半個(gè)月后打掘,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 44,275評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡鹏秋,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,592評(píng)論 2 327
  • 正文 我和宋清朗相戀三年尊蚁,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片侣夷。...
    茶點(diǎn)故事閱讀 38,724評(píng)論 1 341
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡横朋,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出百拓,到底是詐尸還是另有隱情琴锭,我是刑警寧澤,帶...
    沈念sama閱讀 34,409評(píng)論 4 333
  • 正文 年R本政府宣布衙传,位于F島的核電站决帖,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏蓖捶。R本人自食惡果不足惜地回,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 40,052評(píng)論 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧刻像,春花似錦畅买、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,815評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至纹冤,卻和暖如春洒宝,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背萌京。 一陣腳步聲響...
    開封第一講書人閱讀 32,043評(píng)論 1 266
  • 我被黑心中介騙來泰國打工雁歌, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人知残。 一個(gè)月前我還...
    沈念sama閱讀 46,503評(píng)論 2 361
  • 正文 我出身青樓靠瞎,卻偏偏與公主長得像,于是被迫代替她去往敵國和親求妹。 傳聞我的和親對(duì)象是個(gè)殘疾皇子乏盐,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,627評(píng)論 2 350

推薦閱讀更多精彩內(nèi)容