音视频精准截取ffmpeg(十七)

前言

有时会碰到这样的需求场景,对一个视频中的某一段感兴趣,想要精确的截取这一段视频以及对应的音频。例如,有一个25fps的MP4的文件,时长20秒,我想要截取从5秒开始到15秒结束的视频以及对应的音频,这里有两点需要说明:
1、对于视频:开始时间5秒,结束时间15秒。只能做到尽量接近,因为源文件25fps,即每一帧的显示间隔为0.04秒,可能5秒附近的视频帧刚好在5.012秒,最大误差一帧时间差就是1/25==0.04秒,所以4.96-5.04秒范围内都可以。
2、对于音频:比如采样率44100,每一个AVPacket包含1024个采样,那么每一帧的显示时间间隔为1/43≈0.025秒,音频再5秒处的最大误差为0.025秒,所以4.975-5.025秒范围内都可以。

这里以MP4文件为例,假设视频的编码方式为H264,音频为aac,其它格式类似。

实现思路分析

1、在ffmpeg解封装之后得到的AVPacket代表着压缩的音/视频数据,该结构体内有一个字段pts,表示了该音/视频的时间,即前面说的5秒就是要参考这个时间。
2、对于音频来说,只需要依次取出5秒(或者最接近5秒)到15秒处的AVPacket,然后调用ffmpeg封装接口依次写入新的MP4文件即可
3、对于视频来说,写入MP4的第一个AVPacket一定要是I帧(对于H264等有IPB帧改变的编码方式来说,其它编码方式则跟音频一样处理),所以视频的处理分两种情况:

  • 当5秒(或者最接近5秒)处的AVPacket刚好为I帧,那么只需依次取出直到15秒处的AVpacket,然后依次写入MP4文件即可;
  • 当5秒处的AVPacket非I帧,那么就需要往前找出最近一个I帧的AVPacket,然后用这个AVPacket往后依次解码,直到解码出5秒处的AVPacket,然后将解码得到AVFrame重新进行编码为AVPacket(那么5秒处的AVPacket就一定是I帧了)再写入MP4文件,5秒后的视频也按照先解码再编码为AVPacket再写入MP4文件的思路进行

流程图

image.png

关键函数

  • int av_seek_frame(AVFormatContext *s, int stream_index, int64_t timestamp,
    int flags);

调用av_read_frame()函数时,其内部会有一个文件指针(默认情况下指向文件中的首个AVPacket),所以默认时读取的第一个AVPacket就是文件中的首个AVPacket,调用结束后指针再指向下一个AVPacket。此函数的作用就相当于将指针指向指定的key_frame的AVPacket,那么首次调用av_read_frame()函数时将获取的是满足要求的AVPacket,而非文件的第一个AVPacket。

1、stream_index:代表音视频流的索引,如果为-1,那么将对文件的默认流索引进行操作
2、timestamp:移动到指定的时间戳(单位为AVStream.time_base )。如果stream_index为-1,单位为(AV_TIME_BASE)
3、flag:移动参考的方式,取值如下:
#define AVSEEK_FLAG_BACKWARD 1 ///< seek backward
#define AVSEEK_FLAG_BYTE 2 ///< seeking based on position in bytes
#define AVSEEK_FLAG_ANY 4 ///< seek to any frame, even non-keyframes
#define AVSEEK_FLAG_FRAME 8 ///< seeking based on frame number

AVSEEK_FLAG_BACKWARD
基于前面指定的时间戳参数查找,如果timestamp处的AVPacket的key_frame非1,那么就往前找,直到找到最近一个key_frame为1的AVPacket

AVSEEK_FLAG_BYTE
基于位置进行查找

AVSEEK_FLAG_ANY
基于前面指定的时间戳参数进行查找,不管AVPacket是否key_frame为1都返回

AVSEEK_FLAG_FRAME
基于帧编号进行查找

实现代码

#include <stdio.h>
#include <string>
#include <iomanip>
#include <chrono>
#include "CLog.h"
#include <iostream>
extern "C"
{
#include <libavutil/avutil.h>
#include <libavcodec/avcodec.h>
#include <libavutil/timestamp.h>
#include <libavformat/avformat.h>
#include <libavutil/opt.h>
}
using namespace std;

// 打开 .hpp line 33 .cpp line 100
class Cut
{
public:
    /** 实现时间的精准裁剪
     */
    void doCut();
private:
    
