FFMPEG完美入门资料---003---FFmpeg 架构

写在前面

如果对FFmpeg有需要更多了解的请订阅我的专题:音视频专辑

3.1 FFmpeg 文件结构

  • libavformat
    主要存放 ffmpeg 支持的 各种编解码器 的实现及 ffmpeg 编解码 功能相关的数
文件简要说明
allcodecs.c简单的注册类函数
avcodec.h编解码相关结构体定义和函数原型声明
dsputil.c限幅数组初始化
dsputil.h限幅数组声明
imgconvert.c颜色空间转换相关函数实现
imgconvert_template.h颜色空间转换相关结构体定义和函数声明
utils_codec.c一些解码相关的工具类函数的实现
mpeg4audio.cmpeg4 音频编解码器的函数实现
mpeg4audio.hmpeg4 音频编解码器的函数声明
mpeg4data.hmpeg4 音视频编解码器的公用的函数声明及数据结构定义
mpeg4video.cmpeg4 视频编解码器的函数实现
mpeg4video.hmpeg4 视频编解码器的函数的声明及先关数据结构的定义
mpeg4videodec.cmpeg4 视频解码器的函数实现
mpeg4videoenc.cmpeg4 视频编码器的函数实现
  • libavformat
    本目录主要存 放 FFMPEG 支持 的各种媒体格 式 MUXER/DEMUXER 和数据流协议 的定义和实现 文件以及 ffmpeg 解复用 相关的数据结 构及函数定义
文件简要说明
allformats.c简单注册类函数
avformat.h文件和媒体格式相关函数声明和数据结 构定义
avio.c无缓冲 IO 相关函数实现
avio.h无缓冲 IO 相关结构定义和函数声明
aviobuf.c有缓冲数据 IO 相关函数实现
cutils.c简单的字符串操作函数
utils_format.c文件和媒体格式相关的工具函数的实现
file.c文件 io 相关函数
……其他相关媒体流 IO 的函数和数据结构实 现文件。 如:rtsp、http 等。
avi.cAVI 格式的相关函数定西
avi.hAVI 格式的相关函数声明及数据结构定义
avidec.cAVI 格式 DEMUXER 相关函数定义
avienc.cAVI 格式 MUXER 相关函数定义
……其他媒体格式的 muxer/demuxer 相关函 数及数据结构定义和声明文件

*libavutil
主要存放 ffmpeg 工具类 函数的定义

avutil.h简单的像素格式宏定义
bswap.h简单的大小端转换函数的实现
commom.h公共的宏定义和简单函数的实现
mathematics.c数学运算函数实现
rational.h分数相关表示的函数实现

3.2 I\O 模块分析

3.2.1 概述

ffmpeg 项目的数据 IO 部分主要是在 libavformat 库中实现, 某些对于内存的操作部分在 libavutil 库中。数据 IO 是基于文件格式(Format)以及文件传输协议(Protocol) 的, 与具体的编解码标准无关。 ffmpeg 工程转码时数据 IO 层次关系如图所示:
《FFMPEG完美入门资料---003---FFmpeg 架构》 ffmpeg 转码数据 IO 流程

对于上面的数据 IO 流程, 具体可以用下面的例子来说明, 我们从一个 http 服务器 获取音视频数据, 格式是 flv 的, 需要通过转码后变成 avi 格式, 然后通过 udp 协议进 行发布。 其过程就如下所示:

  • 1、读入 http 协议数据流, 根据 http 协议获取真正的文件数据(去除无关报文信 息);
  • 2、根据 flv 格式对数据进行解封装;
  • 3、读取帧进行转码操作;
  • 4、按照目标格式 avi 进行封装;
  • 5、通过 udp 协议发送出去。

3.2.2 相关数据结构介绍

在 libavformat 库中与数据 IO 相关的数据结构主要有 URLProtocol、URLContext、ByteIOContext、AVFormatContext 等, 各结构之间的关系如图所示。

《FFMPEG完美入门资料---003---FFmpeg 架构》 libavformat 库中 IO 相关数据结构之间的关系

1、URLProtocol 结构

表示广义的输入文件, 该结构体提供了很多的功能函数, 每一种广义的输入文件 (如:file、pipe、tcp、rtp 等等)对应着一个 URLProtocol 结构,在 av_register_all() 中将该结构体初始化为一个链表, 表头为 avio.c 里的 URLProtocol *first_protocol = NULL;保存所有支持的输入文件协议, 该结构体的定义如下:

typedef struct URLProtocol 
{ 
const char *name; 
int (*url_open)(URLContext *h, const char *url, int flags); 
int (*url_read)(URLContext *h, unsigned char *buf, int size);
int (*url_write)(URLContext *h, const unsigned char *buf, int size); 
int64_t (*url_seek)(URLContext *h, int64_t pos, int whence); 
int (*url_close)(URLContext *h); 
struct URLProtocol *next; 
int (*url_read_pause)(URLContext *h, int pause);
int64_t (*url_read_seek)(URLContext *h, int stream_index,int64_t timestamp, int flags);
int (*url_get_file_handle)(URLContext *h);
int priv_data_size;
const AVClass *priv_data_class; 
int flags;
int (*url_check)(URLContext *h, int mask);
} URLProtocol;

注意到, URLProtocol 是一个链表结构, 这是为了协议的统一管理, ffmpeg 项目中 将所有的用到的协议都存放在一个全局变量 first_protocol 中, 协议的注册是在 av_register_all 中完成的, 新添加单个协议可以调用 av_register_protocol2 函数实 现。 而协议的注册就是将具体的协议对象添加至 first_protocol 链表的末尾。

URLProtocol 在各个具体的文件协议中有一个具体的实例,如在 file 协议中定义为:

URLProtocol ff_file_protocol = {
 .name = " file" ,
 .url_open = file_open,
 .url_read = file_read,
 .url_write = file_write,
 .url_seek = file_seek,
 .url_close = file_close,
 .url_get_file_handle = file_get_handle, .
 .url_check = file_check,
};

2、URLContext 结构

URLContext 提供了与当前打开的具体的文件协议(URL)相关数据的描述, 在该结 构中定义了指定当前 URL(即 filename 项)所要用到的具体的 URLProtocol, 即:提供 了一个在 URLprotocol 链表中找到具体项的依据, 此外还有一些其它的标志性的信息, 如 flags, is_streamed 等。 它可以看成某一种协议的载体。 其结构定义如下:

typedef struct URLContext
 {
const AVClass *av_class; ///< information for av_log(). Set by url_open(). 
struct URLProtocol *prot; 
int flags; 
int is_streamed; /**< true if streamed (no seek possible), default = false *
int max_packet_size; void *priv_data; 
char *filename; /**< specified URL */ 
int is_connected;
 } URLContext;

那么 ffmpeg 依据什么信息初始化 URLContext?然后又是如何初始化 URLContext 的呢?

在打开一个 URL 时, 全局函数 ffurl_open 会根据 filename 的前缀信息来确定 URL 所使用的具体协议, 并为该协议分配好资源, 再调用 ffurl_connect 函数打开具体协议, 即调用协议的 url_open, 调用关系如下:

int av_open_input_file(AVFormatContext **ic_ptr,
const char *filename,
AVInputFormat *fmt,
int buf_size,
AVFormatParameters *ap)

int avformat_open_input(
AVFormatContext **ps ,
const char *filename ,
AVInputFormat *fmt,
AVDictionary **options)

static int init_input(AVFormatContext *s, const char *filename)

《FFMPEG完美入门资料---003---FFmpeg 架构》

浅蓝色部分的函数完成了 URLContext 函数的初始化,URLContext 使 ffmpeg 外所暴 露的接口是统一的,而不是对于不同的协议用不同的函数,这也是面向对象思维的体现。 在此结构中还有一个值得说的是 priv_data 项, 这是结构的一个可扩展项, 具体协议可 以根据需要添加相应的结构, 将指针保存在这就行。

3、AVIOContext 结构

AVIOContext(即:ByteIOContext)是由 URLProtocol 和 URLContext 结构扩展而 来,也是 ffmpeg 提供给用户的接口,它将以上两种不带缓冲的读取文件抽象为带缓冲的 读取和写入, 为用户提供带缓冲的读取和写入操作。 数据结构定义如下:

typedef struct 
{
  unsigned char *buffer; /**< Start of the buffer. */
  int buffer_size; /**< Maximum buffer size */ 
  unsigned char *buf_ptr; /**< Current position in the buffer */
  unsigned char *buf_end; 
  void *opaque; /关联 URLContext int (*read_packet)(void *opaque, uint8_t *buf, int buf_size); 
  int (*write_packet)(void *opaque, uint8_t *buf, int buf_size);
  int64_t (*seek)(void *opaque, int64_t offset, int whence); 
  int64_t pos; int must_flush; int eof_reached; /**< true if eof reached */
  int write_flag; /**< true if open for writing */
  int max_packet_size; 
  unsigned long checksum; 
  unsigned char *checksum_ptr; 
  unsigned long (*update_checksum)(unsigned long checksum, const uint8_t *buf, unsigned int size);
  int error; 
  int (*read_pause)(void *opaque, int pause) int64_t (*read_seek)(void *opaque, int stream_index,int64_t timestamp, int flags); 
  int seekable;
 } AVIOContext;

结 构 简 单 的 为 用 户 提 供 读 写 容 易 实 现 的 四 个 操 作 , read_packet write_packet read_pause read_seek, 极大的方便了文件的读取, 四个函数在加了缓冲机制后被中转 到, URLContext 指向的实际的文件协议读写函数中。

下面给出 0.8 版本中是如何将 AVIOContext 的读写操作中转到实际文件中的。

在 avio_open()函数中调用了 ffio_fdopen()函数完成了对 AVIOContex 的初始 化, 其调用过程如下:

《FFMPEG完美入门资料---003---FFmpeg 架构》

蓝色部分的函数调用完成了对 AVIOContext 的初始化, 在初始化的过程中, 将 AVIOContext 的 read_packet 、 write_packet 、 seek 分 别 初 始 化 为 : ffurl_read ffurl_write ffurl_seek , 而 这 三 个 函 数 又 将 具 体 的 读 写 操 作 中 转 为 : h->prot->url_read、h->prot->url_write、h->prot->url_seek, 另外两个变量初始化 时也被相应的中转, 如下:

(*s)->read_pause = (int (*)(void *, int))h->prot->url_read_pause;
(*s)->read_seek = (int64_t (*)(void *, int, int64_t, int))h->prot->url_read_seek;

所以, 可以简要的描述为:AVIOContext 的接口口是加了缓冲后的 URLProtocol 的 函数接口。

在 aviobuf.c 中定义了一系列关于 ByteIOContext 这个结构体的函数, 如下

  • put_xxx 系列:

    void put_byte(ByteIOContext *s, int b); 
    void put_buffer(ByteIOContext *s, const unsigned char *buf, int size);
    void put_le64(ByteIOContext *s, uint64_t val); 
    void put_be64(ByteIOContext *s, uint64_t val); 
    void put_le32(ByteIOContext *s, unsigned int val); 
    void put_be32(ByteIOContext *s, unsigned int val); 
    void put_le24(ByteIOContext *s, unsigned int val); 
    void put_be24(ByteIOContext *s, unsigned int val);
    void put_le16(ByteIOContext *s, unsigned int val); 
    void put_be16(ByteIOContext *s, unsigned int val); 
    void put_tag(ByteIOContext *s, const char *tag);
    

*get_xxx 系列:

int get_buffer(ByteIOContext *s, unsigned char *buf, int size);
int get_partial_buffer(ByteIOContext *s, unsigned char *buf, int size); 
int get_byte(ByteIOContext *s);
unsigned int get_le24(ByteIOContext *s);
unsigned int get_le32(ByteIOContext *s); 
uint64_t get_le64(ByteIOContext *s);
unsigned int get_le16(ByteIOContext *s);
char *get_strz(ByteIOContext *s, char *buf, int maxlen); 
unsigned int get_be16(ByteIOContext *s); 
unsigned int get_be24(ByteIOContext *s); 
unsigned int get_be32(ByteIOContext *s);
uint64_t get_be64(ByteIOContext *s);

这些 put_xxx 及 get_xxx 函数是用于从缓冲区 buffer 中写入或者读取若干个字节, 对于读写整型数据,分别实现了大端和小端字节序的版本。而缓冲区 buffer 中的数据又 是 从 何 而 来 呢 , 有 一 个 fill_buffer 的 函 数 , 在 fill_buffer 函 数 中 调 用 了 ByteIOContext 结构的 read_packet 接口。 在调用 put_xxx 函数时, 并没有直接进行真 正写入操作,而是先缓存起来,直到缓存达到最大限制或调用 flush_buffer 函数对缓冲 区进行刷新, 才使用 write_packet 函数进行写入操作。

