簡歷當中的知識點

1、opencv移植ARM的過程

首先準備cmake,libv4l-0.6.3.tar.gz

sudo vi CMakeCache.txt
找到:CMAKE_EXE_LINKER_FLAGS:STRING=
修改爲:CMAKE_EXE_LINKER_FLAGS:STRING=-lpthread -ldl -lrt
意思是:-lpthread支持線程,-ldl避免未定義dlopen,-lrt避免未定義

這是因爲在新的版本里,已經不用videodev.h這個文件了
sudo ln -s /usr/include/libv4l1-videodev.h /usr/include/linux/videodev.h 

CMakeCache.txt,該文件是上次cmake時候留下的緩存文件,如果在編譯過程中報錯,可以將該文件刪除,然後再執行cmake

攝像頭調用的兩種方法總結:
  (1) 先實例化再初始化
     VideoCapture capture;
      capture.open(0);
  (2)在實例化的同時初始化:
     VideoCapture capture(0);
 //=========================================
#include <opencv2/opencv.hpp >
using namespace cv;

int main()
{
	//從攝像頭讀取視頻
	VideoCapture capture(0);
	//循環顯示每一幀
	while (1) 
	{
		Mat  frame;//定義一個Mat變量,用於存儲每一幀的圖像
		capture >> frame;//讀取當前幀
		imshow("讀取視頻幀",frame);//顯示當前幀
		waitKey(30);//延時30ms
	}

	system("pause");
	return 0;
}

Mat類,是一個類,有很多種構造函數。
at函數具體用法
Mat.at<存儲類型名稱>(行,列)[通道]

2、H265

H265編碼器仍舊採用變換和預測的混合編碼方法。輸入幀以宏塊爲單位被編碼器處理,首先按照幀內或幀間預測編碼的方法進行處理;接着,預測值與當前塊相減,相減後得到的殘差塊經變換、量化後產生一組量化後的變換系數;最後,這組量化後的變換系數經過熵編碼,與解碼所需的一些頭信息(如預測模式量化參數、運動矢量等)一起組成一個壓縮後的碼流,經NAL(網絡自適應層)供傳輸和存儲用。爲了提供進一步預測用的參考圖像,編碼器必須有重建的功能。爲了去除編解碼環路中產生的噪聲,提高參考幀的圖像質量,從而提高圖像壓縮性能,設置了一個環路濾波器,濾波後的輸出即是重建圖像,可用作參考圖像。

在這裏插入圖片描述

2.1 H265的編解碼流程主要功能:

幀間和幀內預測(Estimation):圖像經過幀內預測和幀間預測後,與原始視頻幀進行相減形成預測殘差。
變換(Transform)和反變換:將圖像的時域信號變換爲頻域的信號,在頻域中信號的能量集中在低頻區域,並使其碼率相對於空間信號有大幅下降。
量化(Quantization)和反量化:不降低視覺效果的前提下,保留圖像的細節,確定量化參數(QP),減少圖像的編碼長度。
環路濾波(Loop Filter):對塊邊界處的像素進行濾波以平滑像素值的突變,消除視頻圖像中的塊效應,同時可以達到降低噪音的效果。
熵編碼(Entropy Coding):利用信息的統計冗餘進行數據壓縮的無損編碼方法.

2.2 FFMPEG組成

構成FFmpeg主要有三個部分,第一部分是四個作用不同的工具軟件,分別是:ffmpeg.exe,ffplay.exe,ffserver.exe和ffprobe.exe。

ffmpeg.exe:音視頻轉碼、轉換器
ffplay.exe:簡單的音視頻播放器
ffserver.exe:流媒體服務器
ffprobe.exe:簡單的多媒體碼流分析器
第二部分是可以供開發者使用的SDK,爲各個不同平臺編譯完成的庫。如果說上面的四個工具軟件都是完整成品形式的玩具,那麼這些庫就相當於樂高積木一樣,我們可以根據自己的需求使用這些庫開發自己的應用程序。這些庫有:

  • libavcodec:包含音視頻編碼器和解碼器
  • libavutil:包含多媒體應用常用的簡化編程的工具,如隨機數生成器、數據結構、數學函數等功能
  • libavformat:包含多種多媒體容器格式的封裝、解封裝工具
  • libavfilter:包含多媒體處理常用的濾鏡功能
  • libavdevice:用於音視頻數據採集和渲染等功能的設備相關
  • libswscale:用於圖像縮放和色彩空間和像素格式轉換功能
  • libswresample:用於音頻重採樣和格式轉換等功能
    第三部分是整個工程的源代碼,無論是編譯出來的可執行程序還是SDK,都是由這些源代碼編譯出來的。FFmpeg的源代碼由C語言實現,主要在Linux平臺上進行開發。FFmpeg不是一個孤立的工程,它還存在多個依賴的第三方工程來增強它自身的功能。在當前這一系列的博文/視頻中,我們暫時不會涉及太多源代碼相關的內容,主要以FFmpeg的工具和SDK的調用爲主。到下一系列我們將專門研究如何編譯源代碼並根據源代碼來進行二次開發

2.2.1 FFMpeg進行視頻編碼所需要的結構:

爲了實現調用FFMpeg的API實現視頻的編碼,以下結構是必不可少的:

  • AVCodec:AVCodec結構保存了一個編解碼器的實例,實現實際的編碼功能。通常我們在程序中定義一個指向AVCodec結構的指針指向該實例。

  • AVCodecContext:AVCodecContext表示AVCodec所代表的上下文信息,保存了AVCodec所需要的一些參數。對於實現編碼功能,我們可以在這個結構中設置我們指定的編碼參數。通常也是定義一個指針指向AVCodecContext。

  • AVFrame:AVFrame結構保存編碼之前的像素數據,並作爲編碼器的輸入數據。其在程序中也是一個指針的形式。

  • AVPacket:AVPacket表示碼流包結構,包含編碼之後的碼流數據。該結構可以不定義指針,以一個對象的形式定義。

  • FFMpeg編碼的主要步驟:

(1)、輸入編碼參數
這一步我們可以設置一個專門的配置文件,並將參數按照某個事寫入這個配置文件中,再在程序中解析這個配置文件獲得編碼的參數。
如果參數不多的話,我們可以直接使用命令行將編碼參數傳入即可。
(2)、按照要求初始化需要的FFMpeg結構
首先,所有涉及到編解碼的的功能,都必須要註冊音視頻編解碼器之後才能使用。註冊編解碼調用下面的函數
avcodec_register_all();
編解碼器註冊完成之後,根據指定的CODEC_ID查找指定的codec實例。CODEC_ID通常指定了編解碼器的格式,
在這裏我們使用當前應用最爲廣泛的H.264格式爲例。查找codec調用的函數爲avcodec_find_encoder,其聲明格式爲:
AVCodec *avcodec_find_encoder(enum AVCodecID id);
該函數的輸入參數爲一個AVCodecID的枚舉類型,返回值爲一個指向AVCodec結構的指針,
用於接收找到的編解碼器實例。如果沒有找到,那麼該函數會返回一個空指針。調用方法如下:
/* find the mpeg1 video encoder */
ctx.codec = avcodec_find_encoder(AV_CODEC_ID_H264); //根據CODEC_ID查找編解碼器對象實例的指針
if (!ctx.codec) 
{
    fprintf(stderr, "Codec not found\n");
    return false;
}
AVCodec查找成功後,下一步是分配AVCodecContext實例。分配AVCodecContext實例需要我們前面查找到的AVCodec作爲參數,
調用的是avcodec_alloc_context3函數。其聲明方式爲:
AVCodecContext *avcodec_alloc_context3(const AVCodec *codec);
其特點同avcodec_find_encoder類似,返回一個指向AVCodecContext實例的指針。
如果分配失敗,會返回一個空指針。調用方式爲
ctx.c = avcodec_alloc_context3(ctx.codec);          //分配AVCodecContext實例
if (!ctx.c)
{
    fprintf(stderr, "Could not allocate video codec context\n");
    return false;
}
需注意,在分配成功之後,應將編碼的參數設置賦值給AVCodecContext的成員。

現在,AVCodec、AVCodecContext的指針都已經分配好,然後以這兩個對象的指針作爲參數打開編碼器對象。
調用的函數爲avcodec_open2,聲明方式爲:
int avcodec_open2(AVCodecContext *avctx, const AVCodec *codec, AVDictionary **options);
該函數的前兩個參數是我們剛剛建立的兩個對象,第三個參數爲一個字典類型對象,
用於保存函數執行過程總未能識別的AVCodecContext和另外一些私有設置選項。
函數的返回值表示編碼器是否打開成功,若成功返回0,失敗返回一個負數。調用方式爲
if (avcodec_open2(ctx.c, ctx.codec, NULL) < 0)      //根據編碼器上下文打開編碼器
{
    fprintf(stderr, "Could not open codec\n");
    exit(1);
}

然後,我們需要處理AVFrame對象。AVFrame表示視頻原始像素數據的一個容器,處理該類型數據需要兩個步驟,
其一是分配AVFrame對象,其二是分配實際的像素數據的存儲空間。分配對象空間類似於new操作符一樣,
只是需要調用函數av_frame_alloc。如果失敗,那麼函數返回一個空指針。AVFrame對象分配成功後,
需要設置圖像的分辨率和像素格式等。實際調用過程如下:

ctx.frame = av_frame_alloc();                       //分配AVFrame對象
if (!ctx.frame) 
{
    fprintf(stderr, "Could not allocate video frame\n");
    return false;
}
ctx.frame->format = ctx.c->pix_fmt;
ctx.frame->width = ctx.c->width;
ctx.frame->height = ctx.c->height;

分配像素的存儲空間需要調用av_image_alloc函數,其聲明方式爲:
int av_image_alloc(uint8_t *pointers[4], int linesizes[4], int w, int h, enum AVPixelFormat pix_fmt, int align);
該函數的四個參數分別表示AVFrame結構中的緩存指針、各個顏色分量的寬度、圖像分辨率(寬、高)、像素格式和內存對其的大小。
該函數會返回分配的內存的大小,如果失敗則返回一個負值。具體調用方式如:
ret = av_image_alloc(ctx.frame->data, ctx.frame->linesize, ctx.c->width, ctx.c->height, ctx.c->pix_fmt, 32);
if (ret < 0) 
{
    fprintf(stderr, "Could not allocate raw picture buffer\n");
    return false;
}


FFMPEG結構體

  • AVFrame結構體
    AVFrame結構體一般用於存儲原始數據(即非壓縮數據,例如對視頻來說是YUV,RGB,對音頻來說是PCM),此外還包含了一些相關的信息。比如說,解碼的時候存儲了宏塊類型表,QP表,運動矢量表等數據。編碼的時候也存儲了相關的數據。因此在使用FFMPEG進行碼流分析的時候,AVFrame是一個很重要的結構體。

data[]
對於packed格式的數據(例如RGB24),會存到data[0]裏面。
對於planar格式的數據(例如YUV420P),則會分開成data[0],data[1],data[2]…(YUV420P中data[0]存Y,data[1]存U,data[2]存V)

  • AVPacket
    AVPacket是存儲壓縮編碼數據相關信息的結構體
    針對data做一下說明:對於H.264格式來說,在使用FFMPEG進行視音頻處理的時候,我們常常可以將得到的AVPacket的data數據直接寫成文件,從而得到視音頻的碼流文件。
uint8_t *data:壓縮編碼的數據。

int   size:data的大小

int64_t pts:顯示時間戳

int64_t dts:解碼時間戳

int   stream_index:標識該AVPacket所屬的視頻/音頻流。
  • AVCodec是存儲編解碼器信息的結構體
    AVCodec是存儲編解碼器信息的結構體
    下面簡單介紹一下遍歷ffmpeg中的解碼器信息的方法(這些解碼器以一個鏈表的形式存儲):

1.註冊所有編解碼器:av_register_all();

2.聲明一個AVCodec類型的指針,比如說AVCodec* first_c;

3.調用av_codec_next()函數,即可獲得指向鏈表下一個解碼器的指針,循環往復可以獲得所有解碼器的信息。注意,如果想要獲得指向第一個解碼器的指針,則需要將該函數的參數設置爲NULL。

2.3 FFmpeg編碼的流程圖

如下圖是基於FFMPEG的H265視頻編碼器流程圖,該編碼器實現了YUV420P的像素數據編碼爲H265(H264,MPEG2,VP8)的壓縮編碼數據。
首先用函數avcodec_find_encoder()查找編碼器;然後用函數avcodec_alloc_context()申請CODEC,函數avcodec_alloc_frame()申請編碼器中的圖像幀空間;設置編碼器參數,包括寬度、高度等;avcodec_open()打開編碼器CODEC;獲取圖像數據;編碼當前圖像avcodec_encode_video();寫入碼流文件;編碼完畢後,銷燬各種資源,關閉編碼器avcodec_close()等。

