FFmpeg小白学习记录(五)组合视频流和音频流

组合视频流和音频流

通过之前视频流与音频流编解码的学习,我们可以做到将视频流与音频流数据抽离出来,并将这些数据编码为对应的视频或音频。但往往一个多媒体文件中既包含音频也包含视频,所以本次我们学习如何通过 FFmpeg 将 图片+PCM -> mp4 文件

图片+PCM -> mp4 文件,就是将之前 图片->MP4 和 PCM->MP3 的流程组合起来,只不过要注意视频与音频的pts值设置

因为代码量较多,这里选择划分为各个模块进行讲解:基础模块、视频模块、音频模块

基础模块

在基础模块中主要实现编码流程中通用的部分,其余与音频、视频有关的函数均由视频模块、音频模块进行调用

extern"C" {
#include "libavcodec/avcodec.h"
#include "libavformat/avformat.h"
#include"libswresample/swresample.h"
#include "libswscale/swscale.h"
#include "libavutil/imgutils.h"
}
#include <iostream>
#include <opencv2/opencv.hpp>
using namespace std;
using namespace cv;

//通过位运算实现选择测试功能模块 1-测试视频 2-测试音频 3-音视频
#define mode 3
#define VideoMode 0x01
#define AudioMode 0x02

void rgbPcmtoMp4() {
    int ret;

    //声明所需的变量名
    AVFormatContext* dstFmtCtx = NULL;
    AVCodec* videoCodec = NULL, * audioCodec = NULL;
    AVCodecContext* vCodecCtx = NULL, * aCodecCtx = NULL;
    AVStream* vStream = NULL, * aStream = NULL;
    //PCM文件
    const char* audioFile = "result.pcm";
    //最终输出的文件名
    const char* dstFile = "result.mp4";
    FILE* file=NULL;

    do {
        //创建输出结构上下文 AVFormatContext,会根据文件后缀创建相应的初始化参数
        ret = avformat_alloc_output_context2(&dstFmtCtx, NULL, NULL, dstFile);
        if (ret < 0) {
            cout << "Could not create output context" << endl;
            break;
        }

        //打开文件
        ret = avio_open(&dstFmtCtx->pb, dstFile, AVIO_FLAG_READ_WRITE);
        if (ret < 0) {
            cout << "Could not open output file" << endl;
            break;
        }
        
#if mode & VideoMode
        //添加一个视频流
        vCodecCtx = addVideoStream(dstFmtCtx, &vStream);
        if (vCodecCtx == NULL) {
            cout << "Could not add video stream" << endl;
            break;
        }
#endif

#if mode & AudioMode
        //添加一个音频流
        aCodecCtx = addAudioStream(dstFmtCtx, &aStream);
        if (aCodecCtx == NULL) {
            cout << "Could not add audio stream" << endl;
            break;
        }
#endif
        
        //写入文件头信息
        ret = avformat_write_header(dstFmtCtx, NULL);
        if (ret != AVSTREAM_INIT_IN_WRITE_HEADER) {
            cout << "Write file header fail" << endl;
            break;
        }
        
        //打印流信息
        av_dump_format(dstFmtCtx, 0, dstFile, 1);

#if mode & VideoMode
        //编码视频流
        ret=encodeVideo(dstFmtCtx,vCodecCtx,vStream);
        if (ret<0) {
            cout << "encodeVideo fail" << endl;
            break;
        }
#endif

#if mode & AudioMode
        fopen_s(&file,audioFile,"rb");
        if (file==NULL) {
            cout << "open audio file fail" << endl;
            break;
        }
        //编码音频流
        ret=encodeAudio(dstFmtCtx,aCodecCtx,aStream, file);
        if (ret < 0) {
            cout << "encodeAudio fail" << endl;
            break;
        }
#endif
        //向文件中写入文件尾部标识,并释放该文件
        av_write_trailer(dstFmtCtx);

    } while (0);

    //释放资源
    if (dstFmtCtx) {
        avformat_free_context(dstFmtCtx);
        avio_closep(&dstFmtCtx->pb);
    }
    if (vCodecCtx) avcodec_free_context(&vCodecCtx);
    if (aCodecCtx) avcodec_free_context(&aCodecCtx);
    if (file) fclose(file);
}

代码中的方法在之前都有介绍过,这里不再阐述

视频模块