    int64_t start_pos;
    int64_t video_start_pts;
    int64_t audio_start_pts;
    int64_t duration;           // 时长 单位秒
    int64_t video_next_pts;
    int64_t audio_next_pts;
    
    // 源文件
    string srcPath;
    // 目标文件
    string dstPath;
    // 视频流索引
    int video_in_stream_index,video_ou_tream_index;
    // 音频流索引
    int audio_in_stream_index,audio_ou_stream_index;
    bool has_writer_header;
    // 用于解封装
    AVFormatContext *in_fmtctx;
    // 用于封装
    AVFormatContext *ou_fmtctx;
    // 视频编码和解码用
    AVFrame *video_de_frame;
    AVFrame *video_en_frame;
    AVCodecContext *video_de_ctx;
    AVCodecContext *video_en_ctx;
    
    void doDecode(AVPacket *inpkt);
    void doEncode(AVFrame *enfram);
    void doWrite(AVPacket *pkt);
    void releasesources();
};
void Cut::releasesources()
{
    if (in_fmtctx) {
        avformat_close_input(&in_fmtctx);
        in_fmtctx = NULL;
    }
    if (ou_fmtctx) {
        avformat_free_context(ou_fmtctx);
        ou_fmtctx = NULL;
    }
    if (video_en_frame) {
        av_frame_unref(video_en_frame);
        video_en_frame = NULL;
    }
    if (video_de_ctx) {
        avcodec_free_context(&video_de_ctx);
        video_de_ctx = NULL;
    }
    if (video_en_ctx) {
        avcodec_free_context(&video_en_ctx);
        video_de_ctx = NULL;
    }
}

void Cut::doWrite(AVPacket *pkt)
{
    
    if (!has_writer_header) {
        
        if (video_en_ctx == NULL) { //  说明没有进行过编码
            // 拷贝源文件视频频编码参数
            AVStream *au_stream = ou_fmtctx->streams[video_ou_tream_index];
            if (avcodec_parameters_copy(au_stream->codecpar,in_fmtctx->streams[video_in_stream_index]->codecpar) < 0) {
                LOGD("avcodec_parameters_copy fail");
                releasesources();
                return;
            }
        }
        
        // 拷贝源文件音频编码参数
        AVStream *au_stream = ou_fmtctx->streams[audio_ou_stream_index];
        if (avcodec_parameters_copy(au_stream->codecpar, in_fmtctx->streams[audio_in_stream_index]->codecpar) < 0) {
            LOGD("avcodec_parameters_copy fail");
            releasesources();
            return;
        }
        
        // 打开io上下文
        if (!(ou_fmtctx->oformat->flags & AVFMT_NOFILE)) {
            if (avio_open2(&ou_fmtctx->pb, dstPath.c_str(), AVIO_FLAG_WRITE, NULL, NULL) < 0) {
                LOGD("avio_open2() fail");
                releasesources();
                return;
            }
        }
                
        // 写入文件头
        if (avformat_write_header(ou_fmtctx, NULL) < 0) {
            LOGD("avformat_write_header()");
            releasesources();
            return;
        }
        
        has_writer_header = true;
    }
    
    
    int ret = 0;
    if (pkt->stream_index == video_ou_tream_index) {
        if (video_en_ctx != NULL) {
            // 写入之前重新转换一下时间戳
            av_packet_rescale_ts(pkt, video_en_ctx->time_base, ou_fmtctx->streams[video_ou_tream_index]->time_base);
        } else {
            av_packet_rescale_ts(pkt, in_fmtctx->streams[video_in_stream_index]->time_base, ou_fmtctx->streams[video_ou_tream_index]->time_base);
        }
        
    } else {
        // 进行时间戳的转换
        av_packet_rescale_ts(pkt, in_fmtctx->streams[audio_in_stream_index]->time_base, ou_fmtctx->streams[audio_ou_stream_index]->time_base);
    }
    
    
//    LOGD("%s pts %d(%s)",pkt->stream_index == video_ou_tream_index?"vi":"au",pkt->pts,av_ts2timestr(pkt->pts, &ou_fmtctx->streams[pkt->stream_index]->time_base));
    if((ret = av_write_frame(ou_fmtctx, pkt)) < 0) {
        LOGD("av_write_frame fail %d",ret);
        releasesources();
        return;
    }
}