在這裏插入圖片描述
(1)av_register_all():註冊FFmpeg 的H265編碼器。調用了avcodec_register_all(),avcodec_register_all()註冊了H265編碼器有關的組件:硬件加速器,編碼器,Parser,Bitstream Filter等;
(2)avformat_alloc_output_context2():初始化輸出碼流的AVFormatContext,獲取輸出文件的編碼格式;
(3)avio_open():打開輸出文件,調用了2個函數:ffurl_open()和ffio_fdopen()。其中ffurl_open()用於初始化URLContext,ffio_fdopen()用於根據URLContext初始化AVIOContext。URLContext中包含的URLProtocol完成了具體的協議讀寫等工作。AVIOContext則是在URLContext的讀寫函數外面加上了一層“包裝”;
(4)av_new_stream():創建輸出碼流的AVStream結構體,爲輸出文件設置編碼所需要的參數和格式;
(5)avcodec_find_encoder():通過 codec_id查找H265編碼器

HEVC解碼器對應的AVCodec結構體ff_hevc_decoder:
AVCodec ff_hevc_decoder = {
    .name                  = "hevc",
    .long_name             = NULL_IF_CONFIG_SMALL("HEVC (High Efficiency Video Coding)"),
    .type                  = AVMEDIA_TYPE_VIDEO,
    .id                    = AV_CODEC_ID_HEVC,
    .priv_data_size        = sizeof(HEVCContext),
    .priv_class            = &hevc_decoder_class,
    .init                  = hevc_decode_init,
    .close                 = hevc_decode_free,
    .decode                = hevc_decode_frame,
    .flush                 = hevc_decode_flush,
    .update_thread_context = hevc_update_thread_context,
    .init_thread_copy      = hevc_init_thread_copy,
    .capabilities          = AV_CODEC_CAP_DR1 | AV_CODEC_CAP_DELAY |
                             AV_CODEC_CAP_SLICE_THREADS | AV_CODEC_CAP_FRAME_THREADS,
    .caps_internal         = FF_CODEC_CAP_INIT_THREADSAFE | FF_CODEC_CAP_EXPORTS_CROPPING,
    .profiles              = NULL_IF_CONFIG_SMALL(ff_hevc_profiles),
    .hw_configs            = (const AVCodecHWConfigInternal*[]) {
#if CONFIG_HEVC_DXVA2_HWACCEL
                               HWACCEL_DXVA2(hevc),
#endif
#if CONFIG_HEVC_D3D11VA_HWACCEL
                               HWACCEL_D3D11VA(hevc),
#endif
#if CONFIG_HEVC_D3D11VA2_HWACCEL
                               HWACCEL_D3D11VA2(hevc),
#endif
#if CONFIG_HEVC_NVDEC_HWACCEL
                               HWACCEL_NVDEC(hevc),
#endif
#if CONFIG_HEVC_VAAPI_HWACCEL
                               HWACCEL_VAAPI(hevc),
#endif
#if CONFIG_HEVC_VDPAU_HWACCEL
                               HWACCEL_VDPAU(hevc),
#endif
#if CONFIG_HEVC_VIDEOTOOLBOX_HWACCEL
                               HWACCEL_VIDEOTOOLBOX(hevc),
#endif
                               NULL

(6)avcodec_open2():打開編碼器。調用AVCodec的libx265_encode_init()初始化H265解碼器 avcodec_open2()函數;
avcodec_open2() -> libx265_encode_init() -> x265_param_alloc(), x265_param_default_preset(), x265_encoder_open()
(7)avformat_write_header():寫入編碼的H265碼流的文件頭;
(8)avcodec_encode_video2():編碼一幀視頻。將AVFrame(存儲YUV像素數據)編碼爲AVPacket(存儲H265格式的碼流數據)。調用H265編碼器的libx265_encode_frame()函數;
avcodec_encode_video2() -> libx265_encode_frame() -> x265_encoder_encode()
(9)av_write_frame():將編碼後的視頻碼流寫入文件中;
(10)flush_encoder():輸入的像素數據讀取完成後調用此函數,用於輸出編碼器中剩餘的AVPacket;
(11)av_write_trailer():寫入編碼的H265碼流的文件尾;
(12)close():釋放 AVFrame和圖片buf,關閉H265編碼器,調用AVCodec的libx265_encode_close()函數
avcodec_close() -> libx265_encode_close() -> x265_param_free(), x265_encoder_close()

  • 通過elecard hevc可以查看幀內預測和運動補償相關信息。
/**
 * 基於FFmpeg的視頻編碼器
 * 功能:實現了YUV420像素數據編碼爲視頻碼流(H264,H265,MPEG2,VP8)。
 * ffmpeg編碼yuv文件的命令:
 * H264:ffmpeg -s cif -i foreman_cif.yuv -vcodec libx264 -level 40 -profile baseline -me_method epzs -qp 23 -i_qfactor 1.0  -g 12 -refs 1 -frames 50 -r 25 output.264 
 * H265:ffmpeg -s cif -foreman_cif.yuv -vcodec libx265  -frames 100  output.265
 */
 
#include <stdio.h>
 
#define __STDC_CONSTANT_MACROS
 
#ifdef _WIN32
//Windows
extern "C"
{
#include "libavutil/opt.h"
#include "libavcodec/avcodec.h"
#include "libavformat/avformat.h"
};
#else
//Linux...
#ifdef __cplusplus
extern "C"
{
#endif
#include <libavutil/opt.h>
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#ifdef __cplusplus
};
#endif
#endif
 
//H.265碼流與YUV輸入的幀數不同。經過觀察對比其他程序後發現需要調用flush_encoder()將編碼器中剩餘的視頻幀輸出。當av_read_frame()循環退出的時候,實際上解碼器中可能還包含剩餘的幾幀數據。
//因此需要通過“flush_decoder”將這幾幀數據輸出。“flush_decoder”功能簡而言之即直接調用avcodec_decode_video2()獲得AVFrame,而不再向解碼器傳遞AVPacket
int flush_encoder(AVFormatContext *fmt_ctx, unsigned int stream_index){
	int ret;
	int got_frame;
	AVPacket enc_pkt;
 
	if (!(fmt_ctx->streams[stream_index]->codec->codec->capabilities &
		CODEC_CAP_DELAY))
		return 0;
	while (1) {
		enc_pkt.data = NULL;
		enc_pkt.size = 0;
		av_init_packet(&enc_pkt);
		ret = avcodec_encode_video2(fmt_ctx->streams[stream_index]->codec, &enc_pkt,
			NULL, &got_frame);
		av_frame_free(NULL);
		if (ret < 0)
			break;
		if (!got_frame){
			ret = 0;
			break;
		}
		printf("Flush Encoder: Succeed to encode 1 frame!\tsize:%5d\n", enc_pkt.size);
		/* mux encoded frame */
		ret = av_write_frame(fmt_ctx, &enc_pkt);
		if (ret < 0)
			break;
	}
	return ret;
}
 
int main(int argc, char* argv[])
{
	AVFormatContext* pFormatCtx = NULL;
	AVOutputFormat* fmt;
	AVStream* video_st;
	AVCodecContext* pCodecCtx;
	AVCodec* pCodec;
	AVPacket pkt;
	uint8_t* picture_buf;
	AVFrame* pFrame;
	int picture_size;
	int y_size;
	int framecnt = 0;
	FILE *in_file = fopen("chezaiyundong_1280x720_30_300.yuv", "rb");
	int in_w = 1280, in_h = 720;
	int framenum = 10;
	const char* out_file = "chezaiyundong_1280x720_30_300.hevc";
 
	av_register_all();//註冊FFmpeg所有編解碼器
	avformat_alloc_output_context2(&pFormatCtx, NULL, NULL, out_file);//初始化輸出碼流的AVFormatContext(獲取輸出文件的編碼格式)
	fmt = pFormatCtx->oformat;
 
	// 打開文件的緩衝區輸入輸出,flags 標識爲  AVIO_FLAG_READ_WRITE ,可讀寫;將輸出文件中的數據讀入到程序的 buffer 當中,方便之後的數據寫入fwrite
	if (avio_open(&pFormatCtx->pb, out_file, AVIO_FLAG_READ_WRITE) < 0){
		printf("Failed to open output file! \n");
		return -1;
	}
	video_st = avformat_new_stream(pFormatCtx, 0);//創建輸出碼流的AVStream。
	// 設置 碼率25 幀每秒(fps=25)
	video_st->time_base.num = 1;
	video_st->time_base.den = 25;
	if (video_st == NULL){
		return -1;
	}
 
	//爲輸出文件設置編碼的參數和格式
	pCodecCtx = video_st->codec;// 從媒體流中獲取到編碼結構體,一個 AVStream 對應一個  AVCodecContext
	pCodecCtx->codec_id = fmt->video_codec;// 設置編碼器的 id,例如 h265 的編碼 id 就是 AV_CODEC_ID_H265
	pCodecCtx->codec_type = AVMEDIA_TYPE_VIDEO;//編碼器視頻編碼的類型
	pCodecCtx->pix_fmt = AV_PIX_FMT_YUV420P;//設置像素格式爲 yuv 格式
	pCodecCtx->width = in_w; //設置視頻的寬高
	pCodecCtx->height = in_h;
	pCodecCtx->time_base.num = 1;
	pCodecCtx->time_base.den = 25;
	pCodecCtx->bit_rate = 400000;  //採樣的碼率;採樣碼率越大,視頻大小越大
	pCodecCtx->gop_size = 250;//每250幀插入1個I幀,I幀越少,視頻越小
	pCodecCtx->qmin = 10;////最大和最小量化係數 
	//(函數輸出的延時僅僅跟max_b_frames的設置有關,想進行實時編碼,將max_b_frames設置爲0便沒有編碼延時了)
	pCodecCtx->max_b_frames = 3;// 設置 B 幀最大的數量,B幀爲視頻圖片空間的前後預測幀, B 幀相對於 I、P 幀來說,壓縮率比較大,採用多編碼 B 幀提高清晰度
 
	//設置編碼速度
	AVDictionary *param = 0;
	//preset的參數調節編碼速度和質量的平衡。
	//tune的參數值指定片子的類型,是和視覺優化的參數,
	//zerolatency: 零延遲,用在需要非常低的延遲的情況下,比如電視電話會議的編碼
	if (pCodecCtx->codec_id == AV_CODEC_ID_H264) {
		av_dict_set(¶m, "preset", "slow", 0);
		av_dict_set(¶m, "tune", "zerolatency", 0);
		//av_dict_set(¶m, "profile", "main", 0);
	}
	//H.265
	if (pCodecCtx->codec_id == AV_CODEC_ID_H265){
		av_dict_set(¶m, "preset", "ultrafast", 0);
		av_dict_set(¶m, "tune", "zero-latency", 0);
	}
 
	//輸出格式的信息,例如時間,比特率,數據流,容器,元數據,輔助數據,編碼,時間戳
	av_dump_format(pFormatCtx, 0, out_file, 1);
 
	pCodec = avcodec_find_encoder(pCodecCtx->codec_id);//查找編碼器
	if (!pCodec){
		printf("Can not find encoder! \n");
		return -1;
	}
	// 打開編碼器,並設置參數 param
	if (avcodec_open2(pCodecCtx, pCodec, ¶m) < 0){
		printf("Failed to open encoder! \n");
		return -1;
	}
 
	//設置原始數據 AVFrame
	pFrame = av_frame_alloc();
	if (!pFrame) {
		printf("Could not allocate video frame\n");
		return -1;
	}
	pFrame->format = pCodecCtx->pix_fmt;
	pFrame->width = pCodecCtx->width;
	pFrame->height = pCodecCtx->height;
 
	// 獲取YUV像素格式圖片的大小
	picture_size = avpicture_get_size(pCodecCtx->pix_fmt, pCodecCtx->width, pCodecCtx->height);
	// 將 picture_size 轉換成字節數據
	picture_buf = (uint8_t *)av_malloc(picture_size);
	// 設置原始數據 AVFrame 的每一個frame 的圖片大小,AVFrame 這裏存儲着 YUV 非壓縮數據
	avpicture_fill((AVPicture *)pFrame, picture_buf, pCodecCtx->pix_fmt, pCodecCtx->width, pCodecCtx->height);
	//寫封裝格式文件頭
	avformat_write_header(pFormatCtx, NULL);
	//創建編碼後的數據 AVPacket 結構體來存儲 AVFrame 編碼後生成的數據  //編碼前:AVFrame  //編碼後:AVPacket
	av_new_packet(&pkt, picture_size);
	// 設置 yuv 數據中Y亮度圖片的寬高,寫入數據到 AVFrame 結構體中
	y_size = pCodecCtx->width * pCodecCtx->height;
 
	for (int i = 0; i<framenum; i++){
		//Read raw YUV data
		if (fread(picture_buf, 1, y_size * 3 / 2, in_file) <= 0){
			printf("Failed to read raw data! \n");
			return -1;
		}
		else if (feof(in_file)){
			break;
		}
		pFrame->data[0] = picture_buf;              // 亮度Y
		pFrame->data[1] = picture_buf + y_size;      // U 
		pFrame->data[2] = picture_buf + y_size * 5 / 4;  // V
		//順序顯示解碼後的視頻幀
		pFrame->pts = i;
		// 設置這一幀的顯示時間
		//pFrame->pts=i*(video_st->time_base.den)/((video_st->time_base.num)*25);
		int got_picture = 0;
		int ret = avcodec_encode_video2(pCodecCtx, &pkt, pFrame, &got_picture);//編碼一幀視頻。即將AVFrame(存儲YUV像素數據)編碼爲AVPacket(存儲H.264等格式的碼流數據)
		if (ret < 0){
			printf("Failed to encode! \n");
			return -1;
		}
 
		if (got_picture == 1){
			printf("Succeed to encode frame: %5d\tsize:%5d\n", framecnt, pkt.size);
			framecnt++;
			pkt.stream_index = video_st->index;
			printf("video_st->index = %d\n", video_st->index);
			av_write_frame(pFormatCtx, &pkt);//將編碼後的視頻碼流寫入文件(fwrite)
			av_free_packet(&pkt);//釋放內存
		}
	}
 
	//輸出編碼器中剩餘的AVPacket
	int ret = flush_encoder(pFormatCtx, 0);
	if (ret < 0) {
		printf("Flushing encoder failed\n");
		return -1;
	}
 
	// 寫入數據流尾部到輸出文件當中,表示結束並釋放文件的私有數據
	av_write_trailer(pFormatCtx);
 
	if (video_st){
		// 關閉編碼器
		avcodec_close(video_st->codec);
		// 釋放 AVFrame
		av_free(pFrame);
		// 釋放圖片 buf
		av_free(picture_buf);
	}
	// 關閉輸入數據的緩存
	avio_close(pFormatCtx->pb);
	// 釋放 AVFromatContext 結構體
	avformat_free_context(pFormatCtx);
	// 關閉輸入文件
	fclose(in_file);
 
	return 0;
}

sdl

SDL(Simple DirectMedia Layer)庫的作用說白了就是封裝了複雜的視音頻底層交互工作,簡化了視音頻處理的難度。

主要用來做遊戲,現在只用到其視頻顯示部分。特點:跨平臺,開源

1.庫的結構圖見圖:
在這裏插入圖片描述

3.SDL視頻顯示的流程圖見圖
在這裏插入圖片描述
1).SDL視頻顯示函數簡介

SDL_Init():初始化SDL系統

SDL_CreateWindow():創建窗口SDL_Window

SDL_CreateRenderer():創建渲染器SDL_Renderer

SDL_CreateTexture():創建紋理SDL_Texture

SDL_UpdateTexture():設置紋理的數據

SDL_RenderCopy():將紋理的數據拷貝給渲染器

SDL_RenderPresent():顯示

SDL_Delay():工具函數,用於延時。

SDL_Quit():退出SDL系統

其中SDL_Delay 延時函數,控制顯示的速度,即控制幀率。通常每秒25幀,所以通常延時也就是40ms

/*****************************************************************************
* Copyright (C) 2017-2020 Hanson Yu  All rights reserved.
------------------------------------------------------------------------------
* File Module       :     FFmpegAndSDL.cpp
* Description       :     FFmpegAndSDL Demo


* Created           :     2017.09.21.
* Author            :     Yu Weifeng
* Function List     :     
* Last Modified     :     
* History           :     
* Modify Date      Version         Author           Modification
* -----------------------------------------------
* 2017/09/21      V1.0.0         Yu Weifeng       Created
******************************************************************************/

#include "stdafx.h"
#include <stdio.h>


/*解決錯誤:
LNK2019    無法解析的外部符號 __imp__fprintf,該符號在函數 _ShowError 中被引用

原因:
……這是鏈接庫問題
就是工程裏面沒有添加那兩個函數需要的庫,#progma這個是代碼鏈接庫
第二句是vs2015兼容的問題。
lib庫的vs編譯版本 和 工程的vs開發版本 不一致。
導出函數定義變了。所以要人爲加一個函數導出。
*/
#pragma comment(lib, "legacy_stdio_definitions.lib")
extern "C" { FILE __iob_func[3] = { *stdin,*stdout,*stderr }; }

/*
__STDC_LIMIT_MACROS and __STDC_CONSTANT_MACROS are a workaround to allow C++ programs to use stdint.h
macros specified in the C99 standard that aren't in the C++ standard. The macros, such as UINT8_MAX, INT64_MIN,
and INT32_C() may be defined already in C++ applications in other ways. To allow the user to decide
if they want the macros defined as C99 does, many implementations require that __STDC_LIMIT_MACROS
and __STDC_CONSTANT_MACROS be defined before stdint.h is included.

This isn't part of the C++ standard, but it has been adopted by more than one implementation.
*/
#define __STDC_CONSTANT_MACROS

extern "C"
{
#include "libavcodec/avcodec.h"
#include "libavformat/avformat.h"
#include "libswscale/swscale.h"
#include "SDL2/SDL.h"
};


//Refresh Event 自定義事件
#define PLAY_REFRESH_EVENT       (SDL_USEREVENT + 1)//自定義刷新圖像(播放)事件
#define PLAY_BREAK_EVENT         (SDL_USEREVENT + 2) //自定義退出播放事件


static int g_iThreadExitFlag = 0;
/*****************************************************************************
-Fuction        : RefreshPlayThread
-Description    : RefreshPlayThread
-Input          : 
-Output         : 
-Return         : 
* Modify Date      Version         Author           Modification
* -----------------------------------------------
* 2017/09/21      V1.0.0         Yu Weifeng       Created
******************************************************************************/
int RefreshPlayThread(void *opaque) 
{
    g_iThreadExitFlag = 0;
    SDL_Event tEvent={0};
    
    while (!g_iThreadExitFlag) 
    {
        tEvent.type = PLAY_REFRESH_EVENT;
        SDL_PushEvent(&tEvent);//發送事件給其他線程
        SDL_Delay(20);//延時函數 填40的時候,視頻會有種卡的感覺
    }
    //Break
    g_iThreadExitFlag = 0;
    tEvent.type = PLAY_BREAK_EVENT;
    SDL_PushEvent(&tEvent);//發送事件給其他線程 發送一個事件

    return 0;
}

/*****************************************************************************
-Fuction        : main
-Description    : main
-Input          : 
-Output         : 
-Return         : 
* Modify Date      Version         Author           Modification
* -----------------------------------------------
* 2017/09/21      V1.0.0         Yu Weifeng       Created
******************************************************************************/
int main(int argc, char* argv[])
{
    /*------------FFmpeg----------------*/
    const char *strFilePath = "屌絲男士.mov";
    AVFormatContext    *ptFormatContext = NULL;//封裝格式上下文,內部包含所有的視頻信息
    int                i = 0; 
    int             iVideoindex=0;//純視頻信息在音視頻流中的位置,也就是指向音視頻流數組中的視頻元素
    AVCodecContext    *ptCodecContext;//編碼器相關信息上下文,內部包含編碼器相關的信息,指向AVFormatContext中的streams成員中的codec成員
    AVCodec            *ptCodec;//編碼器,使用函數avcodec_find_decoder或者,該函數需要的id參數,來自於ptCodecContext中的codec_id成員
    AVFrame            *ptFrame=NULL;//存儲一幀解碼後像素(採樣)數據
    AVFrame            *ptFrameAfterScale=NULL;//存儲(解碼數據)轉換後的像素(採樣)數據
    unsigned char   *pucFrameAfterScaleBuf=NULL;//用於存儲ptFrameAfterScale中的像素(採樣)緩衝數據
    AVPacket        *ptPacket=NULL;//存儲一幀壓縮編碼數據
    int             iRet =0;
    int             iGotPicture=0;//解碼函數的返回參數,got_picture_ptr Zero if no frame could be decompressed, otherwise, it is nonzero

    /*------------SDL----------------*/
    int iScreenWidth=0, iScreenHeight=0;//視頻的寬和高,指向ptCodecContext中的寬和高
    SDL_Window *ptSdlWindow=NULL;//用於sdl顯示視頻的窗口(用於顯示的屏幕)
    SDL_Renderer* ptSdlRenderer=NULL;//sdl渲染器,把紋理數據畫(渲染)到window上
    SDL_Texture* ptSdlTexture=NULL;//sdl紋理數據,用於存放像素(採樣)數據,然後給渲染器
    SDL_Rect tSdlRect ={0};//正方形矩形結構,存了矩形的座標,長寬,以便確定紋理數據畫在哪個位置,確定位置用,比如畫在左上角就用這個來確定。被渲染器調用
    SDL_Thread *ptVideoControlTID=NULL;//sdl線程id,線程的句柄
    SDL_Event tSdlEvent = {0};//sdl事件,代表一個事件

    /*------------像素數據處理----------------*/
    struct SwsContext *ptImgConvertInfo;//圖像轉換(上下文)信息,圖像轉換函數sws_scale需要的參數,由sws_getContext函數賦值



    /*------------FFmpeg----------------*/
    av_register_all();//註冊FFmpeg所有組件
    avformat_network_init();//初始化網絡組件
    
    ptFormatContext = avformat_alloc_context();//分配空間給ptFormatContext
    if (avformat_open_input(&ptFormatContext, strFilePath, NULL, NULL) != 0) 
    {//打開輸入視頻文件
        printf("Couldn't open input stream.\n");
        return -1;
    }
    if (avformat_find_stream_info(ptFormatContext, NULL)<0) 
    {//獲取視頻文件信息
        printf("Couldn't find stream information.\n");
        return -1;
    }
    //獲取編碼器相關信息上下文,並賦值給ptCodecContext
    iVideoindex = -1;
    for (i = 0; i<ptFormatContext->nb_streams; i++)
    {
        if (ptFormatContext->streams[i]->codec->codec_type == AVMEDIA_TYPE_VIDEO) 
        {
            iVideoindex = i;
            break;
        }
    }
    if (iVideoindex == -1) 
    {
        printf("Didn't find a video stream.\n");
        return -1;
    }
    ptCodecContext = ptFormatContext->streams[iVideoindex]->codec;
    
    ptCodec = avcodec_find_decoder(ptCodecContext->codec_id);//查找解碼器
    if (ptCodec == NULL) 
    {
        printf("Codec not found.\n");
        return -1;
    }
    if (avcodec_open2(ptCodecContext, ptCodec, NULL)<0) 
    {//打開解碼器
        printf("Could not open codec.\n");
        return -1;
    }
    
    ptPacket = (AVPacket *)av_malloc(sizeof(AVPacket));//分配保存解碼前數據的空間
    ptFrame = av_frame_alloc();//分配結構體空間,結構體內部的指針指向的數據暫未分配,用於保存圖像轉換前的像素數據
    
    /*------------像素數據處理----------------*/
    ptFrameAfterScale = av_frame_alloc();//分配結構體空間,結構體內部的指針指向的數據暫未分配,用於保存圖像轉換後的像素數據
    pucFrameAfterScaleBuf = (uint8_t *)av_malloc(avpicture_get_size(PIX_FMT_YUV420P, ptCodecContext->width, ptCodecContext->height));//分配保存數據的空間
     /*int avpicture_fill(AVPicture *picture, uint8_t *ptr,int pix_fmt, int width, int height);
    這個函數的使用本質上是爲已經分配的空間的結構體(AVPicture *)ptFrame掛上一段用於保存數據的空間,
    這個結構體中有一個指針數組data[AV_NUM_DATA_POINTERS],掛在這個數組裏。一般我們這麼使用:
    1) pFrameRGB=avcodec_alloc_frame();
    2) numBytes=avpicture_get_size(PIX_FMT_RGB24, pCodecCtx->width,pCodecCtx->height);
        buffer=(uint8_t *)av_malloc(numBytes*sizeof(uint8_t));
    3) avpicture_fill((AVPicture *)pFrameRGB, buffer, PIX_FMT_RGB24,pCodecCtx->width, pCodecCtx->height);
    以上就是爲pFrameRGB掛上buffer。這個buffer是用於存緩衝數據的。
    ptFrame爲什麼不用fill空間。主要是下面這句:
    avcodec_decode_video(pCodecCtx, pFrame, &frameFinished,packet.data, packet.size);
    很可能是ptFrame已經掛上了packet.data,所以就不用fill了。*/
    avpicture_fill((AVPicture *)ptFrameAfterScale, pucFrameAfterScaleBuf, PIX_FMT_YUV420P, ptCodecContext->width, ptCodecContext->height);    
    //sws開頭的函數用於處理像素(採樣)數據
    ptImgConvertInfo = sws_getContext(ptCodecContext->width, ptCodecContext->height, ptCodecContext->pix_fmt,
        ptCodecContext->width, ptCodecContext->height, PIX_FMT_YUV420P, SWS_BICUBIC, NULL, NULL, NULL);//獲取圖像轉換(上下文)信息

    /*------------SDL----------------*/
    if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER)) 
    {//初始化SDL系統
        printf("Could not initialize SDL - %s\n", SDL_GetError());
        return -1;
    }
    //SDL 2.0 Support for multiple windows
    iScreenWidth = ptCodecContext->width;
    iScreenHeight = ptCodecContext->height;
    ptSdlWindow = SDL_CreateWindow("Simplest ffmpeg player's Window", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED,
        iScreenWidth, iScreenHeight, SDL_WINDOW_OPENGL);//創建窗口SDL_Window

    if (!ptSdlWindow) 
    {
        printf("SDL: could not create window - exiting:%s\n", SDL_GetError());
        return -1;
    }
    ptSdlRenderer = SDL_CreateRenderer(ptSdlWindow, -1, 0);//創建渲染器SDL_Renderer
    //IYUV: Y + U + V  (3 planes)
    //YV12: Y + V + U  (3 planes)
    //創建紋理SDL_Texture
    ptSdlTexture = SDL_CreateTexture(ptSdlRenderer, SDL_PIXELFORMAT_IYUV, SDL_TEXTUREACCESS_STREAMING, ptCodecContext->width, ptCodecContext->height);

    tSdlRect.x = 0;//x y值是左上角爲圓點開始的座標值,調整x y值以及w h值,就可以實現在窗口的指定位置顯示,沒有畫面的地方爲黑框
    tSdlRect.y = 0;//當x y等於0,w h等於窗口的寬高時即爲全屏顯示,此時調整寬高大小,只需調整窗口大小即可
    tSdlRect.w = iScreenWidth;
    tSdlRect.h = iScreenHeight;

    ptVideoControlTID = SDL_CreateThread(RefreshPlayThread, NULL, NULL);//創建一個線程
    
    while (1) 
    {//Event Loop        
        SDL_WaitEvent(&tSdlEvent);//Wait,等待其他線程過來的事件
        if (tSdlEvent.type == PLAY_REFRESH_EVENT) //自定義刷新圖像(播放)事件
        {
            /*------------FFmpeg----------------*/
            if (av_read_frame(ptFormatContext, ptPacket) >= 0) //從輸入文件讀取一幀壓縮數據
            {
                if (ptPacket->stream_index == iVideoindex) 
                {
                    iRet = avcodec_decode_video2(ptCodecContext, ptFrame, &iGotPicture, ptPacket);//解碼一幀壓縮數據
                    if (iRet < 0) 
                    {
                        printf("Decode Error.\n");
                        return -1;
                    }
                    if (iGotPicture) 
                    {
                        //圖像轉換,sws_scale()函數需要用到的轉換信息,即第一個參數,是由sws_getContext函數獲得的
                        sws_scale(ptImgConvertInfo, (const uint8_t* const*)ptFrame->data, ptFrame->linesize, 0, ptCodecContext->height, ptFrameAfterScale->data, ptFrameAfterScale->linesize);

                        /*------------SDL----------------*/
                        SDL_UpdateTexture(ptSdlTexture, NULL, ptFrameAfterScale->data[0], ptFrameAfterScale->linesize[0]);//設置(更新)紋理的數據
                        SDL_RenderClear(ptSdlRenderer);//先清除渲染器裏的數據
                        //SDL_RenderCopy( ptSdlRenderer, ptSdlTexture, &tSdlRect, &tSdlRect );  //將紋理的數據拷貝給渲染器
                        SDL_RenderCopy(ptSdlRenderer, ptSdlTexture, NULL, NULL);//將紋理的數據拷貝給渲染器
                        SDL_RenderPresent(ptSdlRenderer);//顯示
                    }
                }
                av_free_packet(ptPacket);//釋放空間
            }
            else 
            {                
                g_iThreadExitFlag = 1;//Exit Thread
            }
        }
        else if (tSdlEvent.type == SDL_QUIT) //也是SDL自帶的事件,當點擊窗口的×時觸發//SDL_WINDOWENVENT sdl系統自帶的事件,當拉伸窗口的時候會觸發
        {
            g_iThreadExitFlag = 1;
        }
        else if (tSdlEvent.type == PLAY_BREAK_EVENT) //自定義退出播放事件
        {
            break;
        }

    }
    
    /*------------像素數據處理----------------*/
    sws_freeContext(ptImgConvertInfo);//釋放空間
    
    /*------------SDL----------------*/
    SDL_Quit();//退出SDL系統

    /*------------FFmpeg----------------*/
    av_frame_free(&ptFrameAfterScale);//釋放空間
    av_frame_free(&ptFrame);//釋放空間
    avcodec_close(ptCodecContext);//關閉解碼器
    avformat_close_input(&ptFormatContext);//關閉輸入視頻文件

    return 0;
}

