系列文章:
上一篇博客介紹了怎樣用ffmpeg去播放視頻.
裏面用於打開視頻流的avformat_open_input函數除了打開本地視頻之外,實際上也能打開rtmp協議的遠程視頻,實現拉流:
./demo -p 本地視頻路徑
./demo -p rtmp://服務器ip/視頻流路徑
這篇文章我們來講下怎樣實現推流,然後和之前的demo代碼配合就能完成推流、拉流的整個過程,實現直播。
rtmp服務器
整個直播的功能分成下面三個模塊:
從上圖我們可以看到rtmp是需要服務器做轉發的,我們選用開源的srs.直接從github上把它的源碼拉下來編譯,然後直接啓動即可:
git clone [email protected]:ossrs/srs.git
cd srs/trunk
./configure
make
./etc/init.d/srs start
如果是本地的電腦,這個時候就能在局域網內直接用它的內網ip去訪問了.但如果是騰訊雲、阿里雲之類的雲服務器還需要配置安全組開放下面幾個端口的訪問權限:
listen 1935;
max_connections 1000;
#srs_log_tank file;
#srs_log_file ./objs/srs.log;
daemon on;
http_api {
enabled on;
listen 1985;
}
http_server {
enabled on;
listen 8080;
dir ./objs/nginx/html;
}
rtc_server {
enabled on;
listen 8000; # UDP port
# @see https://ossrs.net/lts/zh-cn/docs/v4/doc/webrtc#config-candidate
candidate $CANDIDATE;
}
...
當然如果這幾個端口已經被佔用的話可以修改配置文件conf/srs.conf去修改
服務器到這裏就準備好了,瀏覽器訪問下面網址對srs進行調試、配置:
http://服務器ip:8080/players/rtc_publisher.html
http://服務器ip:1985/console/ng_index.html
推流
準備輸出流
我們選擇推送本地的視頻到rtmp服務器,所以第一步仍然是打開本地視頻流:
bool VideoSender::Send(const string& srcUrl, const string& destUrl) {
...
// 打開文件流讀取文件頭解析出視頻信息如軌道信息、時長等
// mFormatContext初始化爲NULL,如果打開成功,它會被設置成非NULL的值
// 這個方法實際可以打開多種來源的數據,url可以是本地路徑、rtmp地址等
// 在不需要的時候通過avformat_close_input關閉文件流
if(avformat_open_input(&inputFormatContext, srcUrl.c_str(), NULL, NULL) < 0) {
cout << "open " << srcUrl << " failed" << endl;
break;
}
// 對於沒有文件頭的格式如MPEG或者H264裸流等,可以通過這個函數解析前幾幀得到視頻的信息
if(avformat_find_stream_info(inputFormatContext, NULL) < 0) {
cout << "can't find stream info in " << srcUrl << endl;
break;
}
// 打印輸入視頻信息
av_dump_format(inputFormatContext, 0, srcUrl.c_str(), 0);
...
}
本地視頻打開之後,我們創建輸出視頻流上下文,然後爲輸出流創建軌道,最後打開輸出視頻流:
// 創建輸出流上下文,outputFormatContext初始化爲NULL,如果打開成功,它會被設置成非NULL的值,在不需要的時候使用avformat_free_context釋放
// 輸出流使用flv格式
if(avformat_alloc_output_context2(&outputFormatContext, NULL, "flv", destUrl.c_str()) < 0) {
cout << "can't alloc output context for " << destUrl << endl;
break;
}
// 拷貝編解碼參數
if(!createOutputStreams(inputFormatContext, outputFormatContext)) {
break;
}
// 打印輸出視頻信息
av_dump_format(outputFormatContext, 0, destUrl.c_str(), 1);
// 打開輸出流,結束的時候使用avio_close關閉
if(avio_open(&outputFormatContext->pb, destUrl.c_str(), AVIO_FLAG_WRITE) < 0) {
cout << "can't open avio " << destUrl << endl;
break;
}
這裏有個createOutputStreams用於根據本地視頻文件的軌道信息,爲輸出流創建同樣的軌道:
static bool createOutputStreams(AVFormatContext* inputFormatContext, AVFormatContext* outputFormatContext) {
// 遍歷輸入流的所有軌道,拷貝編解碼參數到輸出流
for(int i = 0 ; i < inputFormatContext->nb_streams ; i++) {
// 爲輸出流創建軌道
AVStream* stream = avformat_new_stream(outputFormatContext, NULL);
if(NULL == stream) {
cout << "can't create stream, index " << i << endl;
return false;
}
// 編解碼參數在AVCodecParameters中保存,從輸入流拷貝到輸出流
if(avcodec_parameters_copy(stream->codecpar, inputFormatContext->streams[i]->codecpar) < 0) {
cout << "can't copy codec paramters, stream index " << i << endl;
return false;
}
// codec_tag代表了音視頻數據採用的碼流格式,不同的封裝格式如flv、mp4等的支持情況是不一樣的
// 上面的avcodec_parameters_copy將輸出流的codec_tag從輸入拷貝過來變成了一樣的
// 由於我們輸出流在avformat_alloc_output_context2的時候寫死了flv格式
// 如果輸入流不是flv而是mp4等格式的話就可能會出現mp4裏某種codec_tag在flv不支持導致推流失敗的情況
// 這裏我們可以用av_codec_get_id從輸出流的oformat的支持的codec_tag列表裏面查找codec_id
// 如果和codecpar的codec_id不一致的話代表不支持
if(av_codec_get_id(outputFormatContext->oformat->codec_tag, stream->codecpar->codec_tag) != stream->codecpar->codec_id) {
// 這裏將codec_tag設置爲0,FFmpeg會根據編碼codec_id從封裝格式的codec_tag列表中找到一個codec_tag
stream->codecpar->codec_tag = 0;
}
}
return true;
}
codec_id和codec_tag
這裏可以看到對於編碼器有codec_id和codec_tag兩個字段去描述,codec_id代表的是數據的編碼類型.而codec_tag用於更詳細的描述編解碼的格式信息,它對應的是FourCC(Four-Character Codes)數據。
例如codec_id都是AV_CODEC_ID_RAWVIDEO的裸數據,但它可能是YUV的裸數據也可能是RGB的裸數據:
// libavformat/isom.c
{ AV_CODEC_ID_RAWVIDEO, MKTAG('r', 'a', 'w', ' ') }, /* uncompressed RGB */
{ AV_CODEC_ID_RAWVIDEO, MKTAG('y', 'u', 'v', '2') }, /* uncompressed YUV422 */
{ AV_CODEC_ID_RAWVIDEO, MKTAG('2', 'v', 'u', 'y') }, /* uncompressed 8-bit 4:2:2 */
{ AV_CODEC_ID_RAWVIDEO, MKTAG('y', 'u', 'v', 's') }, /* same as 2VUY but byte-swapped */
又例如codec_id都是AV_CODEC_ID_H264,但實際上也有許多細分類型:
// libavformat/isom.c
{ AV_CODEC_ID_H264, MKTAG('a', 'v', 'c', '1') }, /* AVC-1/H.264 */
{ AV_CODEC_ID_H264, MKTAG('a', 'v', 'c', '2') },
{ AV_CODEC_ID_H264, MKTAG('a', 'v', 'c', '3') },
{ AV_CODEC_ID_H264, MKTAG('a', 'v', 'c', '4') },
{ AV_CODEC_ID_H264, MKTAG('a', 'i', '5', 'p') }, /* AVC-Intra 50M 720p24/30/60 */
{ AV_CODEC_ID_H264, MKTAG('a', 'i', '5', 'q') }, /* AVC-Intra 50M 720p25/50 */
{ AV_CODEC_ID_H264, MKTAG('a', 'i', '5', '2') }, /* AVC-Intra 50M 1080p25/50 */
{ AV_CODEC_ID_H264, MKTAG('a', 'i', '5', '3') }, /* AVC-Intra 50M 1080p24/30/60 */
{ AV_CODEC_ID_H264, MKTAG('a', 'i', '5', '5') }, /* AVC-Intra 50M 1080i50 */
{ AV_CODEC_ID_H264, MKTAG('a', 'i', '5', '6') }, /* AVC-Intra 50M 1080i60 */
{ AV_CODEC_ID_H264, MKTAG('a', 'i', '1', 'p') }, /* AVC-Intra 100M 720p24/30/60 */
{ AV_CODEC_ID_H264, MKTAG('a', 'i', '1', 'q') }, /* AVC-Intra 100M 720p25/50 */
{ AV_CODEC_ID_H264, MKTAG('a', 'i', '1', '2') }, /* AVC-Intra 100M 1080p25/50 */
可以看出來codec_tag是通過4個字母去表示的,我們來看看MKTAG的定義:
#define MKTAG(a,b,c,d) ((a) | ((b) << 8) | ((c) << 16) | ((unsigned)(d) << 24))
最終它得到的是一個整數,例如MKTAG('a', 'v', 'c', '1')得到的值是0x31637661
- 0x31 =1
- 0x63 = c
- 0x76 = v
- 0x61 = a
我們可以用av_fourcc2str這個函數將最終的整數轉換回字符串
回過頭來看看這個判斷:
if(av_codec_get_id(outputFormatContext->oformat->codec_tag, stream->codecpar->codec_tag) != stream->codecpar->codec_id)
大部分情況下如果codec_tag在輸出流不支持的情況下av_codec_get_id拿到的是AV_CODEC_ID_NONE,所以大部分情況可以等價於:
if(av_codec_get_id(outputFormatContext->oformat->codec_tag, stream->codecpar->codec_tag) != AV_CODEC_ID_NONE)
不過也存在都是MKTAG('l', 'p', 'c', 'm'),但codec_id可能是AV_CODEC_ID_PCM_S16BE或者AV_CODEC_ID_PCM_S16LE的情況:
{ AV_CODEC_ID_PCM_S16BE, MKTAG('l', 'p', 'c', 'm') },
{ AV_CODEC_ID_PCM_S16LE, MKTAG('l', 'p', 'c', 'm') },
所以最好還是和原本的codec_id做比較會靠譜點。
寫入視頻數據
接着就是視頻數據的寫入了,主要有三個步驟,寫入文件頭、讀取本地視頻包並寫入輸出視頻流、寫入文件結尾:
// 設置flvflags爲no_duration_filesize用於解決下面的報錯
// [flv @ 0x14f808e00] Failed to update header with correct duration.
// [flv @ 0x14f808e00] Failed to update header with correct filesize
AVDictionary * opts = NULL;
av_dict_set(&opts, "flvflags", "no_duration_filesize", 0);
if(avformat_write_header(outputFormatContext, opts ? &opts : NULL) < 0) {
cout << "write header to " << destUrl << " failed" << endl;
break;
}
// 創建創建AVPacket接收數據包
// 無論是壓縮的音頻流還是壓縮的視頻流,都是由一個個數據包組成的
// 解碼的過程實際就是從文件流中讀取一個個數據包傳給解碼器去解碼
// 對於視頻,它通常應包含一個壓縮幀
// 對於音頻,它可能是一段壓縮音頻、包含多個壓縮幀
// 在不需要的時候可以通過av_packet_free釋放
packet = av_packet_alloc();
if(NULL == packet) {
cout << "can't alloc packet" << endl;
break;
}
...
// 從文件流裏面讀取出數據包,這裏的數據包是編解碼層的壓縮數據
while(av_read_frame(inputFormatContext, packet) >= 0) {
// 我們以視頻軌道爲基準去同步時間
// 如果時間還沒有到就添加延遲,避免向服務器推流速度過快
...
// 往輸出流寫入數據
av_interleaved_write_frame(outputFormatContext, packet);
// 寫入成之後壓縮數據包的數據就不需要了,將它釋放
av_packet_unref(packet);
}
// 寫入視頻尾部信息
av_write_trailer(outputFormatContext);
幀同步
由於av_read_frame這裏讀取出來的是未解碼的壓縮數據速度很快,如果不做控制一下子就發送完成了,會造成數據堆積在服務器上。這裏我們忽略網絡傳輸耗時,依然通過視頻包的pts做一定的同步:
while(av_read_frame(inputFormatContext, packet) >= 0) {
// 我們以視頻軌道爲基準去同步時間
// 如果時間還沒有到就添加延遲,避免向服務器推流速度過快
if(videoStreamIndex == packet->stream_index) {
if(AV_NOPTS_VALUE == packet->pts) {
// 有些視頻流不帶pts數據,按30fps將間隔統一成32ms
av_usleep(32000);
} else {
// 帶pts數據的視頻流,我們計算出每一幀應該在什麼時候播放
int64_t nowTime = av_gettime() - startTime;
int64_t pts = packet->pts * 1000 * 1000 * timeBaseFloat;
if(pts > nowTime) {
av_usleep(pts - nowTime);
}
}
}
// 往輸出流寫入數據
av_interleaved_write_frame(outputFormatContext, packet);
// 寫入成之後壓縮數據包的數據就不需要了,將它釋放
av_packet_unref(packet);
}
資源釋放
等視頻流讀寫完成之後就是最後的資源釋放收尾工作了:
if(NULL != packet) {
av_packet_free(&packet);
}
if(NULL != outputFormatContext) {
if(NULL != outputFormatContext->pb) {
avio_close(outputFormatContext->pb);
}
avformat_free_context(outputFormatContext);
}
if(NULL != inputFormatContext) {
avformat_close_input(&inputFormatContext);
}
其他
源碼和上篇博客的是同一個倉庫,編譯之後可以通過-s參數推流到服務器:
./demo -s video.flv rtmp://服務器ip/live/livestream
推流的同時就能使用-p參數去拉流進行實時播放:
./demo -p rtmp://服務器ip/live/livestream
這個demo只是簡單的將本地視頻文件推到服務器,實際上我們可以對他做些修改就能實現將攝像頭的視頻流推到服務器了。