void Cut::doDecode(AVPacket *inpkt)
{
    AVCodecContext *decodec_ctx = video_de_ctx;
    AVFormatContext *infmt = in_fmtctx;
    
    // 初始化解码器
    AVStream *stream = infmt->streams[video_in_stream_index];
    if (decodec_ctx == NULL) {
        AVCodec *codec = avcodec_find_decoder(stream->codecpar->codec_id);
        if (!codec) {
            LOGD("avcodec_find_decoder fail");
            releasesources();
            return;
        }
        decodec_ctx = avcodec_alloc_context3(codec);
        if (!decodec_ctx) {
            LOGD("avcodec_alloc_context3() fail");
            releasesources();
            return;
        }
        
        // 设置解码参数;这里直接从对应的AVStream中拷贝过来
        if (avcodec_parameters_to_context(decodec_ctx, stream->codecpar) < 0) {
            LOGD("avcodec_parameters_from_context fail");
            releasesources();
            return;
        }
        
        // 初始化解码器上下文
        if (avcodec_open2(decodec_ctx, codec, NULL) < 0) {
            LOGD("avcodec_open2() fail");
            releasesources();
            return;
        }
        
        // 进行赋值
        video_de_ctx = decodec_ctx;
    }
    
    // 初始化编码器
    if (video_en_ctx == NULL) {
        
        AVCodec *codec = avcodec_find_encoder(video_de_ctx->codec_id);
        if (!codec) {
            LOGD("avcodec avcodec_find_encoder fail");
            releasesources();
            return;
        }
        video_en_ctx = avcodec_alloc_context3(codec);
        if (video_en_ctx == NULL) {
            LOGD("avcodec_alloc_context3 fail");
            releasesources();
            return;
        }
        
        // 设置编码参数;由于不做任何编码方式的改变,这里直接从源拷贝。
        AVStream *stream = in_fmtctx->streams[video_in_stream_index];
        if (avcodec_parameters_to_context(video_en_ctx, stream->codecpar) < 0) {
            LOGD("avcodec_parameters_to_context fail");
            releasesources();
            return;
        }
        // 设置时间基
        video_en_ctx->time_base = stream->time_base;
        video_en_ctx->framerate = stream->r_frame_rate;
        
        // 必须要设置,否则如mp4文件生成后无法看到预览图
        if (ou_fmtctx->oformat->flags & AVFMT_GLOBALHEADER) {
            video_en_ctx->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
        }
        
        // 初始化编码器
        if(avcodec_open2(video_en_ctx,codec,NULL) < 0) {
            LOGD("avcodec_open2() fail");
            releasesources();
            return;
        }
        
        // 设置输出视频流的对应的编码参数
        AVStream *ou_stream = ou_fmtctx->streams[video_ou_tream_index];
        // 设置相关参数
        if(avcodec_parameters_from_context(ou_stream->codecpar, video_en_ctx) < 0) {
            LOGD("avcodec_parameters_from_context fail");
            releasesources();
            return;
        }
    }
    
    if (video_de_frame == NULL) {
        video_de_frame = av_frame_alloc();
    }
    
    // 进行解码
    int ret = 0;
    ret = avcodec_send_packet(decodec_ctx, inpkt);
//    LOGD("inpkt pts %d(%s)",inpkt->pts,av_ts2timestr(inpkt->pts,&stream->time_base));
    while (true) {
        
        ret = avcodec_receive_frame(decodec_ctx, video_de_frame);
        if (ret == AVERROR_EOF) {
            // 解码完毕
            doEncode(NULL);
            break;
        } else if(ret < 0){
            break;
        }
        
        // 解码了一帧数据;解码得到的AVFrame和前面送往解码器的AVpacket是一一对应的,所以两者的pts也是对应的
//        LOGD("deframe pts %d(%s)",(video_de_frame)->pts,av_ts2timestr((video_de_frame)->pts,&stream->time_base));
        if (video_de_frame->pts >= video_start_pts) {
            // 进行编码;编码之前进行数据的重新拷贝
            doEncode(video_de_frame);
        }
    }
}