视频模块中又分为 初始化编码 两个部分,视频模块中与之前的视频流编码没有区别,可以当作复习再看一遍

初始化

初始化中主要工作为为文件添加一个视频流,并设置相关的参数,为之后的编码作准备

//宽、高、每幅图像显示的帧数
int w = 600, h = 900, perFrameCnt = 25;

/**
* 添加一个视频流,并初始化AVCodecContext和AVStream
* @param fmtCtx AVFormatContext结构体指针
* @param stream Straem指针的指针,用于在函数中进行赋值
* @return 成功返回创建的AVCodecContext指针,若失败则返回NULL
*/
AVCodecContext* addVideoStream(AVFormatContext* fmtCtx, AVStream** stream) {
    int ret = 0;
    AVCodecContext* codecCtx = NULL;

    do {
        //查找编码器
        AVCodec* codec = avcodec_find_encoder(fmtCtx->oformat->video_codec);
        if (codec == NULL) {
            ret = -1;
            cout << "Cannot find any endcoder" << endl;
            break;
        }

        //申请编码器上下文结构体
        codecCtx = avcodec_alloc_context3(codec);
        if (codecCtx == NULL) {
            ret = -1;
            cout << "Cannot alloc AVCodecContext" << endl;
            break;
        }

        //创建视频流
        *stream = avformat_new_stream(fmtCtx, codec);
        if (*stream == NULL) {
            ret = -1;
            cout << "failed create new video stream" << endl;
            break;
        }

        //为视频流设置参数
        AVCodecParameters* param = (*stream)->codecpar;
        param->width = w;
        param->height = h;
        param->codec_type = AVMEDIA_TYPE_VIDEO;
        param->format = AV_PIX_FMT_YUV420P;     //视频帧格式:YUV420P
        param->bit_rate = 400000;               //码率

        //将参数传给解码器上下文
        ret = avcodec_parameters_to_context(codecCtx, param);
        if (ret < 0) {
            cout << "Cannot copy para to context" << endl;
            break;
        }

        // 某些封装格式必须要设置该标志,否则会造成封装后文件中信息的缺失,如:mp4
        if (fmtCtx->oformat->flags & AVFMT_GLOBALHEADER) {
            codecCtx->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
        }

        //gop表示多少个帧中存在一个关键帧
        codecCtx->gop_size = 12;
        codecCtx->qmin = 10;
        codecCtx->qmax = 51;
        codecCtx->qcompress = (float)0.6;
        //设置时间基
        codecCtx->time_base = AVRational{ 1,25 };

        //打开解码器
        ret = avcodec_open2(codecCtx, codec, NULL);
        if (ret < 0) {
            cout << "Open encoder failed" << endl;
            break;
        }

        //再将codecCtx设置的参数传给param,用于写入头文件信息
        ret = avcodec_parameters_from_context(param, codecCtx);
        if (ret < 0) {
            cout << "Cannot copy para from context" << endl;
            break;
        }
    } while (0);

    //出错则释放资源
    if (ret < 0) {
        if (codecCtx) avcodec_free_context(&codecCtx);
        return NULL;
    }

    return codecCtx;
}
编码
//执行具体的编码操作
int encodeVideoFrame(AVFormatContext* fmtCtx, AVCodecContext* codecCtx, AVFrame* frame, AVPacket* pkt, AVStream* vStream) {
    int ret = 0;
    //将frame发送至编码器进行编码,codecCtx中保存了codec
    //当frame为NULL时,表示将缓冲区中的数据读取出来
    if (avcodec_send_frame(codecCtx, frame) >= 0) {
        //接收编码后形成的packet
        while (avcodec_receive_packet(codecCtx, pkt) >= 0) {
            //设置对应的流索引
            pkt->stream_index = vStream->index;
            pkt->pos = -1;
            //转换pts至基于时间基的pts,可以理解为视频帧显示的时间戳
            av_packet_rescale_ts(pkt, codecCtx->time_base, vStream->time_base);
            cout << "encoder success pts:" << pkt->pts<< endl;
            
            //将包数据写入文件中,该方法不用使用 av_packet_unref
            ret = av_interleaved_write_frame(fmtCtx, pkt);
            if (ret < 0) {
                char errStr[256];
                av_strerror(ret, errStr, 256);
                cout << "error is:" << errStr << endl;
                break;
            }
        }
    }
    return ret;
}