FFmpegAndSDL.cpp

RTP原理

RTP Header解析

在這裏插入圖片描述

  1.    V:RTP協議的版本號,佔2位,當前協議版本號爲2
    
  2.    P:填充標誌,佔1位,如果P=1,則在該報文的尾部填充一個或多個額外的八位組,它們不是有效載荷的一部分。
    
  3.    X:擴展標誌,佔1位,如果X=1,則在RTP報頭後跟有一個擴展報頭
    
  4.    CC:CSRC計數器,佔4位,指示CSRC 標識符的個數
    
  5.    M: 標記,佔1位,不同的有效載荷有不同的含義,對於視頻,標記一幀的結束;對於音頻,標記會話的開始。
    

6、 PT: 有效荷載類型,佔7位,用於說明RTP報文中有效載荷的類型,
如GSM音頻、JPEM圖像等,在流媒體中大部分是用來區分音頻流和視頻流的,這樣便於客戶端進行解析。

7、 序列號:佔16位,用於標識發送者所發送的RTP報文的序列號,
每發送一個報文,序列號增1。這個字段當下層的承載協議用UDP的時候,
網絡狀況不好的時候可以用來檢查丟包。同時出現網絡抖動的情況可以用來對數據進行重新排序,
序列號的初始值是隨機的,同時音頻包和視頻包的sequence是分別記數的。