void Cut::doEncode(AVFrame *enfram)
{
    // 开始进行编码
    if (video_en_frame == NULL) {
        video_en_frame = av_frame_alloc();
        video_en_frame->width = video_en_ctx->width;
        video_en_frame->height = video_en_ctx->height;
        video_en_frame->format = video_en_ctx->pix_fmt;
        av_frame_get_buffer(video_en_frame, 0);
        if(av_frame_make_writable(video_en_frame) < 0) {
            LOGD("av_frame_make_writable fail");
            releasesources();
            return;
        }
    }
    
    // 将要编码的数据拷贝过来
    int ret = 0;
    AVPacket *pkt = av_packet_alloc();
    if (enfram) {
        av_frame_copy(video_en_frame, enfram);
        video_en_frame->pts = video_next_pts * video_en_ctx->time_base.den/video_en_ctx->framerate.num;
           video_next_pts++;
        avcodec_send_frame(video_en_ctx, video_en_frame);
    } else {
        avcodec_send_frame(video_en_ctx, NULL);
    }
    
    while (true) {
        ret = avcodec_receive_packet(video_en_ctx, pkt);
        if (ret < 0) {
            break;
        }
        
        // 编码成功;写入文件
        LOGD("encode pts %d(%s)",pkt->pts,av_ts2timestr(pkt->pts, &(in_fmtctx->streams[video_in_stream_index]->time_base)));
        pkt->stream_index = video_ou_tream_index;
        doWrite(pkt);
    }
    
}