3.3 Demuxer 和 muxer 模块分析

3.3.1 概述

ffmpeg 的 demuxer 和 muxer 接口分别在 AVInputFormat 和 AVOutputFormat 两个结

构体中实现, 在 av_register_all()函数中将两个结构分别静态初始化为两个链表, 保 存在全局变量:first_iformat 和 first_oformat 两个变量中。在 FFmpeg 的文件转换或 者打开过程中, 首先要做的就是根据传入文件和传出文件的后缀名匹配合适的 demuxer 和 muxer, 得到合适的信息后保存在 AVFormatContext 中。

3.3.2 相关数据结构介绍

1、AVInputFormat

该结构被称为 demuxer, 是音视频文件的一个解封装器, 它的定义如下:

typedef struct AVInputFormat
 {
 const char *name; 
 const char *long_name;
 int priv_data_size; //具体文件容器格式对应的 Context 的大小, 如:avicontext int (*read_probe)(AVProbeData *); 
 int (*read_header)(struct AVFormatContext *, AVFormatParameters *ap); 
 int (*read_packet)(struct AVFormatContext *, AVPacket *pkt); 
 int (*read_close)(struct AVFormatContext *); 
 #if FF_API_READ_SEEK 
 attribute_deprecated int (*read_seek)(struct AVFormatContext *, int stream_index, int64_t timestamp, int flags);
  #endif 
 int64_t (*read_timestamp)(struct AVFormatContext *s, int stream_index, int64_t *pos, int64_t pos_limit);
  int flags; const char *extensions;
  int value; 
  int (*read_play)(struct AVFormatContext *);
  int (*read_pause)(struct AVFormatContext *); 
  const struct AVCodecTag * const *codec_tag;
  int (*read_seek2)(struct AVFormatContext *s, int stream_index, int64_t min_ts, int64_t ts, int64_t max_ts, int flags);
  #if FF_API_OLD_METADATA2
 const AVMetadataConv *metadata_conv;
  #endif 
  const AVClass *priv_class; ///< AVClass for the private context
  struct AVInputFormat *next; 
 } AVInputFormat;

对于不同的文件格式要实现相应的函数接口, 这样每一种格式都有一个对应的 demuxer, 所有的 demuxer 都保存在全局变量 first_iformat 中。 红色表示提供的接口。

2、AVOutputFormat

该结构与 AVInputFormat 类似也是在编译时静态初始化, 组织为一个链表结构, 提 供了多个 muxer 的函数接口。

 int (*write_header)(struct AVFormatContext *);
 int (*write_packet)(struct AVFormatContext *, AVPacket *pkt); 
 int (*write_trailer)(struct AVFormatContext *);

对于不同的文件格式要实现相应的函数接口, 这样每一种格式都有一个对应的 muxer, 所有的 muxer 都保存在全局变量 first_oformat 中。

3、AVFormatContext

该结构表示与程序当前运行的文件容器格式使用的上下文, 着重于所有文件容器共 有的属性,在运行时动态的确定其值,是 AVInputFormat 和 AVOutputFormat 的载体,但 同一个结构对象只能使 AVInputFormat 和 AVOutputFormat 中的某一个有效。每一个输入 和输出文件, 都在

static AVFormatContext *output_files[MAX_FILES] 

static AVFormatContext *input_files[MAX_FILES];

定义的指针数组全局变量中有对应的实体。 对于输入和输出, 因为共用的是同一个结构 体, 所以需要分别对该结构中如下定义的 iformat 或 oformat 成员赋值。 在转码时读写 数据是通过 AVFormatContext 结构进行的。 定义如下:

typedef struct AVFormatContext 
{ 
 const AVClass *av_class; 
 struct AVInputFormat *iformat; //指向具体的 demuxer
 struct AVOutputFormat *oformat; //指向具体的 muxer
 void *priv_data; //具体文件容器格式的 Context 如:avicontext
 AVIOContext *pb; //广义的输入输出;
 unsigned int nb_streams; //本次打开的文件容器中流的数量
 AVStream **streams; //每个流的相关描述
 char filename[1024]; // input or output filename */ 
 int64_t timestamp; 
 int ctx_flags;
 struct AVPacketList *packet_buffer;
 …… 
 enum CodecID video_codec_id; 
 enum CodecID audio_codec_id;
 enum CodecID subtitle_codec_id; 
 unsigned int max_index_size;
 unsigned int max_picture_buffer; 
 …… 
 struct AVPacketList *raw_packet_buffer; 
 struct AVPacketList *raw_packet_buffer_end; 
 struct AVPacketList *packet_buffer_end;
  …… 
 } AVFormatContext;