8、 時戳(Timestamp):佔32位,必須使用90 kHz 時鐘頻率。
時戳反映了該RTP報文的第一個八位組的採樣時刻。接收者使用時戳來計算延遲和延遲抖動,
並進行同步控制。

9、 同步信源(SSRC)標識符:佔32位,
用於標識同步信源。該標識符是隨機選擇的,參加同一視頻會議的兩個同步信源不能有相同的SSRC。

10、 特約信源(CSRC)標識符:每個CSRC標識符佔32位,可以有0~15個。
每個CSRC標識了包含在該RTP報文有效載荷中的所有特約信源。

注:基本的RTP說明並不定義任何頭擴展本身,如果遇到X=1,需要特殊處理

RTP荷載H264碼流

在這裏插入圖片描述
荷載格式定義三個不同的基本荷載結構,接收者可以通過RTP荷載的第一個字節後5位(如圖2)識別荷載結構。

  1. 單個NAL單元包:荷載中只包含一個NAL單元。NAL頭類型域等於原始 NAL單元類型,即在範圍1到23之間

  2. 聚合包:本類型用於聚合多個NAL單元到單個RTP荷載中。本包有四種版本,單時間聚合包類型A (STAP-A),單時間聚合包類型B (STAP-B),多時間聚合包類型(MTAP)16位位移(MTAP16), 多時間聚合包類型(MTAP)24位位移(MTAP24)。賦予STAP-A, STAP-B, MTAP16, MTAP24的NAL單元類型號分別是 24,25, 26, 27

  3. 分片單元:用於分片單個NAL單元到多個RTP包。現存兩個版本FU-A,FU-B,用NAL單元類型 28,29標識