int encodeVideo(AVFormatContext* fmtCtx, AVCodecContext* codecCtx, AVStream* stream) {
    int ret = 0;
    AVFrame* rgbFrame = NULL, * yuvFrame = NULL;
    AVPacket* pkt = av_packet_alloc();

    do {
        //申请Frame
        rgbFrame = av_frame_alloc();
        yuvFrame = av_frame_alloc();

        //获取相关参数,为之后调用av_frame_get_buffer,需要给Frame赋值
        //视频帧需要设置格式、宽、高
        int w = codecCtx->width, h = codecCtx->height;
        AVPixelFormat format = codecCtx->pix_fmt;

        yuvFrame->width = w;
        yuvFrame->height = h;
        yuvFrame->format = format;

        rgbFrame->width = w;
        rgbFrame->height = h;
        //这里使用BGR类型是因为OpenCV读取图片其格式为BGR
        rgbFrame->format = AV_PIX_FMT_BGR24;

        //为视频数据分配新的缓冲区,但有几个注意点
        ret = av_frame_get_buffer(rgbFrame, 0);
        //bgr格式数据是连续的,所以linesize[0]应等于格式所占字节数*图像宽
        //但av_frame_get_buffer方法所得到的linesize[0]会大于字节数*图像宽
        //需要我们手动设置linesize[0]
        rgbFrame->linesize[0] = w*3;

        if (ret < 0) {
            cout << "rgbFrame get buffer fail" << endl;;
            break;
        }
        
        //为视频数据分配新的缓冲区
        ret = av_frame_get_buffer(yuvFrame, 0);
        if (ret < 0) {
            cout << "yuvFrame get buffer fail" << endl;;
            break;
        }

        //设置BGR数据转换为YUV的SwsContext
        SwsContext* imgCtx = sws_getContext(w, h, (AVPixelFormat)rgbFrame->format,
            w, h, format, 0, NULL, NULL, NULL);

        //FFmpeg读取图片流程过于复杂,所以使用OpenCV读取图像
        //想了解FFmpeg如何读取图像的,可以看看 视频流编码流程(三)
        Mat img;
        //用于编码为视频帧的图像
        char imgPath[] = "img/p0.jpg";
        //获取对应一帧BGR图像所需的字节数
        int size = av_image_get_buffer_size((AVPixelFormat)rgbFrame->format,w,h,1);

        for (int i = 0; i < 7; i++) {
            imgPath[5] = '0' + i;
            img = imread(imgPath);
            //BGR数据填充至图像帧
            memcpy(rgbFrame->data[0], img.data, size);

            //进行图像格式转换
            sws_scale(imgCtx,
                rgbFrame->data,
                rgbFrame->linesize,
                0,
                h,
                yuvFrame->data,
                yuvFrame->linesize);

            for (int j = 0; j < perFrameCnt; j++) {
                //设置 pts 值,用于度量解码后视频帧位置
                yuvFrame->pts = i * perFrameCnt + j;
                //将编码过程抽离为一个函数
                ret = encodeVideoFrame(fmtCtx, codecCtx, yuvFrame, pkt, stream);
                if (ret < 0) {
                    cout << "Do encodeVideoFrame function Fail" << endl;
                    break;
                }
            }
            if (ret < 0) break;
        }
        if (ret < 0) break;
        
        //刷新解码缓存区
        ret = encodeVideoFrame(fmtCtx, codecCtx, NULL, pkt, stream);
        if (ret < 0) {
            cout << "Do encodeVideoFrame function Fail" << endl;
            break;
        }
    } while (0);
    
    //释放使用的资源
    if (rgbFrame) av_frame_free(&rgbFrame);
    if (yuvFrame) av_frame_free(&yuvFrame);
    av_packet_free(&pkt);

    return ret;
}

音频模块

音频模块中也分为 初始化编码 两个部分,音频模块大致流程与之前的音频流编码没有区别,只是在音频帧pts设置上需要做一些改变,因为之前编码音频流只需要保证按照顺序播放即可(即pts呈递增趋势),但是加入了画面,就要保证音频与视频播放同步

初始化

初始化中主要工作为为文件添加一个音频流,并设置相关的参数,为之后的编码作准备

