iOS視頻壓縮筆記

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:

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幀。

  1. I幀即Intra-coded picture(幀內編碼圖像幀),不參考其他圖像幀,只利用本幀的信息進行編碼
  2. P幀即Predictive-codedPicture(預測編碼圖像幀),利用之前的I幀或P幀,采用運動預測的方式進行幀間預測編碼
  3. 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 profileMain Profile level改為High Profile level

現在我們已經知道要做什么了,那么怎么做呢?

5.解決方法

由于之前的AVAssetExportSession不能指定碼率和Format profile,我們這里需要使用AVAssetReaderAVAssetWriter

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左右。結合視頻效果,這個壓縮成果我們還是很滿意的。


壓縮后的文件

下面是視頻部分畫面截圖的效果:

原視頻,34m
原來的MediumQuality壓縮效果,2.6m
原來的720p壓縮效果,32.5m
優化后的720p的壓縮效果,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);
            }

修改后的 SDAVAssetExportSession

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,533評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,055評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,365評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,561評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,346評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,889評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,978評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,118評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,637評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,558評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,739評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,246評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,980評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,619評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,347評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,702評論 2 370