红色部分的成员是 AVFormatContext 中最为重要的成员变量, 这些变量的初始化是 ffmpeg 能正常工作的必要条件, 那么, AVFormatContext 是如何被初始化的呢?文件的 格式是如何被探测到的呢?

首先我们来探讨:

 struct AVInputFormat *iformat; //指向具体的 demuxer
 struct AVOutputFormat *oformat; //指向具体的 muxer 
 void *priv_data; //具体文件容器格式的 Context 如:avicontext

三个成员的初始化。

在 avformat_open_input() 函 数 中 调 用 了 init_input() 函 数 , 然 后 用 调 用 了 av_probe_input_format()函数实现了对 AVFormatContext 的初始化。其调用关系如下:

 int av_open_input_file(AVFormatContext **ic_ptr, const char *filename, AVInputFormat *fmt, int buf_size, AVFormatParameters *ap);
 int avformat_open_input(ic_ptr, filename, fmt, &opts);
 static int init_input(s, filename);
 av_probe_input_format(&pd, 0);

av_probe_input_format (AVProbeData *pd, int is_opened, int *score_max) 函数用途是根据传入的 probe data 数据, 依次调用每个 demuxer 的 read_probe 接口, 来进行该 demuxer 是否和传入的文件内容匹配的判断。 与 demuxer 的匹配不同, muxer 的匹配是调用 guess_format 函数, 根据 main( ) 函数的 argv 里的输出文件后缀名来进 行的。 至此完成了前三个重要成员的初始化, 具体的做法就不在深入分析。

下面分别给出 av_read_frame 函数以及 av_write_frame 函数的基本流程。

int av_read_frame(AVFormatContext *s, AVPacket *pkt);
 ->av_read_frame_internel
    ->av_read_packet 
       ->iformat->read_packet(在实现中会丢弃多余信息) 
          ->av_get_packet 
             ->get_xxx

int av_write_frame(AVFormatContext *s, AVPacket *pkt);
  ->oformat->write_packet
     ->put_xxx

由上可见, 对 AVFormatContext 的读写操作最终是通过 ByteIOContext 来实现的, 这样, AVFormatContext 与 URLContext 就由 ByteIOContext 结构联系到一起了。 在 AVFormat 结构体中有一个 packet 的缓冲区 raw_packet_buffer, 是 AVPackList 的指针 类型, av_read_packet 函数将读到的包添加至 raw_packet_buffer 链表末尾。

3.4 Decoder/Encoder 模块

3.4.1 概述

编解码模块主要包含的数据结构为:AVCodec、AVCodecContext 每一个解码类型都 会有自己的 Codec 静态对像, Codec 的 int priv_data_size 记录该解码器上下文的结构 大 小 , 如 MsrleContext 。 这 些 都 是 编 译 时 确 定 的 , 程 序 运 行 时 通 过 avcodec_register_all()将所有的解码器注册成一个链表。在 av_open_input_stream() 函数中调用 AVInputFormat 的 read_header()中读文件头信息时, 会读出数据流的 CodecID, 即确定了他的解码器 Codec。

在 main()函数中除了解析传入参数并初始化 demuxer 与 muxer 的 parse_options( ) 函数以外, 其他的功能都是在 av_encode( )函数里完成的。 在 libavcodec\utils.c 中 有 如 下 二 个 函 数 :AVCodec *avcodec_find_encoder(enum CodecID id) 和 AVCodec *avcodec_find_decoder(enum CodecID id)他们的功能就是根据传入的 CodecID, 找到 匹配的 encoder 和 decoder。在 av_encode( )函数的开头,首先初始化各个 AVInputStream 和 AVOutputStream,然后分别调用上述二个函数,并将匹配上的 encoder 与 decoder 分 别保存在:

AVInputStream->AVStream *st->AVCodecContext *codec->struct AVCodec *codec 