static int select_bit_rate(AVCodec* codec) {
    // 对于不同的编码器最优码率不一样,单位bit/s;对于mp3来说,192kbps可以获得较好的音质效果。
    int bit_rate = 64000;
    AVCodecID id = codec->id;
    if (id == AV_CODEC_ID_MP3) {
        bit_rate = 192000;
    }
    else if (id == AV_CODEC_ID_AC3) {
        bit_rate = 192000;
    }

    return bit_rate;
}

/**
* 添加一个音频流,并初始化AVCodecContext和AVStream
* @param fmtCtx AVFormatContext结构体指针
* @param stream Straem指针的指针,用于在函数中进行赋值
* @return 成功返回创建的AVCodecContext指针,若失败则返回NULL
*/
AVCodecContext* addAudioStream(AVFormatContext* fmtCtx, AVStream** stream) {
    int ret = 0;
    AVCodecContext* codecCtx = NULL;

    do {
        //查找编码器
        AVCodec* codec = avcodec_find_encoder(fmtCtx->oformat->audio_codec);
        if (codec == NULL) {
            ret = -1;
            cout << "Cannot find any audio endcoder" << endl;
            break;
        }

        //申请编码器上下文结构体
        codecCtx = avcodec_alloc_context3(codec);
        if (codecCtx == NULL) {
            ret = -1;
            cout << "Cannot alloc context" << endl;
            break;
        }
        
        //设置相关参数
        codecCtx->bit_rate = select_bit_rate(codec);    // 码率
        codecCtx->sample_rate = 48000;                  // 采样率
        codecCtx->sample_fmt = AV_SAMPLE_FMT_FLTP;      // 采样格式
        codecCtx->channel_layout = AV_CH_LAYOUT_STEREO; // 声道格式
        codecCtx->channels = 2;                         // 声道数

        //创建音频流
        *stream = avformat_new_stream(fmtCtx, codec);
        if (*stream == NULL) {
            ret = -1;
            cout << "failed create new audio stream" << endl;
            break;
        }

        //打开解码器
        ret = avcodec_open2(codecCtx, codec, NULL);
        if (ret < 0) {
            cout << "avcodec_open2 fail" << endl;
            break;
        }

        AVCodecParameters* param = (*stream)->codecpar;
        param->codec_type = AVMEDIA_TYPE_AUDIO;
        
        // 将codecCtx设置的参数传给param,用于写入头文件信息
        ret = avcodec_parameters_from_context(param, codecCtx);
        if (ret < 0) {
            cout << "Cannot copy para from context" << endl;
            break;
        }
    } while (0);

    //出错则释放资源
    if (ret < 0) {
        if (codecCtx) avcodec_free_context(&codecCtx);
        return NULL;
    }

    return codecCtx;
}
编码
//执行具体的编码操作
int encodeAudioFrame(AVFormatContext* fmtCtx, AVCodecContext* codecCtx, AVFrame* frame, AVPacket* pkt, AVStream* aStream) {
    int ret = 0;
    //将frame发送至编码器进行编码
    if (avcodec_send_frame(codecCtx, frame) >= 0) {
        //接收编码后形成的packet
        while (avcodec_receive_packet(codecCtx, pkt) >= 0) {
            //设置对应的流索引
            pkt->stream_index = aStream->index;
            pkt->pos = -1;

            //转换pts至基于时间基的pts
            av_packet_rescale_ts(pkt, codecCtx->time_base, aStream->time_base);
            cout << "encoder audio success pts:" << pkt->pts << endl;

            //将包数据写入文件中,该方法不用使用 av_packet_unref
            ret = av_interleaved_write_frame(fmtCtx, pkt);
            if (ret < 0) {
                char errStr[256];
                av_strerror(ret, errStr, 256);
                cout << "error is:" << errStr << endl;
                break;
            }
        }
    }
    return ret;
}