常用的打包時的分包規則是:如果小於MTU採用單個NAL單元包,如果大於MTU就採用FUs分片方式。
因爲常用的打包方式就是單個NAL包和FU-A方式,所以我們只解析這兩種。
在這裏插入圖片描述
S: 1 bit 當設置成1,開始位指示分片NAL單元的開始。當跟隨的FU荷載不是分片NAL單元荷載的開始,開始位設爲0。

E: 1 bit 當設置成1, 結束位指示分片NAL單元的結束,即, 荷載的最後字節也是分片NAL單元的最後一個字節。當跟隨的 FU荷載不是分片NAL單元的最後分片,結束位設置爲0。

R: 1 bit 保留位必須設置爲0,接收者必須忽略該位

RTP定義

實時傳輸協議(Real-time Transport Protocol,RTP)是在Internet上處理多媒體數據流的一種網絡協議,利用它能夠在一對一(unicast,單播)或者一對多(multicast,多播)的網絡環境中實現傳流媒體數據的實時傳輸(不需要下載完畢後才能看視頻)。RTP通常使用UDP來進行多媒體數據的傳輸,但如果需要的話可以使用TCP等其它協議,整個RTP協議由兩個密切相關的部分組成:RTP數據協議和RTCP控制協議。
RTP數據協議負責對流媒體數據進行封包並實現媒體流的實時傳輸,每一個RTP數據報都由頭部(Header)和負載(Payload)兩個部分組成,其中頭部前12個字節的含義是固定的,而負載則可以是音頻或者視頻數據。