void Cut::doCut()
{
    string curFile(__FILE__);
    unsigned long pos = curFile.find("2-video_audio_advanced");
    if (pos == string::npos) {
        LOGD("can not find file");
        return;
    }
    
    // 只考虑同一种容器的内容剪切
    string srcDic = curFile.substr(0,pos) + "filesources/";
    srcPath = srcDic + "test_1280x720_3.mp4";
    dstPath = srcDic + "1-cut-test_1280x720_3.mp4";
    
    // 截取起始时间 格式 hh:mm:ss
    string start = "00:00:15";
    video_start_pts = 0;
    audio_start_pts = 0;
    duration = 5;        // 时长 单位秒
    
    // 那么最终截取的文件长度将从start处开始之后的duration秒;
    start_pos += stoi(start.substr(0,2))*3600;
    start_pos += stoi(start.substr(3,2))*60;
    start_pos += stoi(start.substr(6,2));
    
    has_writer_header = false;
    in_fmtctx = NULL;
    ou_fmtctx = NULL;
    video_de_frame = NULL;
    video_en_frame = NULL;
    video_de_ctx = NULL;
    video_en_ctx = NULL;
    
    int ret = 0;
    audio_in_stream_index = -1;
    video_in_stream_index = -1;
    audio_ou_stream_index = -1;
    video_ou_tream_index = -1;
    if ((ret = avformat_open_input(&in_fmtctx, srcPath.c_str(), NULL, NULL)) < 0) {
        LOGD("avformat_open_input fail");
        releasesources();
        return;
    }
    if ((ret = avformat_find_stream_info(in_fmtctx, NULL)) < 0) {
        LOGD("avformat_find_stream_info fail");
        releasesources();
        return;
    }
    
    // 获取输入流的索引
    for (int i=0; i<in_fmtctx->nb_streams; i++) {
        
        AVStream *stream = in_fmtctx->streams[i];
        if (stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
            video_in_stream_index = i;
        }
        
        if (stream->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) {
            audio_in_stream_index = i;
        }
    }
    
    // 将视频流指针移到指定的位置,那么后面调用av_read_frame()函数时将从此位置开始读取
    if (video_in_stream_index != -1) {
        AVStream *stream = in_fmtctx->streams[video_in_stream_index];
        /** 移动当前读取指针到指定的位置
         *  参数2:指定移动的流索引,可以为-1,如果为-1那么将选择in_fmtctx的默认索引
         *  参数3:指定移动的时间位置(时间戳),基于指定流的时间基。如果参数二为-1,那么这里基于AV_TIME_BASE时间基。
         *  参数4:指定移动的算法;AVSEEK_FLAG_BACKWARD:代表基于参数3指定的时间戳往前找,直到找到I帧。
         */
        if((ret = av_seek_frame(in_fmtctx,video_in_stream_index,start_pos*stream->time_base.den/stream->time_base.num,AVSEEK_FLAG_BACKWARD)) < 0){
            LOGD("vdio av_seek_frame fail %d",ret);
            releasesources();
            return;
        }
    }

    // 将音频流指针移到指定的位置
    if (audio_in_stream_index != -1) {
        AVStream *stream = in_fmtctx->streams[audio_in_stream_index];
        if ((ret = av_seek_frame(in_fmtctx, audio_in_stream_index, start_pos*stream->time_base.den/stream->time_base.num, AVSEEK_FLAG_BACKWARD) < 0)) {
            LOGD("audio av_seek_frame %d",ret);
            releasesources();
            return;
        }
    }
    
    // 打开封装器
    if(avformat_alloc_output_context2(&ou_fmtctx, NULL, NULL, dstPath.c_str()) < 0) {
        LOGD("avformat_alloc_output_context2 fail");
        releasesources();
        return;
    }
    
    // 添加对应的音视频流;
    if (video_in_stream_index != -1) {
        AVStream *stream = avformat_new_stream(ou_fmtctx, NULL);
        video_ou_tream_index = stream->index;
    }
            
    if (audio_in_stream_index != -1) {
        AVStream *stream = avformat_new_stream(ou_fmtctx, NULL);
        audio_ou_stream_index = stream->index;
    }
    
    
    // 读取AVPacket
    AVPacket *in_packt = av_packet_alloc();
    bool first_video_packet = true;
    bool video_need_decode = true;
    bool flag = false;
    bool video_end = false;
    bool audio_end = false;
    while (av_read_frame(in_fmtctx, in_packt) >= 0) {
        
        // 达到时间点了 则终止
        if (video_end && audio_end) {
            break;
        }
        
        AVStream *stream = in_fmtctx->streams[in_packt->stream_index];
        if (in_packt->stream_index == video_in_stream_index) {
            // 对于视频来说,起始时刻对应的AVPacket可能不是I帧,所以就需要先解码再编码
            
            // 允许有5帧的时间差
            int64_t max_frame = 5;
            int64_t delt_pts = av_rescale_q(max_frame, (AVRational){1,stream->r_frame_rate.num},stream->time_base);
            video_start_pts = start_pos * stream->time_base.den/stream->time_base.num;
            int64_t end_pts = (start_pos + duration) * stream->time_base.den/stream->time_base.num;
            if (first_video_packet && video_start_pts - in_packt->pts <= delt_pts) {
                video_need_decode = false;
                LOGD("is key frame pts %s",av_ts2timestr(in_packt->pts,&stream->time_base));
            }
            
            if (end_pts <=in_packt->pts) {
                doDecode(NULL);
                video_end = true;
                continue;
            }
            

            if (video_need_decode) {    // 如果需要重新编解码 则进入重新编解码的流程
                doDecode(in_packt);
            } else {
                in_packt->pts -= video_start_pts;
                in_packt->dts -= video_start_pts;
                in_packt->stream_index = video_ou_tream_index;
                doWrite(in_packt);
            }
            
            first_video_packet = false;
            flag = true;
        }
        
        // 对于音频来说
        if (in_packt->stream_index == audio_in_stream_index && flag) {
            audio_start_pts = (start_pos) * stream->time_base.den/stream->time_base.num;
            int64_t end_pts = (start_pos + duration) * stream->time_base.den / stream->time_base.num;
            if (end_pts <= in_packt->pts) {
                audio_end = true;
                continue;;
            }
            /** 遇到问题:音频正常播放,视频只是播放了很短的几帧画面
             *  分析原因:由于pkt的时间戳没有减去起始时间导致了音视频的时间戳错乱
             *  解决方案:音频时间戳减去起始时间即可
             */
            in_packt->pts -= audio_start_pts;
            in_packt->dts -= audio_start_pts;
            in_packt->stream_index = audio_ou_stream_index;
            doWrite(in_packt);
        }
    }
    
    // 写入文件尾部
    av_write_trailer(ou_fmtctx);
    LOGD("结束写入文件..");
    // 释放资源
    releasesources();
}

遇到问题

1、音频正常播放,视频只是播放了很短的几帧画面
分析原因:由于pkt的时间戳没有减去起始时间导致了音视频的时间戳错乱
解决方案:音频时间戳减去起始时间即可

项目地址

https://github.com/nldzsz/ffmpeg-demo

位于cppsrc目录下Cut.hpp/Cut.cpp文件中

项目下示例可运行于iOS/android/mac平台,工程分别位于demo-ios/demo-android/demo-mac三个目录下,可根据需要选择不同平台

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342