AVOutputStream->AVStream *st->AVCodecContext *codec->struct AVCodec *codec 

变量。

3.4.2 相关数据结构的初始化

AVCodecContext 结构

AVCodecContext 保存 AVCodec 指针和与 codec 相关数据,如 video 的 width、height, audio 的 sample rate 等。

AVCodecContext 中的 codec_type, codec_id 二个变量对于 encoder/decoder 的匹 配来说, 最为重要。

enum CodecType codec_type; /* see CODEC_TYPE_xxx */
enum CodecID codec_id; /* see CODEC_ID_xxx */

如上所示, codec_type 保存的是 CODEC_TYPE_VIDEO, CODEC_TYPE_AUDIO 等媒体类 型, codec_id 保存的是 CODEC_ID_FLV1, CODEC_ID_VP6F 等编码方式。

以支持 flv 格式为例, 在前述的 av_open_input_file(…… ) 函数中, 匹配到正确 的 AVInputFormat demuxer 后,通过 av_open_input_stream( )函数中调用 AVInputFormat 的 read_header 接口来执行 flvdec.c 中的 flv_read_header( )函数。flv_read_header( ) 函数内, 根据文件头中的数据, 创建相应的视频或音频 AVStream, 并设置 AVStream 中 AVCodecContext 的正确的 codec_type 值。codec_id 值是在解码过程。flv_read_packet( ) 函数执行时根据每一个 packet 头中的数据来设置的。

以 avidec 为例 有如下初始化,我们主要知道的就是 code_id 和 code_type 该字段关 联具体的解码器, 和解码类型(音视频或 subtitle)

if (st->codec->stream_codec_tag == AV_RL32(" Axan" )) 
{
  st->codec->codec_id = CODEC_ID_XAN_DPCM;
  st->codec->codec_tag = 0; 
}
 if (amv_file_format) 
{
  st->codec->codec_id = CODEC_ID_ADPCM_IMA_AMV; 
  ast->dshow_block_align = 0; 
}
 break;
 case AVMEDIA_TYPE_SUBTITLE:
    st->codec->codec_type = AVMEDIA_TYPE_SUBTITLE; 
    st->request_probe= 1;
    break;
 default: 
   st->codec->codec_type = AVMEDIA_TYPE_DATA; 
   st->codec->codec_id= CODEC_ID_NONE; 
  st->codec->codec_tag= 0; 
avio_skip(pb, size);

3.5 其他重要数据结构的初始化

3.5.1 AVStream

AVStream 结构保存与数据流相关的编解码器,数据段等信息。比较重要的有如下二个成员:

AVCodecContext *codec; /**< codec context */ 
void *priv_data;

其中 codec 指针保存的就是上节所述的 encoder 或 decoder 结构。 priv_data 指针 保存的是和具体编解码流相关的数据,如下代码所示,在 ASF 的解码过程中,priv_data 保存的就是 ASFStream 结构的数据。

AVStream *st;
ASFStream *asf_st; 
… … 
st->priv_data = asf_st;

3.5.2 AVInputStream/ AVOutputStream

根据输入和输出流的不同, 前述的 AVStream 结构都是封装在 AVInputStream 和 AVOutputStream 结构中, 在 av_encode( )函数中使用。 AVInputStream 中还保存的有与 时间有关的信息。 AVOutputStream 中还保存有与音视频同步等相关的信息。

3.5.3 AVPacket

AVPacket 结构定义如下, 其是用于保存读取的 packet 数据。

typedef struct AVPacket
 {
  int64_t pts; ///< presentation time stamp in time_base units
  int64_t dts; ///< decompression time stamp in time_base units
  uint8_t *data;
  int size; 
  int stream_index;
  int flags;
  int duration; ///< presentation duration in time_base units
  void (*destruct)(struct AVPacket *); 
  void *priv; 
  int64_t pos; ///< byte position in stream, -1 if unknown 
  } AVPacket;

在 av_encode()函数中, 调用 AVInputFormat 的

(*read_packet)(struct AVFormatContext *, AVPacket *pkt)

接口, 读取输入文 件的一帧数据保存在当前输入 AVFormatContext 的 AVPacket 成员中。

写在后面

如果对FFmpeg有需要更多了解的请订阅我的专题:音视频专辑

    原文作者:张芳涛
    原文地址: https://www.jianshu.com/p/9907c1911732
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