int encodeAudio(AVFormatContext* fmtCtx, AVCodecContext* codecCtx, AVStream* stream, FILE* file) {
    int ret = 0;
    AVFrame* srcFrame = NULL, * dstFrame = NULL;
    AVPacket* pkt = av_packet_alloc();

    do {
        /*设置音频帧参数
        * PCM文件中的参数,PCM文件不携带音频格式信息,需要我们自己记录相关的参数
        * 并为srcFrame的参数赋值
        * 音频帧调用av_frame_get_buffer分配缓冲区,需要format、nb_samples、channel_layout
        */
        
        srcFrame = av_frame_alloc();
        srcFrame->format = AV_SAMPLE_FMT_FLT;
        srcFrame->nb_samples = 1152;
        srcFrame->channel_layout = AV_CH_LAYOUT_STEREO;
        srcFrame->channels = 2;
        srcFrame->sample_rate = 44100;

        dstFrame = av_frame_alloc();
        dstFrame->format = codecCtx->sample_fmt;
        dstFrame->nb_samples = codecCtx->frame_size;
        dstFrame->channel_layout = codecCtx->channel_layout;
        dstFrame->channels = codecCtx->channels;
        dstFrame->sample_rate = codecCtx->sample_rate;

        //为音频数据分配新的缓冲区
        ret = av_frame_get_buffer(srcFrame, 0);
        if (ret < 0) {
            cout << "frame get buffer fail" << endl;;
            break;
        }
        //使得srcFrame可写
        av_frame_make_writable(srcFrame);

        ret = av_frame_get_buffer(dstFrame, 0);
        if (ret < 0) {
            cout << "frame get buffer fail" << endl;;
            break;
        }
        av_frame_make_writable(dstFrame);

        // 申请进行对应转换所需的SwrContext
        SwrContext* swrCtx = swr_alloc_set_opts(NULL,
            codecCtx->channel_layout, codecCtx->sample_fmt, codecCtx->sample_rate,
            srcFrame->channel_layout, (enum AVSampleFormat)srcFrame->format, srcFrame->sample_rate,
            0, NULL);

        int audioPts = 0;
        // 获取一帧源数据的字节大小
        int require_size = av_samples_get_buffer_size(NULL, srcFrame->channels, srcFrame->nb_samples, (AVSampleFormat)srcFrame->format, 0);
        while (1) {
            ret = fread(srcFrame->data[0], 1, require_size, file);
            if (ret < 0) {
                cout << "fread error" << endl;
                break;
            }
            // 进行格式转换
            ret = swr_convert_frame(swrCtx, dstFrame, srcFrame);
            if (ret < 0) {
                cout << "swr_convert_frame fail " << ret << endl;
                continue;
            }
            /*
             * 音频:pts = (timebase.den/sample_rate)*[nb_samples*index]
             * index为当前第几个音频AVFrame(索引从0开始),nb_samples为每个AVFrame中的采样数
             * 但经过swr_convert_frame后,nb_samples会修改并且大小会改变
             * 需要一个数记录,而(timebase.den/sample_rate)中的部分会在
             * av_packet_rescale_ts 函数中进行转换
            */
            dstFrame->pts = audioPts;
            audioPts += dstFrame->nb_samples;
            ret = encodeAudioFrame(fmtCtx, codecCtx, dstFrame, pkt, stream);
            if (ret < 0) {
                cout << "Do encodeVideoFrame function Fail 1" << endl;
                break;
            }
            if (feof(file)) {
                break;
            }
        }
        if (ret < 0) break;

        //刷新转换缓存区
        while (swr_convert_frame(swrCtx, dstFrame, NULL) == 0) {
            if (dstFrame->nb_samples != 0) {
                dstFrame->pts = audioPts;
                audioPts += dstFrame->nb_samples;
                ret = encodeAudioFrame(fmtCtx, codecCtx, dstFrame, pkt, stream);
                if (ret < 0) {
                    cout << "Do encodeVideoFrame function Fail 2" << endl;
                    break;
                }
            }
            else {
                break;
            }
        }

        //刷新编码缓冲区
        ret = encodeAudioFrame(fmtCtx, codecCtx, NULL, pkt, stream);
        if (ret < 0) {
            cout << "Do encodeVideoFrame function Fail 3" << endl;
            break;
        }
    } while (0);

    //释放资源
    av_packet_free(&pkt);
    av_frame_free(&srcFrame);
    av_frame_free(&dstFrame);

    return ret;
}

实现效果

通过代码可以知道,案例中编写视频长度应该只有7s,而音频长度取决于PCM文件大小,这里使用的PCM是由1分31秒的mp3文件转换成的,所以音频长度 > 视频长度,最终呈现效果为视频与音频同步播放,7s之后画面呈现视频最后一帧图像,mp4文件长度为音频长度——1分31秒

现在我们已经可以将原始数据转换为mp4文件

加上视频文件的解码,我们就可以做到将一个多媒体文件中的视频流和另一个多媒体文件中的音频流组合在一起形成一个新的多媒体文件了

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

推荐阅读更多精彩内容