從零開始仿寫一個抖音App——基於FFmpeg的極簡視訊播放器
本文首發於簡書——何時夕,搬運轉載請註明出處,否則將追究版權責任。交流qq群:859640274
ofollow,noindex">GitHub/">GitHub地址
好久不見,最近加班比較多所以第二篇音視訊方面的文章 delay 了一週,大家多包涵哈。本文預計閱讀時間二十分鐘。
本文分為以下章節,讀者可以按需閱讀
- 1.FFmpeg原始碼食用 ——Clion中編譯、修改、引用FFmpeg原始碼
- 2.FFmpeg Api食用 ——FFmpeg 資料結構以及官方 demo 解析
- 3.極簡視訊播放器 ——寫一個基於 FFmpeg 的極簡 Android 視訊播放器
一、FFmpeg原始碼食用
注意事項:
- 1.需要一些 git 的知識,git中文文件 。
- 2.我的FFmpeg :我 fork 的 FFmpeg 專案,原始碼的編譯已經完成,編譯的 shell 指令碼在根目錄下。
- 3.FFmpeg-learing :本文章的示例程式碼
- 4.下面程式碼塊中,使用-----程式碼塊x,本文發自簡書、掘金:何時夕----- 來區分各個程式碼塊,該文字不屬於程式碼的一部分
- 5.下面使用project 指代 clone 下來的 FFmpeg 專案的路徑。
- 6.下面的操作都是基於 Mac 平臺,linux 平臺應該也能順利執行,win 平臺的話筆者實在沒時間去折騰(靠你們自己啦 )。
- 7.開始前需要安裝一些前置軟體:Clion(百度 )、make(mac 可以用 brew 裝、linux 可以用 apt 裝 )
1.開始
拿到一個專案,我們一般有兩種方式可以使用它:一個是使用它編譯打包後的產物,一個是自己引用他的專案整合到自己的專案中。我們在這一章就來講講如何食用 FFmpeg 的原始碼,將我們的程式碼寫入 FFmpeg專案中,然後編譯到 android 專案中。FFmpeg-learing ,強烈建議大家依照專案程式碼進行文章的閱讀。
- 1.首先將FFmpeg官方專案 fork 到我們自己的 github 上,以便以後對這個專案的修改。
- 2.clone 自己的 FFmpeg 專案到電腦上。
- 3.以後我的程式碼修改和編譯會基於 FFmpeg 3.3.8 這個版本(這個版本好編譯一點 ),所以我們需要新建一個分支local_build_base_on_3.3.8 。然後使用git reset --hard 18c9d5d3e80dc0b47e0a260b51f5230bdd499e8b 來到 FFmpeg 的 tag 為 n3.3.8 這個 commit 上。
-
4.現在我們就可以開始編譯程式碼了。編譯的流程網上很多,我就簡單說一下。
- 1.將project/configure 檔案中 3305-3308行,這四行程式碼換成程式碼塊1 中的程式碼。
- 2.將程式碼塊2 中的程式碼儲存為project/build_android.sh 檔案,然後執行./build_android.sh 命令。
-----程式碼塊1,本文發自簡書、掘金:何時夕----- # SLIBNAME_WITH_MAJOR='$(SLIBNAME).$(LIBMAJOR)' # LIB_INSTALL_EXTRA_CMD='$$(RANLIB) "$(LIBDIR)/$(LIBNAME)"' # SLIB_INSTALL_NAME='$(SLIBNAME_WITH_VERSION)' # SLIB_INSTALL_LINKS='$(SLIBNAME_WITH_MAJOR) $(SLIBNAME)' SLIBNAME_WITH_MAJOR='$(SLIBPREF)$(FULLNAME)-$(LIBMAJOR)$(SLIBSUF)' LIB_INSTALL_EXTRA_CMD='$$(RANLIB) "$(LIBDIR)/$(LIBNAME)"' SLIB_INSTALL_NAME='$(SLIBNAME_WITH_MAJOR)' SLIB_INSTALL_LINKS='$(SLIBNAME)'
-----程式碼塊2,本文發自簡書、掘金:何時夕----- #!/bin/bash # 切換到 FFmpeg 的目錄 cd /Users/whensunset/AndroidStudioProjects/KSVideoProject/ffmpeg # NDK的路徑,根據自己的安裝位置進行設定 export NDK=/Users/whensunset/AndroidStudioProjects/KSVideoProject/android-ndk-r14b export SYSROOT=$NDK/platforms/android-16/arch-arm/ export TOOLCHAIN=$NDK/toolchains/arm-linux-androideabi-4.9/prebuilt/darwin-x86_64 export CPU=arm # 配置編譯後的產物放置路徑 export PREFIX=$(pwd)/android/$CPU export ADDI_CFLAGS="-marm" # 建立一個方法,這個方法使用 configure 這個檔案傳入一些引數來對 FFmpeg 進行編譯,可以使用 configure -help 命令來對引數進行了解 function build_one { ./configure \ --prefix=$PREFIX \ --target-os=linux \ --cross-prefix=$TOOLCHAIN/bin/arm-linux-androideabi- \ --arch=arm \ --sysroot=$SYSROOT \ --extra-cflags="-Os -fpic $ADDI_CFLAGS" \ --extra-ldflags="$ADDI_LDFLAGS" \ --cc=$TOOLCHAIN/bin/arm-linux-androideabi-gcc \ --nm=$TOOLCHAIN/bin/arm-linux-androideabi-nm \ --enable-shared \ --enable-runtime-cpudetect \ --enable-gpl \ --enable-small \ --enable-cross-compile \ --disable-debug \ --disable-static \ --disable-doc \ --disable-asm \ --disable-ffmpeg \ --disable-ffplay \ --disable-ffprobe \ --disable-ffserver \ --enable-postproc \ --enable-avdevice \ --disable-symver \ --disable-stripping \ $ADDITIONAL_CONFIGURE_FLAG sed -i '' 's/HAVE_LRINT 0/HAVE_LRINT 1/g' config.h sed -i '' 's/HAVE_LRINTF 0/HAVE_LRINTF 1/g' config.h sed -i '' 's/HAVE_ROUND 0/HAVE_ROUND 1/g' config.h sed -i '' 's/HAVE_ROUNDF 0/HAVE_ROUNDF 1/g' config.h sed -i '' 's/HAVE_TRUNC 0/HAVE_TRUNC 1/g' config.h sed -i '' 's/HAVE_TRUNCF 0/HAVE_TRUNCF 1/g' config.h sed -i '' 's/HAVE_CBRT 0/HAVE_CBRT 1/g' config.h sed -i '' 's/HAVE_RINT 0/HAVE_RINT 1/g' config.h make clean make -j8 make install } ## 執行前面建立的編譯 FFmpeg 的方法 build_one
-
5.不出意外的話,我們會在project/android/arm
看見了include
和lib
這兩個資料夾。
- 1.include:瞭解 c/c++ 的同學知道,include 檔案是 c/c++ 的介面定義檔案,可以比作 java 中的介面,用來將內部 api 暴露給外部。
- 2.lib:這裡裡面就是 android 中可以使用的 so 檔案了。
- 3.我們可以根據 include 檔案中提供的函式定義,來呼叫 so 檔案中被暴露到外部的 api。
- 6.上面就是我們整個 FFmpeg 的編譯過程。
2.修改FFmpeg原始碼
本小節我們來聊聊怎麼修改 FFmpeg 的原始碼,然後自動化的在我們的 android 專案中編譯和打包。
在Clion 中編輯 FFmpeg 原始碼:
- 1.首先我們在上面一節已經得 FFmpeg 的原始碼了,此時我們需要開啟 Clion,然後點選import project from sources 選擇 project 資料夾,按 Clion 的預設設定將原始碼匯入。
- 2.這個時候我們會看見 Clion 會自動生成CmakeLists.txt 的檔案,裡面引入了原始碼中所有可編譯的檔案。
- 3.為了有一個乾淨的 git 專案,所以需要在.gitignore 裡面加上一些檔案的過濾。如程式碼塊3
----程式碼塊3,本文發自簡書、掘金:何時夕----- *.version *.ptx *.ptx.c /config.asm /config.h .idea /.idea /cmake-build-debug /android *.log
-
4.匯入完成之後,大家會發現很多檔案裡面會報紅,然後一些被 include 的標頭檔案都找不到。這個是正常現象,因為我們有專門的指令碼來編譯程式碼,Clion只是作為一個編輯器來使用,所以報紅的地方不影響我們接下來的操作。如果你實在看不順眼的話,可以嘗試用 Clion 的 Auto Import 快捷鍵來看見一個就糾正一個。
-
5.現在我們就能愉快的編輯 FFmpeg 的原始碼了。我們在project/libavcodec/allcodecs.c/avcodec_register_all 這個方法裡面加一行初學者的標配av_log(NULL, AV_LOG_DEBUG, "hello world");
-
6.現在可以修改原始碼了,也有指令碼能編譯原始碼了,一個簡單的將 so 檔案引入 android 專案的方法就是手動編譯然後拷貝 so 檔案到 android 專案中。但我們是程式設計師,我們需要方便一點的方式來構建這個流程。
- 1.首先我們在從零開始仿寫一個抖音App——音視訊開篇 這篇文章中介紹了怎樣將 so 檔案引入 android 專案然後在 jni 層呼叫,這裡我就不一一贅述了。
- 2.那麼此時我們只需要在我們需要的時候編譯 FFmpeg 的原始碼,然後將生成的 so 檔案替換老的 so 檔案就行了。如程式碼塊4
- 3.現在有了自動編譯拷貝的指令碼了,我們需要將這個指令碼在 gradle 編譯專案的時候執行。如程式碼塊5 ,我們將裡面的程式碼放到 app moudle 的build.gradle 檔案中。
- 4.現在只要點選一下 run,就會發現Gradle Console 裡面會輸出 FFmpeg 編譯時的輸出 log。至此我們就能愉快的修改和使用 FFmpeg 的原始碼了。
----程式碼塊4,本文發自簡書、掘金:何時夕----- #!/usr/bin/env bash # exit 不註釋的時候,表示 android 專案編譯的時候不需要編譯 ffmepg,註釋的時候,表示 android 專案編譯的時候要編譯 ffmpeg。 # exit # 執行 FFmpeg 原始碼專案中的編譯指令碼 sh /Users/whensunset/AndroidStudioProjects/KSVideoProject/ffmpeg/build_android.sh # 當前專案的 so 檔案的存放目錄,需要改成自己的 so_path="/Users/whensunset/AndroidStudioProjects/KSVideoProject/FFmpeglearning/app/src/main/jni/ffmpeg/armeabi/" # 所有 so 檔案編譯生成後的預設命名 libavcodec_name="libavcodec-57.so" libavdeivce_name="libavdevice-57.so" libavfilter_name="libavfilter-6.so" libavformat_name="libavformat-57.so" libavutil_name="libavutil-55.so" libpostproc_name="libpostproc-54.so" libswresample_name="libswresample-2.so" libseacale_name="libswscale-4.so" # 刪除當前專案中的老的 so 檔案刪除 rm ${so_path}${libavcodec_name} rm ${so_path}${libavdeivce_name} rm ${so_path}${libavfilter_name} rm ${so_path}${libavformat_name} rm ${so_path}${libavutil_name} rm ${so_path}${libpostproc_name} rm ${so_path}${libswresample_name} rm ${so_path}${libseacale_name} # FFmpeg 原始碼專案中,編譯好的 so 檔案的路徑,需要改成自己的 build_so_path="/Users/whensunset/AndroidStudioProjects/KSVideoProject/ffmpeg/android/arm/lib/" # 將新編譯的 so 檔案拷貝到當前專案的 so 目錄下 cd /Users/whensunset/AndroidStudioProjects/KSVideoProject/FFmpeglearning/app cp ${build_so_path}${libavcodec_name} ${so_path}${libavcodec_name} cp ${build_so_path}${libavdeivce_name} ${so_path}${libavdeivce_name} cp ${build_so_path}${libavfilter_name} ${so_path}${libavfilter_name} cp ${build_so_path}${libavformat_name} ${so_path}${libavformat_name} cp ${build_so_path}${libavutil_name} ${so_path}${libavutil_name} cp ${build_so_path}${libpostproc_name} ${so_path}${libpostproc_name} cp ${build_so_path}${libswresample_name} ${so_path}${libswresample_name} cp ${build_so_path}${libseacale_name} ${so_path}${libseacale_name}
----程式碼塊5,本文發自簡書、掘金:何時夕----- // 建立一個 build_ffmpeg 的 task,其負責執行shell 指令碼 task build_ffmpeg { doLast { exec { commandLine 'sh', '/Users/whensunset/AndroidStudioProjects/KSVideoProject/FFmpeglearning/app/build_ffmpeg.sh' } } } // 將 build_ffmpeg 這個 task 作為編譯的前置任務來執行。 tasks.whenTaskAdded { task -> task.dependsOn 'build_ffmpeg' }
二、FFmpeg 解碼
上篇文章中我們簡單分析了一個 FFmpeg 的官方 demo。幾周過去了,目前專案中已經有五個移植成功的官方 demo了,而且都是可以執行的。所以這一章我就來分析解碼 demo。為最後一章寫一個簡單的 android 視訊播放器打基礎。
FFmpeg-learing :本章示例專案。
從零開始仿寫一個抖音App——音視訊開篇 :上一篇文章。
1.開始
- 1.首先專案比較簡單,入口是 MainActivity,裡面有很多按鈕,每一個功能都由一個按鈕觸發。
- 2.點選按鈕之後,會開啟一個執行緒來執行相應的程式碼,這裡的程式碼最終會進入到 c++ 程式碼中使用 FFmpeg 的 Api 來進行視訊檔案的處理。
- 3.FFmpegPlayer 這個 java 類是用來呼叫 c++ 程式碼的類。
- 4.player.cpp 是 native 程式碼的入口。
-
5.同學們應該還沒忘記上一章中我們在 FFmpeg 中新增的 log 吧。可能有些人會問,那個 log 到底在哪裡可以看見呢?在 c/c++ 中會有一個標準輸出流的概念,Ffmpeg 的 log 都是向標準輸出流中輸出的,這個標準輸出流一般會向控制檯之類的東西里面上面列印資料,我們可以將這裡 log 的輸出流重定向到 android 的日誌裡面,這樣我們就能在 Android Studio 中的 Logcat 裡面看見它了。
- 1.首先大家看 player.cpp 檔案中有程式碼塊6 中的程式碼,這裡我們先定義了兩個巨集,巨集裡面分別是 ndk 中提供的 android 的日誌列印方法,我們將日誌的 TAG 設定為 “FFmpeg”。後面我們只需要在 AS 的控制檯中過濾這個欄位就能看見 FFmpeg 內部輸出的日誌了。
- 2.然後我們定義了一個方法,這個方法我們期望能在 FFmpeg 列印 log 之後呼叫,然後將 FFmpeg 列印的 log 交給這個方法,從而將 log 輸出到 android 的日誌中。
- 3.再看程式碼塊7 ,這個程式碼在 player.cpp 中,這裡 FFmpeg 提供了 av_log_set_callback 方法,他會將我們剛剛定義的方法作為一個函式指標傳入 FFmpeg 中進行持有,只要 FFmpeg 進行了 log 呼叫,那麼就會觸發我們在2中定義的方法,從而將 FFmpeg 的日誌輸出流,重定向到我們的 android 日誌系統中。
- 4.當然我們需要在 FFmpegPlayer 中定義 native 方法,然後在 MainActivity 中進行初始化呼叫。
-----程式碼塊6,本文發自簡書、掘金:何時夕----------- #ifndef LOG_TAG #defineLOG_TAG"FFMPEG" #endif #defineXLOGD(...)__android_log_print(ANDROID_LOG_INFO,LOG_TAG,__VA_ARGS__) #defineXLOGE(...)__android_log_print(ANDROID_LOG_ERROR,LOG_TAG,__VA_ARGS__) static void log_callback_null(void *ptr, int level, const char *fmt, va_list vl) { static int print_prefix = 1; static char prev[1024]; char line[1024]; av_log_format_line(ptr, level, fmt, vl, line, sizeof(line), &print_prefix); strcpy(prev, line); if (level <= AV_LOG_WARNING) { XLOGE("%s", line); } else { XLOGD("%s", line); } }
-----程式碼塊7,本文發自簡書、掘金:何時夕----------- extern "C" JNIEXPORT void JNICALL Java_com_example_whensunset_ffmpeg_1learning_FFmpegPlayer_initFfmpegLog(JNIEnv *env, jobject instance) { av_log_set_callback(log_callback_null); }
2.解碼
- 1.下面的程式碼就是解碼的程式碼,大家可以在示例專案中找到FFMPEG_純淨的解碼器 按鈕點選觸發這個功能。
- 2.注意在執行之前需要在將示例專案中的c.mpeg4 檔案拷貝到手機中的 **/storage/emulated/0/av_test/ **這個目錄下。
-
3.有個前提知識我們需要了解,一個 MP4 檔案解析到螢幕上需要下面這些步驟:
- 1.解封裝:解析 Mp4 檔案的結構,然後讀取檔案中的資料流。
- 2.解碼:1中的資料流是經過編碼演算法壓縮的,一般有 h264、mpeg4等等編碼方式。這一步需要將資料流的每一幀都解碼成類似圖片的形式。
- 3.顯示:將2中解碼出來的影象繪製到螢幕上。
- 4.下面的程式碼主要用途是將我們傳入的 c.mpeg4 檔案直接解碼成 c.yuv 這種原始影象資料,並沒有解封裝的過程。
----程式碼塊8,本文發自簡書、掘金:何時夕----- #include <stdio.h> #include <stdlib.h> #include <string.h> extern "C" { #include "libavcodec/avcodec.h" } #define INBUF_SIZE 4096 static void pgm_save(unsigned char *buf, int wrap, int xsize, int ysize, const char *filename) { FILE *f; int i; f = fopen(filename, "w"); fprintf(f, "P5\n%d %d\n%d\n", xsize, ysize, 255); for (i = 0; i < ysize; i++) fwrite(buf + i * wrap, 1, xsize, f); fclose(f); } static int decode(AVCodecContext *dec_ctx, AVFrame *frame, AVPacket *pkt, const char *filename) { char buf[1024]; int ret; // 將一幀壓縮影象傳入解碼器中 ret = avcodec_send_packet(dec_ctx, pkt); if (ret < 0) { return ret; } while (ret >= 0) { // 從解碼器中取出剛剛傳入的壓縮影象被解碼出來的影象,avcodec_send_packet 和 avcodec_receive_frame 一般是對應的。取出資料成功後,再去取時 ret 會小於0 ret = avcodec_receive_frame(dec_ctx, frame); if (ret < 0) { return ret; } av_log(NULL, AV_LOG_DEBUG, "saving frame %3d\n", dec_ctx->frame_number); fflush(stdout); /* the picture is allocated by the decoder. no need to free it */ snprintf(buf, sizeof(buf), "%s-%d", filename, dec_ctx->frame_number); // ........** // ........** // ........** // ........** // ........** // ........** // ........** // 如上所示,點就是我們平時看見的一幀影象,*是無用資料。一般來說:width指的是一行點的數量,height指的是一列點的數量,linesize[0]指的是 width + *的數量。 // data[0]中存放資料的方式則是這樣:........**........**........**........**........**........**........**將一幀影象平鋪。 // 最終我們存到檔案中的資料就是這樣:........ ........ ........ ........ ........ ........ ........ 中間的空格檔案中不存在,只是為了好看一點 pgm_save(frame->data[0], frame->linesize[0], frame->width, frame->height, filename); } return 0; } char *decode_video(char **argv) { const char *filename, *outfilename; const AVCodec *codec; AVCodecParserContext *parser; AVCodecContext *c = NULL; FILE *f; AVFrame *frame; uint8_t inbuf[INBUF_SIZE + AV_INPUT_BUFFER_PADDING_SIZE]; uint8_t *data; size_t data_size; int ret; AVPacket *pkt; // 輸入和輸出檔案的名稱,輸入檔案是 c.mpeg4,輸出檔案是 c.yuv。 filename = argv[0]; outfilename = argv[1]; // 註冊所有的編解碼器 avcodec_register_all(); // 為 AVPacket 進行初始化,AVPacket 用於一幀壓縮後的影象的資料結構 pkt = av_packet_alloc(); if (!pkt) exit(1); // 將 inbuf 從 INBUF_SIZE 到INBUF_SIZE + AV_INPUT_BUFFER_PADDING_SIZE 這一段的資料都設定為0(這確保了對損壞的MPEG流不會發生過讀) /* set end of buffer to 0 (this ensures that no overreading happens for damaged MPEG streams) */ memset(inbuf + INBUF_SIZE, 0, AV_INPUT_BUFFER_PADDING_SIZE); // 根據名稱來查詢某個編解碼器,這裡我們使用輸入檔案的編解碼器 mpeg4 codec = avcodec_find_decoder_by_name("mpeg4"); if (!codec) { ret = -1111; goto end; } // 根據編解碼器的id,來找到一個 解析器,這個解析器可以用來解析出 mpeg4 檔案流中的一幀壓縮後的資料 parser = av_parser_init(codec->id); if (!parser) { ret = -1112; goto end; } // 根據編解碼器初始化 編碼器的上下文 資料結構。 c = avcodec_alloc_context3(codec); if (!c) { ret = -1113; goto end; } // 打來編解碼器 if ((ret = avcodec_open2(c, codec, NULL)) < 0) { goto end; } // 開啟檔案 f = fopen(filename, "rb"); if (!f) { ret = -1114; goto end; } // 初始化 AV_Frame 這個資料結構,它是用來儲存一幀解碼後的影象的資料結構 frame = av_frame_alloc(); if (!frame) { ret = -1115; goto end; } // 一直迴圈,直到輸入檔案被讀到了最後 while (!feof(f)) { // 從原檔案中讀取4096個位元組 data_size = fread(inbuf, 1, INBUF_SIZE, f); if (!data_size) break; // 4096 的位元組中可能會包含多幀壓縮後的影象,所以這裡每次解析出一幀壓縮影象資料,然後解碼成一幀解碼後圖像資料,然後再迴圈,直至4096個位元組被讀取完畢。 data = inbuf; while (data_size > 0) { // 從4096個位元組中以 data 作為起點,解析出一幀壓縮影象資料到 AV_Packet 中。返回值是壓縮幀的byte大小 if ((ret = av_parser_parse2(parser, c, &pkt->data, &pkt->size, data, data_size, AV_NOPTS_VALUE, AV_NOPTS_VALUE, 0)) < 0) { goto end; } // 將 data 移動到新的起點 data += ret; // 記錄 4096 位元組中剩下的可用位元組大小 data_size -= ret; // 如果 size 大於0表示剛剛讀取資料成功 if (pkt->size) { // 將一個 pkt 包解析成一個 frame decode(c, frame, pkt, outfilename); } } } /* flush the decoder */ decode(c, frame, NULL, outfilename); fclose(f); end: av_parser_close(parser); avcodec_free_context(&c); av_frame_free(&frame); av_packet_free(&pkt); if (ret < 0) { char buf2[500] = {0}; if (ret == -1111) { return (char *) "codec not found"; } else if (ret == -1112) { return (char *) "parser not found"; } else if (ret == -1113) { return (char *) "could not allocate video codec context"; } else if (ret == -1114) { return (char *) "could not open input file"; } else if (ret == -1115) { return (char *) "could not allocate video frame"; } av_strerror(ret, buf2, 1024); return buf2; } else { return (char *) "解碼成功"; } }
三、極簡視訊播放器
最後一章就來介紹一個用 FFmpeg 解碼的極簡視訊播放器。
- 1.首先這個視訊播放器非常簡單,簡單到啥也沒有,只是將從檔案中解碼出來的影象繪製到 surface上面。
- 2.示例程式的使用方法是:將需要播放的視訊以/storage/emulated/0/av_test/b.mp4 ,這個命名拷貝到手機中去。
- 3.更多的資訊,大家可以看程式碼,裡面都有註釋。寫的有點累了,這篇文章就到這吧:)
----程式碼塊9,本文發自簡書、掘金:何時夕----- extern "C" { #include <android/native_window.h> #include <android/native_window_jni.h> #include "libavcodec/avcodec.h" #include "libavformat/avformat.h" #include "libswscale/swscale.h" #include "libavutil/imgutils.h" }; #include <sys/time.h> #include <unistd.h> #include <pthread.h> static AVFormatContext *pFormatCtx; static AVCodecContext *pCodecCtx; static int video_stream_index = -1; static AVCodec *pCodec; static int64_t last_pts = AV_NOPTS_VALUE; static long getCurrentTime() { struct timeval tv; gettimeofday(&tv,NULL); return tv.tv_sec * 1000 + tv.tv_usec / 1000; } struct timeval now; struct timespec outtime; pthread_cond_t cond; pthread_mutex_t mutex; static void sleep(int nHm) { gettimeofday(&now, NULL); now.tv_usec += 1000 * nHm; if (now.tv_usec > 1000000) { now.tv_sec += now.tv_usec / 1000000; now.tv_usec %= 1000000; } outtime.tv_sec = now.tv_sec; outtime.tv_nsec = now.tv_usec * 1000; pthread_cond_timedwait(&cond, &mutex, &outtime); } static int open_input_file(const char *filename) { int ret; // 開啟檔案,確認檔案的封裝格式,然後將檔案的資訊寫入 AVFormatContext 中 if ((ret = avformat_open_input(&pFormatCtx, filename, NULL, NULL)) < 0) { av_log(NULL, AV_LOG_ERROR, "Cannot open input file\n"); return ret; } // 從 AVFormatContext 中解析檔案中的各種流的資訊,比如音訊流、視訊流、字幕流等等 if ((ret = avformat_find_stream_info(pFormatCtx, NULL)) < 0) { av_log(NULL, AV_LOG_ERROR, "Cannot find stream information\n"); return ret; } // 找到根據傳入引數,找到最適合的資料流,和該資料流的編解碼器,這裡傳入 AVMEDIA_TYPE_VIDEO 表示需要找到視訊流 ret = av_find_best_stream(pFormatCtx, AVMEDIA_TYPE_VIDEO, -1, -1, &pCodec, 0); if (ret < 0) { av_log(NULL, AV_LOG_ERROR, "Cannot find a video stream in the input file\n"); return ret; } // 將找到的視訊流,的 index 暫存 video_stream_index = ret; // 根據前面找到的視訊流的編解碼器,構造編解碼器上下文 pCodecCtx = avcodec_alloc_context3(pCodec); if (!pCodecCtx) return AVERROR(ENOMEM); // 使用視訊流的資訊來編解碼器上下文的引數 avcodec_parameters_to_context(pCodecCtx, pFormatCtx->streams[video_stream_index]->codecpar); // 開啟編解碼器 if ((ret = avcodec_open2(pCodecCtx, pCodec, NULL)) < 0) { av_log(NULL, AV_LOG_ERROR, "Cannot open video decoder\n"); return ret; } return 0; } int play(JNIEnv *env, jobject surface) { int ret; char filepath[] = "/storage/emulated/0/av_test/b.mp4"; // 初始化 libavformat 然後 註冊所有的 封裝器,解封裝器 和 協議。 av_register_all(); if (open_input_file(filepath) < 0) { av_log(NULL, AV_LOG_ERROR, "can not open file"); return 0; } // 初始化兩個 儲存解碼後視訊幀 的資料結構,pFrame 表示解碼後的視訊幀,pFrameRGBA 表示將 pFrame 轉換成 RGBA 格式的 視訊幀 AVFrame *pFrame = av_frame_alloc(); AVFrame *pFrameRGBA = av_frame_alloc(); // 計算格式為 RGBA 的視訊幀的 byte 大小,視訊幀的長和寬在解封裝的時候就確定了 int numBytes = av_image_get_buffer_size(AV_PIX_FMT_RGBA, pCodecCtx->width, pCodecCtx->height, 1); // 初始化一塊記憶體,記憶體大小就是 格式為 RGBA 的視訊幀的大小 uint8_t *buffer = (uint8_t *) av_malloc(numBytes * sizeof(uint8_t)); // 填充 buffer av_image_fill_arrays(pFrameRGBA->data, pFrameRGBA->linesize, buffer, AV_PIX_FMT_RGBA, pCodecCtx->width, pCodecCtx->height, 1); // 由於解碼出來的幀格式不是RGBA的,在渲染之前需要進行格式轉換 struct SwsContext *sws_ctx = sws_getContext(pCodecCtx->width, pCodecCtx->height, pCodecCtx->pix_fmt, pCodecCtx->width, pCodecCtx->height, AV_PIX_FMT_RGBA, SWS_BILINEAR, NULL, NULL, NULL); // 獲取native window,即surface ANativeWindow *nativeWindow = ANativeWindow_fromSurface(env, surface); // 獲取視訊寬高 int videoWidth = pCodecCtx->width; int videoHeight = pCodecCtx->height; // 設定native window的buffer大小,可自動拉伸 ANativeWindow_setBuffersGeometry(nativeWindow, videoWidth, videoHeight, WINDOW_FORMAT_RGBA_8888); ANativeWindow_Buffer windowBuffer; av_dump_format(pFormatCtx, 0, filepath, 0); // 初始化 壓縮視訊幀 的資料結構 AVPacket *packet = (AVPacket *) av_malloc(sizeof(AVPacket)); while (1) { long start_time = getCurrentTime(); // 從視訊流中讀取出一幀 壓縮幀 if ((ret = av_read_frame(pFormatCtx, packet)) < 0) { av_log(NULL, AV_LOG_DEBUG, "can not read frame"); break; } // 如果 壓縮幀 是從是 視訊流中讀出來的,那麼就可以被解碼 if (packet->stream_index == video_stream_index) { // 解碼 ret = avcodec_send_packet(pCodecCtx, packet); if (ret < 0) { av_log(NULL, AV_LOG_ERROR, "Error while sending a packet to the decoder\n"); break; } while (ret >= 0) { // 解碼 ret = avcodec_receive_frame(pCodecCtx, pFrame); if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) { break; } else if (ret < 0) { av_log(NULL, AV_LOG_ERROR, "Error while receiving a frame from the decoder\n"); } ANativeWindow_lock(nativeWindow, &windowBuffer, 0); // 將 YUV 格式的資料轉換為 RGBA 格式的資料 sws_scale(sws_ctx, (uint8_t const *const *) pFrame->data, pFrame->linesize, 0, pCodecCtx->height, pFrameRGBA->data, pFrameRGBA->linesize); // 獲取stride uint8_t *dst = (uint8_t *) windowBuffer.bits; int dstStride = windowBuffer.stride * 4; uint8_t *src = pFrameRGBA->data[0]; int srcStride = pFrameRGBA->linesize[0]; // 由於window的stride和幀的stride不同,因此需要逐行復制,逐行將影象幀的資料拷貝到 Surface 的緩衝流中。 int h; for (h = 0; h < videoHeight; h++) { memcpy(dst + h * dstStride, src + h * srcStride, srcStride); } // 為了保持 40毫秒一幀,如果解碼時間很快,那麼就 sleep一會兒 int sleep_time = 40 - (getCurrentTime() - start_time); if (sleep_time > 0) { sleep(sleep_time); } ANativeWindow_unlockAndPost(nativeWindow); } } av_packet_unref(packet); } if (sws_ctx) sws_freeContext(sws_ctx); av_frame_free(&pFrameRGBA); if (pFrame) av_frame_free(&pFrame); if (pCodecCtx) avcodec_close(pCodecCtx); if (pFormatCtx) avformat_close_input(&pFormatCtx); if (buffer) av_free(buffer); return 0; }
四、尾巴
又是一篇文章結尾,最近公司加班太多了,很多計劃都沒有如期進行,希望過了這個月會好一點。不需要打賞,只希望大家能多評論點贊關注,也算是對我的支援和鼓勵。下篇文章見!