RTCP 控制協議需要與RTP數據協議一起配合使用,當應用程序啓動一個RTP會話時將同時佔用兩個端口,分別供RTP和RTCP使用。RTP本身並不能爲按序傳輸數據包提供可靠的保證,也不提供流量控制和擁塞控制,這些都由RTCP來負責完成。通常RTCP會採用與RTP相同的分發機制,向會話中的所有成員週期性地發送控制信息,應用程序通過接收這些數據,從中獲取會話參與者的相關資料,以及網絡狀況、分組丟失概率等反饋信息,從而能 夠對服務質量進行控制或者對網絡狀況進行診斷。

實時流協議(RealTime Streaming Protocol,RTSP),它的意義在於使得實時流媒體數據的受控和點播變得可能。總的說來,RTSP是一個流媒體表示協議, 主要用來控制具有實時特性的數據發送,但它本身並不傳輸數據,而是必須依賴於下層傳輸協議所提供的某些服務。RTSP 可以對流媒體提供諸如播放、暫停、快進等操作,它負責定義具體的控制消息、操作方法、狀態碼等,此外還描述了與RTP間的交互操作。

  • 如何使用RTP
    參考連接參考連接
    rtp的運行當然少不了JRTPLIB庫的支持,JRTPLIB是一個面向對象的RTP封裝庫,安裝過程如下:
    1、這裏用的是jrtplib-3.7.1,下載地址:

RTP是傳輸層的子層

RTP(實時傳輸協議),顧名思義它是用來提供實時傳輸的,因而可以看成是傳輸層的一個子層。下圖給出了流媒體應用中的一個典型的協議體系結構。
在這裏插入圖片描述

從圖中可以看出,RTP被劃分在傳輸層,它建立在UDP(一般實際情況是基於UDP,基於TCP效率太低)上。同UDP協議一樣,爲了實現其實時傳輸功能,RTP也有固定的封裝形式。RTP用來爲端到端的實時傳輸提供時間信息和流同步,但並不保證服務質量。服務質量由RTCP來提供。這些特點,在第4章可以看到。不少人也把RTP歸爲應用層的一部分,這是從應用開發者的角度來說的。操作系統中的TCP/IP等協議棧所提供的是我們最常用的服務,而RTP的實現還是要靠開發者自己

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章