概要#
この記事では、まず FFmpeg ビデオデコードをテーマに、FFmpeg がビデオをデコードする際の主要なプロセスと基本原理を紹介します。次に、FFmpeg ビデコードに関連する簡単なアプリケーションについても説明します。これには、既存の FFmpeg ビデオデコードの基礎の上に、特定のタイムラインに従ってビデオを再生する方法や、ビデオ再生中にシークのロジックを追加する方法が含まれます。さらに、ビデオデコード時に見落としがちな詳細についても重点的に紹介し、最後に基本的なビデオデコード機能を持つ VideoDecoder をどのようにラップするかを簡単に説明します。
前書き#
FFmpeg#
FFmpeg は、デジタルオーディオ、ビデオを録音、変換し、ストリームに変換するためのオープンソースのコンピュータプログラムであり、マルチメディアデータを処理および操作するためのライブラリを生成します。その中には、先進的な音声およびビデオデコードライブラリlibavcodec
と音声およびビデオフォーマット変換ライブラリlibavformat
が含まれています。
FFmpeg の 6 つの一般的な機能モジュール#
- libavformat:mp4、flv などのファイルフォーマットや、rtmp、rtsp などのネットワークプロトコルのパッケージングとデパッケージングライブラリ;
- libavcodec:音声およびビデオデコードのコアライブラリ;
- libavfilter:音声、ビデオ、字幕フィルタライブラリ;
- libswscale:画像フォーマット変換ライブラリ;
- libswresample:音声リサンプリングライブラリ;
- libavutil:ツールライブラリ
ビデオデコードの基礎入門#
- デマルチプレクシング(Demux):デマルチプレクシングはデパッケージングとも呼ばれます。ここでは、パッケージフォーマットという概念があります。パッケージフォーマットは、音声とビデオの組み合わせフォーマットを指し、一般的には mp4、flv、mkv などがあります。簡単に言えば、パッケージは音声ストリーム、ビデオストリーム、字幕ストリーム、およびその他の添付ファイルを一定のルールに従って組み合わせたものです。デパッケージングは、ストリーミングメディアファイルを音声データやビデオデータなどに分解する役割を果たします。この時、分割されたデータは圧縮エンコードされており、一般的なビデオ圧縮データフォーマットには h264 があります。
- デコード(Decode):簡単に言えば、圧縮されたエンコードデータを元のビデオピクセルデータに解凍することです。一般的な元のビデオピクセルデータフォーマットには yuv があります。
- 色空間変換(Color Space Convert):通常、画像ディスプレイは RGB モデルを使用して画像を表示しますが、画像データを転送する際には YUV モデルを使用することで帯域幅を節約できます。したがって、画像を表示する際には、yuv ピクセルフォーマットのデータを rgb ピクセルフォーマットに変換してからレンダリングする必要があります。
- レンダリング(Render):前述のようにデコードされ、色空間変換された各ビデオフレームのデータをグラフィックカードに送信して、画面に描画します。
一、FFmpeg 導入前の準備作業#
1.1 FFmpeg so ライブラリのコンパイル#
- FFmpeg の公式サイトからソースコードライブラリをダウンロードして解凍します;
- NDK ライブラリをダウンロードして解凍します;
- 解凍後の FFmpeg ソースコードライブラリディレクトリ内の configure を設定し、ハイライトされた部分のいくつかのパラメータを以下の内容に変更します。主な目的は、Android で使用できる名前 - バージョン.so ファイルの形式を生成することです;
# ······
# build settings
SHFLAGS='-shared -Wl,-soname,$$(@F)'
LIBPREF="lib"
LIBSUF=".a"
FULLNAME='$(NAME)$(BUILDSUF)'
LIBNAME='$(LIBPREF)$(FULLNAME)$(LIBSUF)'
SLIBPREF="lib"
SLIBSUF=".so"
SLIBNAME='$(SLIBPREF)$(FULLNAME)$(SLIBSUF)'
SLIBNAME_WITH_VERSION='$(SLIBNAME).$(LIBVERSION)'
# 変更された設定
SLIBNAME_WITH_MAJOR='$(SLIBNAME)$(FULLNAME)-$(LIBMAJOR)$(SLIBSUF)'
LIB_INSTALL_EXTRA_CMD='$$(RANLIB)"$(LIBDIR)/$(LIBNAME)"'
SLIB_INSTALL_NAME='$(SLIBNAME_WITH_MAJOR)'
SLIB_INSTALL_LINKS='$(SLIBNAME)'
# ······
- FFmpeg ソースコードライブラリディレクトリに新しいスクリプトファイル
build_android_arm_v8a.sh
を作成し、ファイル内に NDK のパスを設定し、以下の他の内容を入力します;
# 前回のコンパイルをクリア
make clean
# ここでNDKのパスを設定します
export NDK=/Users/bytedance/Library/Android/sdk/ndk/21.4.7075529
TOOLCHAIN=$NDK/toolchains/llvm/prebuilt/darwin-x86_64
function build_android
{
./configure \
--prefix=$PREFIX \
--disable-postproc \
--disable-debug \
--disable-doc \
--enable-FFmpeg \
--disable-doc \
--disable-symver \
--disable-static \
--enable-shared \
--cross-prefix=$CROSS_PREFIX \
--target-os=android \
--arch=$ARCH \
--cpu=$CPU \
--cc=$CC \
--cxx=$CXX \
--enable-cross-compile \
--sysroot=$SYSROOT \
--extra-cflags="-Os -fpic $OPTIMIZE_CFLAGS" \
--extra-ldflags="$ADDI_LDFLAGS"
make clean
make -j16
make install
echo "============================ build android arm64-v8a success =========================="
}
# arm64-v8a
ARCH=arm64
CPU=armv8-a
API=21
CC=$TOOLCHAIN/bin/aarch64-linux-android$API-clang
CXX=$TOOLCHAIN/bin/aarch64-linux-android$API-clang++
SYSROOT=$NDK/toolchains/llvm/prebuilt/darwin-x86_64/sysroot
CROSS_PREFIX=$TOOLCHAIN/bin/aarch64-linux-android-
PREFIX=$(pwd)/android/$CPU
OPTIMIZE_CFLAGS="-march=$CPU"
echo $CC
build_android
- NDK フォルダ内のすべてのファイルの権限を設定します
chmod 777 -R NDK
; - ターミナルでスクリプト
./build_android_arm_v8a.sh
を実行し、FFmpeg のコンパイルを開始します。コンパイル成功後のファイルは FFmpeg のandroid
ディレクトリ内にあり、複数の.so ファイルが生成されます;
- arm-v7a をコンパイルするには、上記のスクリプトをコピーして以下の
build_android_arm_v7a.sh
の内容に変更するだけです。
#armv7-a
ARCH=arm
CPU=armv7-a
API=21
CC=$TOOLCHAIN/bin/armv7a-linux-androideabi$API-clang
CXX=$TOOLCHAIN/bin/armv7a-linux-androideabi$API-clang++
SYSROOT=$NDK/toolchains/llvm/prebuilt/darwin-x86_64/sysroot
CROSS_PREFIX=$TOOLCHAIN/bin/arm-linux-androideabi-
PREFIX=$(pwd)/android/$CPU
OPTIMIZE_CFLAGS="-mfloat-abi=softfp -mfpu=vfp -marm -march=$CPU "
1.2 Android に FFmpeg の so ライブラリを導入する#
- NDK 環境、CMake ビルドツール、LLDB(C/C++ コードデバッガ);
- 新しい C++ モジュールを作成すると、通常以下の重要なファイルが生成されます:
CMakeLists.txt
、native-lib.cpp
、MainActivity
; app/src/main/
ディレクトリ内に新しいディレクトリを作成し、jniLibs
と名付けます。これは Android Studio が so 動的ライブラリをデフォルトで配置するディレクトリです。次に、jniLibs
ディレクトリ内にarm64-v8a
ディレクトリを作成し、コンパイルした.so ファイルをこのディレクトリに貼り付けます。次に、コンパイル時に生成された.h ヘッダーファイル(FFmpeg が公開するインターフェース)をcpp
ディレクトリ内のinclude
に貼り付けます。上記の.so 動的ライブラリディレクトリと.h ヘッダーファイルディレクトリは、CMakeLists.txt
内で明示的に宣言され、リンクされます;- 最上層の
MainActivity
では、ここで C/C++ コードでコンパイルされたライブラリnative-lib
をロードします。native-lib
はCMakeLists.txt
で "ffmpeg" というライブラリに追加されているため、System.loadLibrary()
に入力するのは "ffmpeg" です;
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// ネイティブメソッドの呼び出しの例
sample_text.text = stringFromJNI()
}
// 外部参照メソッドを宣言します。このメソッドはC/C++層のコードに対応しています。
external fun stringFromJNI(): String
companion object {
// init{}内でC/C++でコンパイルされたライブラリをロードします:ffmpeg
// ライブラリ名の定義と追加はCMakeLists.txtで行います
init {
System.loadLibrary("ffmpeg")
}
}
}
native-lib.cpp
は C++ インターフェースファイルで、Java 層で宣言された external メソッドがここで実装されます;
#include <jni.h>
#include <string>
extern "C" JNIEXPORT jstring JNICALL
Java_com_bytedance_example_MainActivity_stringFromJNI(
JNIEnv *env,
jobject /* this */) {
std::string hello = "Hello from C++";
return env->NewStringUTF(hello.c_str());
}
CMakeLists.txt
はビルドスクリプトで、native-lib
という so ライブラリをコンパイルするためのビルド情報を設定することを目的としています;
# Android StudioでCMakeを使用する方法についての詳細は、ドキュメントを参照してください。
# https://d.android.com/studio/projects/add-native-code.html
# ネイティブライブラリをビルドするために必要なCMakeの最小バージョンを設定します。
cmake_minimum_required(VERSION 3.10.2)
# プロジェクトを宣言し、名前を付けます。
project("ffmpeg")
# ライブラリを作成し、名前を付け、ソースコードへの相対パスを設定します。
# 複数のライブラリを定義でき、CMakeがそれらをビルドします。
# Gradleは自動的に共有ライブラリをAPKにパッケージします。
# soライブラリとヘッダーファイルのディレクトリを定義し、後で使用しやすくします
set(FFmpeg_lib_dir ${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
set(FFmpeg_head_dir ${CMAKE_SOURCE_DIR}/FFmpeg)
# ヘッダーファイルディレクトリを追加します
include_directories(
FFmpeg/include
)
add_library( # ライブラリの名前を設定します。
ffmmpeg
# ライブラリを共有ライブラリとして設定します。
SHARED
# ソースファイルへの相対パスを提供します。
native-lib.cpp
)
# 指定されたプリビルドライブラリを検索し、パスを変数として保存します。
# CMakeはデフォルトでシステムライブラリを検索パスに含めるため、追加したい公開NDKライブラリの名前だけを指定すればよいです。
# CMakeはライブラリが存在することを確認してからビルドを完了します。
# FFmpeg関連のsoライブラリを追加します
add_library( avutil
SHARED
IMPORTED )
set_target_properties( avutil
PROPERTIES IMPORTED_LOCATION
${FFmpeg_lib_dir}/libavutil.so )
add_library( swresample
SHARED
IMPORTED )
set_target_properties( swresample
PROPERTIES IMPORTED_LOCATION
${FFmpeg_lib_dir}/libswresample.so )
add_library( avcodec
SHARED
IMPORTED )
set_target_properties( avcodec
PROPERTIES IMPORTED_LOCATION
${FFmpeg_lib_dir}/libavcodec.so )
find_library( # パス変数の名前を設定します。
log-lib
# CMakeが見つけるように指定するNDKライブラリの名前を指定します。
log)
# ターゲットライブラリにリンクするライブラリを指定します。
# このビルドスクリプトで定義したライブラリ、プリビルドのサードパーティライブラリ、またはシステムライブラリを複数リンクできます。
target_link_libraries( # ターゲットライブラリを指定します。
audioffmmpeg
# 前に追加したFFmpeg.soライブラリをターゲットライブラリnative-libにリンクします
avutil
swresample
avcodec
-landroid
# ターゲットライブラリをNDKに含まれるログライブラリにリンクします。
${log-lib})
- 上記の操作により、FFmpeg が Android プロジェクトに導入されます。
二、FFmpeg によるビデオデコードの原理と詳細#
2.1 主なプロセス#
2.2 基本原理#
2.2.1 一般的な FFmpeg インターフェース#
// 1 AVFormatContextを割り当てる
avformat_alloc_context();
// 2 ファイル入力ストリームを開く
avformat_open_input(AVFormatContext **ps, const char *url,
const AVInputFormat *fmt, AVDictionary **options);
// 3 入力ファイルからデータストリーム情報を抽出する
avformat_find_stream_info(AVFormatContext *ic, AVDictionary **options);
// 4 コーデックコンテキストを割り当てる
avcodec_alloc_context3(const AVCodec *codec);
// 5 データストリームに関連するコーデックパラメータに基づいてコーデックコンテキストを埋める
avcodec_parameters_to_context(AVCodecContext *codec,
const AVCodecParameters *par);
// 6 登録されたコーデックを検索する
avcodec_find_decoder(enum AVCodecID id);
// 7 コーデックを開く
avcodec_open2(AVCodecContext *avctx, const AVCodec *codec, AVDictionary **options);
// 8 ストリームから圧縮フレームデータを抽出し続ける。取得されるのは1フレームのビデオの圧縮データ
av_read_frame(AVFormatContext *s, AVPacket *pkt);
// 9 デコーダに生の圧縮データを送信する(compressed data)
avcodec_send_packet(AVCodecContext *avctx, const AVPacket *avpkt);
// 10 デコーダから出力されたデコードデータを受信する
avcodec_receive_frame(AVCodecContext *avctx, AVFrame *frame);
2.2.2 ビデオデコードの全体的な考え方#
- まず、
libavformat
を登録し、すべてのコーデック、マルチプレクサ / デマルチプレクサ、プロトコルなどを登録する必要があります。これは、FFmpeg に基づくすべてのアプリケーションで最初に呼び出される関数であり、この関数を呼び出さなければ FFmpeg の各機能を正常に使用できません。さらに、最新の FFmpeg バージョンでは、この行のコードを追加する必要はなくなりました;
av_register_all();
- ビデオファイルを開き、ファイル内のデータストリーム情報を抽出します;
auto av_format_context = avformat_alloc_context();
avformat_open_input(&av_format_context, path_.c_str(), nullptr, nullptr);
avformat_find_stream_info(av_format_context, nullptr);
- 次に、ビデオメディアストリームのインデックスを取得し、ファイル内のビデオメディアストリームを見つける必要があります;
int video_stream_index = -1;
for (int i = 0; i < av_format_context->nb_streams; i++) {
// ビデオメディアストリームのインデックスを見つける
if (av_format_context->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
video_stream_index = i;
LOGD(TAG, "find video stream index = %d", video_stream_index);
break;
}
}
- ビデオメディアストリームを取得し、デコーダコンテキストを取得し、デコーダコンテキストのパラメータ値を設定し、デコーダを開きます;
// ビデオメディアストリームを取得
auto stream = av_format_context->streams[video_stream_index];
// 登録されたコーデックを見つける
auto codec = avcodec_find_decoder(stream->codecpar->codec_id);
// デコーダコンテキストを取得
AVCodecContext* codec_ctx = avcodec_alloc_context3(codec);
// ビデオメディアストリームのパラメータをデコーダコンテキストに設定
auto ret = avcodec_parameters_to_context(codec_ctx, stream->codecpar);
if (ret >= 0) {
// デコーダを開く
avcodec_open2(codec_ctx, codec, nullptr);
// ······
}
- ピクセルフォーマット、画像の幅、高さを指定して必要なバッファのメモリサイズを計算し、バッファを設定します。また、上に描画するため、
ANativeWindow
を使用し、ANativeWindow_setBuffersGeometry
を使用してこの描画ウィンドウの属性を設定します;
video_width_ = codec_ctx->width;
video_height_ = codec_ctx->height;
int buffer_size = av_image_get_buffer_size(AV_PIX_FMT_RGBA,
video_width_, video_height_, 1);
// 出力バッファ
out_buffer_ = (uint8_t*) av_malloc(buffer_size * sizeof(uint8_t));
// 幅と高さを設定してバッファ内のピクセル数を制限します。表示画面のサイズではなく。
// バッファが表示される画面のサイズと一致しない場合、実際に表示されるのは引き伸ばされたり、圧縮された画像になる可能性があります
int result = ANativeWindow_setBuffersGeometry(native_window_, video_width_,
video_height_, WINDOW_FORMAT_RGBA_8888);
- RGBA ピクセルフォーマットの
AVFrame
にメモリ空間を割り当て、RGBA に変換されたフレームデータを格納します。rgba_frame
バッファを設定し、out_buffer_
と関連付けます;
auto rgba_frame = av_frame_alloc();
av_image_fill_arrays(rgba_frame->data, rgba_frame->linesize,
out_buffer_,
AV_PIX_FMT_RGBA,
video_width_, video_height_, 1);
SwsContext
を取得します。これは、sws_scale()
を呼び出して画像フォーマット変換と画像スケーリングを行う際に使用されます。YUV420P から RGBA に変換する際、sws_scale
を呼び出すとフォーマット変換が失敗し、正しい高さ値を返さない可能性があります。これは、sws_getContext
を呼び出す際のflags
に関係しており、SWS_BICUBIC
をSWS_FULL_CHR_H_INT | SWS_ACCURATE_RND
に変更する必要があります;
struct SwsContext* data_convert_context = sws_getContext(
video_width_, video_height_, codec_ctx->pix_fmt,
video_width_, video_height_, AV_PIX_FMT_RGBA,
SWS_BICUBIC, nullptr, nullptr, nullptr);
- 生データを格納するための
AVFrame
にメモリ空間を割り当て、元のフレームデータを指し、ビデオデコード前のデータを格納するためのAVPacket
にもメモリ空間を割り当てます;
auto frame = av_frame_alloc();
auto packet = av_packet_alloc();
- ビデオストリームから圧縮フレームデータをループして読み取り、デコードを開始します;
ret = av_read_frame(av_format_context, packet);
if (packet->size) {
Decode(codec_ctx, packet, frame, stream, lock, data_convert_context, rgba_frame);
}
Decode()
関数内で、圧縮データを含むpacket
を入力としてデコーダに送信します;
/* 圧縮データを含むパケットをデコーダに送信します */
ret = avcodec_send_packet(codec_ctx, pkt);
- デコーダは指定された
frame
にデコードされたフレームデータを返します。以降は、デコードされたframe
のpts
をタイムスタンプに換算し、タイムラインに従ってフレームを描画します;
while (ret >= 0 && !is_stop_) {
// デコードされたデータをframeに返します
ret = avcodec_receive_frame(codec_ctx, frame);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
return;
} else if (ret < 0) {
return;
}
// 現在デコードされたframeのptsをタイムスタンプに換算し、指定されたタイムスタンプと比較します
auto decode_time_ms = frame->pts * 1000 / stream->time_base.den;
if (decode_time_ms >= time_ms_) {
last_decode_time_ms_ = decode_time_ms;
is_seeking_ = false;
// ······
// 画像データフォーマット変換
// ······
// 変換されたデータを画面に描画します
}
av_packet_unref(pkt);
}
- 描画画面の前に、画像データフォーマットの変換を行う必要があります。ここで前述の
SwsContext
を使用します;
// 画像データフォーマット変換
int result = sws_scale(
sws_context,
(const uint8_t* const*) frame->data, frame->linesize,
0, video_height_,
rgba_frame->data, rgba_frame->linesize);
if (result <= 0) {
LOGE(TAG, "Player Error : data convert fail");
return;
}
- 上に描画するため、
ANativeWindow
とANativeWindow_Buffer
を使用します。画面を描画する前に、描画する次のサーフェスをロックして描画を行い、表示するフレームデータをバッファに書き込み、最後にウィンドウの描画サーフェスのロックを解除して、バッファのデータを画面に表示します;
// 再生
result = ANativeWindow_lock(native_window_, &window_buffer_, nullptr);
if (result < 0) {
LOGE(TAG, "Player Error : Can not lock native window");
} else {
// 画像を画面に描画します
// 注意 : ここでrgba_frameの1行のピクセルとwindow_bufferの1行のピクセルの長さは一致しない可能性があります
// 正しく変換しないと、画面が乱れる可能性があります
auto bits = (uint8_t*) window_buffer_.bits;
for (int h = 0; h < video_height_; h++) {
memcpy(bits + h * window_buffer_.stride * 4,
out_buffer_ + h * rgba_frame->linesize[0],
rgba_frame->linesize[0]);
}
ANativeWindow_unlockAndPost(native_window_);
}
- 以上が主要なデコードプロセスです。さらに、C++ を使用する際にはリソースとメモリ空間を自分で解放する必要があるため、デコードが終了した後にリリースインターフェースを呼び出してリソースを解放し、メモリリークを防ぐ必要があります。
sws_freeContext(data_convert_context);
av_free(out_buffer_);
av_frame_free(&rgba_frame);
av_frame_free(&frame);
av_packet_free(&packet);
avcodec_close(codec_ctx);
avcodec_free_context(&codec_ctx);
avformat_close_input(&av_format_context);
avformat_free_context(av_format_context);
ANativeWindow_release(native_window_);
2.3 簡単なアプリケーション#
ビデオデコードプロセスをより良く理解するために、ビデオデコーダVideoDecoder
をラップします。このデコーダには、以下のいくつかの関数があります:
VideoDecoder(const char* path, std::function<void(long timestamp)> on_decode_frame);
void Prepare(ANativeWindow* window);
bool DecodeFrame(long time_ms);
void Release();
このビデオデコーダでは、指定されたタイムスタンプを入力すると、そのフレームデータがデコードされて返されます。特に重要なのはDecodeFrame(long time_ms)
関数で、ユーザーが自分で呼び出し、指定されたフレームのタイムスタンプを渡して対応するフレームデータをデコードできます。さらに、同期ロックを追加してデコードスレッドと使用スレッドを分離することもできます。
2.3.1 同期ロックを追加してビデオ再生を実現#
ビデオをデコードするだけの場合は、同期待機を使用する必要はありません;
しかし、ビデオを再生する場合は、フレームをデコードして描画するたびにロックを使用して同期待機する必要があります。これは、ビデオを再生する際にデコードと描画を分離し、特定のタイムラインに従ってデコードと描画を行う必要があるためです。
condition_.wait(lock);
上層でDecodeFrame
関数を呼び出してデコードタイムスタンプを渡すと、同期ロックが解除され、デコード描画のループが続行されます。
bool VideoDecoder::DecodeFrame(long time_ms) {
// ······
time_ms_ = time_ms;
condition_.notify_all();
return true;
}
2.3.2 再生時に seek_frame を追加#
通常の再生の場合、ビデオはフレームごとにデコードされて再生されます。しかし、進捗バーをドラッグして指定のシークポイントに到達した場合、最初から最後までフレームごとにデコードしてシークポイントに到達するのは効率が悪い可能性があります。この場合、特定のルールに従ってシークポイントのタイムスタンプをチェックし、条件を満たす場合は直接指定されたタイムスタンプにシークする必要があります。
FFmpeg の av_seek_frame#
av_seek_frame
は、キーフレームと非キーフレームの両方に位置を特定できます。これは選択したflag
値によって決まります。ビデオのデコードはキーフレームに依存するため、通常はキーフレームに位置を特定する必要があります;
int av_seek_frame(AVFormatContext *s, int stream_index, int64_t timestamp,
int flags);
av_seek_frame
のflag
は、I フレームと渡されたタイムスタンプの間の位置関係を指定するために使用されます。過去のタイムスタンプにシークする場合、タイムスタンプが I フレームの位置にちょうどあるとは限りませんが、デコードには I フレームが必要なため、まずこのタイムスタンプの近くの I フレームを見つける必要があります。この時、flag
は現在のタイムスタンプの前の I フレームまたは後の I フレームにシークするかを示します;flag
には 4 つのオプションがあります:
flag オプション | 説明 |
---|---|
AVSEEK_FLAG_BACKWARD | 最初の Flag は、要求されたタイムスタンプの前の最近のキーフレームにシークします。通常、シークは ms 単位で行われ、指定された ms タイムスタンプがちょうどキーフレームでない場合(大きな確率)、最近のキーフレームに自動的に戻ります。この方法の位置特定は非常に正確ではありませんが、モザイクの問題をうまく処理できるため、BACKWARD の方法はキーフレームを見つけるために後方検索を行います。 |
AVSEEK_FLAG_BYTE | 2 番目の Flag は、ファイル内の対応する位置(バイト表現)にシークします。AVSEEK_FLAG_FRAME と完全に一致しますが、検索アルゴリズムは異なります。 |
AVSEEK_FLAG_ANY | 3 番目の Flag は、任意のフレームにシークでき、必ずしもキーフレームである必要はないため、使用時にモザイクが発生する可能性がありますが、進捗と手の滑りは完全に一致します。 |
AVSEEK_FLAG_FRAME | 4 番目の Flag は、シークするタイムスタンプに対応するフレーム番号で、前のキーフレームを見つけるために後方にシークすることを理解できます。 |
flag
は、上記の複数の値を同時に含む可能性があります。たとえば、AVSEEK_FLAG_BACKWARD | AVSEEK_FLAG_BYTE
;FRAME
とBACKWARD
は、フレーム間の間隔を基にシークのターゲット位置を推算し、早送りや巻き戻しに適しています;BYTE
は、大幅なスライドに適しています。
シークのシナリオ#
- デコード時に渡されるタイムスタンプが前進方向であり、前のフレームのタイムスタンプを超える距離がある場合はシークが必要です。この「一定の距離」は、複数回の実験によって推定されたものであり、以下のコードで使用される 1000ms とは限りません;
- 後退方向で、前回のデコードタイムスタンプより小さいが、前回のデコードタイムスタンプとの距離が大きい(たとえば、50ms を超えた場合)場合は、前のキーフレームにシークする必要があります;
- bool 変数
is_seeking_
を使用するのは、他の操作が現在のシーク操作に干渉しないようにするためであり、目的は現在 1 つのシーク操作のみが行われるように制御することです。
if (!is_seeking_ && (time_ms_ > last_decode_time_ms_ + 1000 ||
time_ms_ < last_decode_time_ms_ - 50)) {
is_seeking_ = true;
// シーク時に渡すのは指定されたフレームのtime_baseを持つタイムスタンプであるため、times_msを使用して推算する必要があります
LOGD(TAG, "seek frame time_ms_ = %ld, last_decode_time_ms_ = %ld", time_ms_,
last_decode_time_ms_);
av_seek_frame(av_format_context,
video_stream_index,
time_ms_ * stream->time_base.den / 1000,
AVSEEK_FLAG_BACKWARD);
}
シークロジックの挿入#
デコード前にシークが必要かどうかを確認するため、av_read_frame
関数(ビデオメディアストリームの次のフレームを返す)の前にシークロジックを挿入します。条件を満たす場合は、av_seek_frame
を使用して指定の I フレームに到達し、次にav_read_frame
を呼び出して目的のタイムスタンプの位置までデコードを続けます。
// シークのロジックはここに書きます
// 次にビデオストリームの次のフレームを読み取ります
int ret = av_read_frame(av_format_context, packet);
2.4 デコードプロセス中の詳細#
2.4.1 DecodeFrame 時のシーク条件#
av_seek_frame
関数を使用する際は、正しいflag
を指定する必要があり、シーク操作を行う条件を約束しないと、ビデオにモザイクが発生する可能性があります。
if (!is_seeking_ && (time_ms_ > last_decode_time_ms_ + 1000 ||
time_ms_ < last_decode_time_ms_ - 50)) {
is_seeking_ = true;
av_seek_frame(···,···,···,AVSEEK_FLAG_BACKWARD);
}
2.4.2 デコード回数の削減#
ビデオデコード時、特定の条件下では、渡されたタイムスタンプのフレームデータをデコードする必要がない場合があります。たとえば:
- 現在のデコードタイムスタンプが前進方向であり、前回のデコードタイムスタンプと同じか、現在デコード中のタイムスタンプと同じである場合、デコードする必要はありません;
- 現在のデコードタイムスタンプが前回のデコードタイムスタンプ以下であり、前回のデコードタイムスタンプとの距離が小さい(たとえば、50ms を超えない)場合、デコードする必要はありません。
bool VideoDecoder::DecodeFrame(long time_ms) {
LOGD(TAG, "DecodeFrame time_ms = %ld", time_ms);
if (last_decode_time_ms_ == time_ms || time_ms_ == time_ms) {
LOGD(TAG, "DecodeFrame last_decode_time_ms_ == time_ms");
return false;
}
if (time_ms <= last_decode_time_ms_ &&
time_ms + 50 >= last_decode_time_ms_) {
return false;
}
time_ms_ = time_ms;
condition_.notify_all();
return true;
}
これらの条件制約があれば、不要なデコード操作を減らすことができます。
2.4.3 AVFrame の pts を使用#
AVPacket
はデコード前のデータ(エンコードデータ/AAC など)を格納し、デパッケージング後、デコード前のデータであり、依然として圧縮データです;AVFrame
はデコード後のデータ(ピクセルデータ/RGB/PCM など)を格納します;AVPacket
のpts
とAVFrame
のpts
の意味は異なります。前者はこのデコーディングパッケージが表示される時間を示し、後者はフレームデータが表示される時間を示します;
// AVPacketのpts
/**
* AVStream->time_base単位でのプレゼンテーションタイムスタンプ;デコーディングされたパケットがユーザーに提示される時間。
* ファイルに保存されていない場合はAV_NOPTS_VALUEになる可能性があります。
* ptsはdtsより大きいか等しい必要があります。プレゼンテーションはデコーディング前に行うことはできません。
* 一部のフォーマットは、dtsとpts/ctsの用語を異なる意味で誤用します。これらのタイムスタンプは、AVPacketに保存される前に真のpts/dtsに変換する必要があります。
*/
int64_t pts;
// AVFrameのpts
/**
* プレゼンテーションタイムスタンプはtime_base単位(フレームがユーザーに表示される時間)です。
*/
int64_t pts;
- 現在デコード中のフレームデータを画面に描画するかどうかは、渡されたデコードタイムスタンプと現在デコーダが返すデコード済みフレームのタイムスタンプの比較結果によって決まります。ここで
AVPacket
のpts
を使用することはできません。これは、増加するタイムスタンプではない可能性が高いです; - 画面描画を行う前提は、渡された指定のデコードタイムスタンプが現在デコードされたフレームの pts を換算したタイムスタンプ以下であることです。
auto decode_time_ms = frame->pts * 1000 / stream->time_base.den;
LOGD(TAG, "decode_time_ms = %ld", decode_time_ms);
if (decode_time_ms >= time_ms_) {
last_decode_time_ms_ = decode_time_ms;
is_seeking = false;
// 画面描画
// ····
}
2.4.4 デコードの最後のフレームでビデオにデータがない場合#
av_read_frame(av_format_context, packet)
はビデオメディアストリームの次のフレームをAVPacket
に返します。この関数が返す int 値が 0 の場合はSuccess
であり、0 未満の場合はError
またはEOF
です。
したがって、ビデオ再生中に 0 未満の値が返された場合、avcodec_flush_buffers
関数を呼び出してデコーダの状態をリセットし、バッファ内の内容をフラッシュしてから、現在渡されたタイムスタンプにシークし、デコード後のコールバックを完了し、同期ロックを待機させます。
// 音声のいくつかのフレームまたはビデオの1フレームをストリームから読み取ります。
// ここではビデオの1フレーム(完全なフレーム)を読み取ります。取得されるのは1フレームのビデオの圧縮データであり、次にデコードを行うことができます
ret = av_read_frame(av_format_context, packet);
if (ret < 0) {
avcodec_flush_buffers(codec_ctx);
av_seek_frame(av_format_context, video_stream_index,
time_ms_ * stream->time_base.den / 1000, AVSEEK_FLAG_BACKWARD);
LOGD(TAG, "ret < 0, condition_.wait(lock)");
// デコードの最後のフレームでビデオにデータがないのを防ぎます
on_decode_frame_(last_decode_time_ms_);
condition_.wait(lock);
}
2.5 上層でのデコーダ VideoDecoder のラップ#
上層でVideoDecoder
をラップする場合、C++ 層のVideoDecoder
インターフェースをnative-lib.cpp
に公開するだけで、上層は JNI を介して C++ インターフェースを呼び出します。
たとえば、上層が指定されたデコードタイムスタンプを渡してデコードを行う場合、deocodeFrame
メソッドを作成し、タイムスタンプを C++ 層のnativeDecodeFrame
に渡してデコードを行います。nativeDecodeFrame
メソッドの実装はnative-lib.cpp
に記述します。
// FFmpegVideoDecoder.kt
class FFmpegVideoDecoder(
path: String,
val onDecodeFrame: (timestamp: Long, texture: SurfaceTexture, needRender: Boolean) -> Unit
){
// timeMsフレームを抽出し、syncが同期待機かどうか
fun decodeFrame(timeMS: Long, sync: Boolean = false) {
// 現在抽フレームが必要ない場合は待機しません
if (nativeDecodeFrame(decoderPtr, timeMS) && sync) {
// ······
} else {
// ······
}
}
private external fun nativeDecodeFrame(decoder: Long, timeMS: Long): Boolean
companion object {
const val TAG = "FFmpegVideoDecoder"
init {
System.loadLibrary("ffmmpeg")
}
}
}
次に、native-lib.cpp
内で C++ 層のVideoDecoder
インターフェースDecodeFrame
を呼び出すことで、上層と C++ 下層の間の接続を JNI を介して確立します。
// native-lib.cpp
extern "C"
JNIEXPORT jboolean JNICALL
Java_com_example_decoder_video_FFmpegVideoDecoder_nativeDecodeFrame(JNIEnv* env,
jobject thiz,
jlong decoder,
jlong time_ms) {
auto videoDecoder = (codec::VideoDecoder*)decoder;
return videoDecoder->DecodeFrame(time_ms);
}
三、感想#
技術経験
- FFmpeg をコンパイルして Android と組み合わせてビデオのデコード再生を実現するのは非常に便利です。
- C++ 層で具体的なデコードプロセスを実装するため、学習の難易度があり、一定の C++ の基礎があると良いです。
四、付録#
C++ でラップされた VideoDecoder
VideoDecoder.h
#include <jni.h>
#include <mutex>
#include <android/native_window.h>
#include <android/native_window_jni.h>
#include <time.h>
extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libswresample/swresample.h>
#include <libswscale/swscale.h>
}
#include <string>
/*
* VideoDecoderは特定の音声ビデオファイル(例えば.mp4)内のビデオメディアストリームデータをデコードするために使用できます。
* Java層から指定されたファイルのパスを渡すと、一定のfpsで指定されたタイムスタンプに従ってデコード(フレーム抽出)を行います。この実装はC++が提供するDecodeFrameによって行われます。
* 各デコード終了時に、デコードされたフレームのタイムスタンプを上層のデコーダにコールバックし、他の操作に使用できるようにします。
*/
namespace codec {
class VideoDecoder {
private:
std::string path_;
long time_ms_ = -1;
long last_decode_time_ms_ = -1;
bool is_seeking_ = false;
ANativeWindow* native_window_ = nullptr;
ANativeWindow_Buffer window_buffer_{};、
// ビデオの幅と高さの属性
int video_width_ = 0;
int video_height_ = 0;
uint8_t* out_buffer_ = nullptr;
// on_decode_frameは指定されたフレームのタイムスタンプを上層デコーダにコールバックするために使用されます。
std::function<void(long timestamp)> on_decode_frame_ = nullptr;
bool is_stop_ = false;
// ループ同期時に使用されるロック“std::unique_lock<std::mutex>”と組み合わせて使用されます
std::mutex work_queue_mtx;
// 実際に同期待機と解除を行う属性
std::condition_variable condition_;
// デコーダが実際にデコードを行う関数
void Decode(AVCodecContext* codec_ctx, AVPacket* pkt, AVFrame* frame, AVStream* stream,
std::unique_lock<std::mutex>& lock, SwsContext* sws_context, AVFrame* pFrame);
public:
// 新しいデコーダを作成する際にメディアファイルパスとデコード後のコールバックon_decode_frameを渡す必要があります。
VideoDecoder(const char* path, std::function<void(long timestamp)> on_decode_frame);
// JNI層で上層から渡されたSurfaceをラップして新しいANativeWindowを作成し、後でデコード後にフレームデータを描画する際に使用します
void Prepare(ANativeWindow* window);
// 指定されたタイムスタンプのビデオフレームを抽出します。上層から呼び出すことができます
bool DecodeFrame(long time_ms);
// デコーダリソースを解放します
void Release();
// 現在のシステムミリ秒時間を取得します
static int64_t GetCurrentMilliTime(void);
};
}
VideoDecoder.cpp
#include "VideoDecoder.h"
#include "../log/Logger.h"
#include <thread>
#include <utility>
extern "C" {
#include <libavutil/imgutils.h>
}
#define TAG "VideoDecoder"
namespace codec {
VideoDecoder::VideoDecoder(const char* path, std::function<void(long timestamp)> on_decode_frame)
: on_decode_frame_(std::move(on_decode_frame)) {
path_ = std::string(path);
}
void VideoDecoder::Decode(AVCodecContext* codec_ctx, AVPacket* pkt, AVFrame* frame, AVStream* stream,
std::unique_lock<std::mutex>& lock, SwsContext* sws_context,
AVFrame* rgba_frame) {
int ret;
/* 圧縮データを含むパケットをデコーダに送信します */
ret = avcodec_send_packet(codec_ctx, pkt);
if (ret == AVERROR(EAGAIN)) {
LOGE(TAG,
"Decode: Receive_frame and send_packet both returned EAGAIN, which is an API violation.");
} else if (ret < 0) {
return;
}
// すべての出力フレームを読み取ります(一般的に、任意の数のフレームがある可能性があります)
while (ret >= 0 && !is_stop_) {
// frameに対して、avcodec_receive_frame内部で毎回呼び出されます
ret = avcodec_receive_frame(codec_ctx, frame);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
return;
} else if (ret < 0) {
return;
}
int64_t startTime = GetCurrentMilliTime();
LOGD(TAG, "decodeStartTime: %ld", startTime);
// 現在デコード中のframeのタイムスタンプを換算します
auto decode_time_ms = frame->pts * 1000 / stream->time_base.den;
LOGD(TAG, "decode_time_ms = %ld", decode_time_ms);
if (decode_time_ms >= time_ms_) {
LOGD(TAG, "decode decode_time_ms = %ld, time_ms_ = %ld", decode_time_ms, time_ms_);
last_decode_time_ms_ = decode_time_ms;
is_seeking_ = false;
// データフォーマット変換
int result = sws_scale(
sws_context,
(const uint8_t* const*) frame->data, frame->linesize,
0, video_height_,
rgba_frame->data, rgba_frame->linesize);
if (result <= 0) {
LOGE(TAG, "Player Error : data convert fail");
return;
}
// 再生
result = ANativeWindow_lock(native_window_, &window_buffer_, nullptr);
if (result < 0) {
LOGE(TAG, "Player Error : Can not lock native window");
} else {
// 画像を画面に描画します
auto bits = (uint8_t*) window_buffer_.bits;
for (int h = 0; h < video_height_; h++) {
memcpy(bits + h * window_buffer_.stride * 4,
out_buffer_ + h * rgba_frame->linesize[0],
rgba_frame->linesize[0]);
}
ANativeWindow_unlockAndPost(native_window_);
}
on_decode_frame_(decode_time_ms);
int64_t endTime = GetCurrentMilliTime();
LOGD(TAG, "decodeEndTime - decodeStartTime: %ld", endTime - startTime);
LOGD(TAG, "finish decode frame");
condition_.wait(lock);
}
// 主にAVPacket内のすべての空間データをクリアし、クリア後に初期化操作を行い、dataとsizeを0に設定して次回の呼び出しを容易にします。
// packetの参照を解放します
av_packet_unref(pkt);
}
}
void VideoDecoder::Prepare(ANativeWindow* window) {
native_window_ = window;
av_register_all();
auto av_format_context = avformat_alloc_context();
avformat_open_input(&av_format_context, path_.c_str(), nullptr, nullptr);
avformat_find_stream_info(av_format_context, nullptr);
int video_stream_index = -1;
for (int i = 0; i < av_format_context->nb_streams; i++) {
// ビデオメディアストリームのインデックスを見つけます
if (av_format_context->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
video_stream_index = i;
LOGD(TAG, "find video stream index = %d", video_stream_index);
break;
}
}
// 一度実行します
do {
if (video_stream_index == -1) {
codec::LOGE(TAG, "Player Error : Can not find video stream");
break;
}
std::unique_lock<std::mutex> lock(work_queue_mtx);
// ビデオメディアストリームを取得します
auto stream = av_format_context->streams[video_stream_index];
// 登録されたコーデックを見つけます
auto codec = avcodec_find_decoder(stream->codecpar->codec_id);
// デコーダコンテキストを取得します
AVCodecContext* codec_ctx = avcodec_alloc_context3(codec);
auto ret = avcodec_parameters_to_context(codec_ctx, stream->codecpar);
if (ret >= 0) {
// 開きます
avcodec_open2(codec_ctx, codec, nullptr);
// デコーダが開かれた後にのみ幅と高さの値があります
video_width_ = codec_ctx->width;
video_height_ = codec_ctx->height;
AVFrame* rgba_frame = av_frame_alloc();
int buffer_size = av_image_get_buffer_size(AV_PIX_FMT_RGBA, video_width_, video_height_,
1);
// 出力バッファにメモリ空間を割り当てます
out_buffer_ = (uint8_t*) av_malloc(buffer_size * sizeof(uint8_t));
av_image_fill_arrays(rgba_frame->data, rgba_frame->linesize, out_buffer_,
AV_PIX_FMT_RGBA,
video_width_, video_height_, 1);
// 幅と高さを設定してバッファ内のピクセル数を制限します。物理的な表示サイズではなく。
// バッファが物理画面の表示サイズと一致しない場合、実際の表示は引き伸ばされたり、圧縮された画像になる可能性があります
int result = ANativeWindow_setBuffersGeometry(native_window_, video_width_,
video_height_, WINDOW_FORMAT_RGBA_8888);
if (result < 0) {
LOGE(TAG, "Player Error : Can not set native window buffer");
avcodec_close(codec_ctx);
avcodec_free_context(&codec_ctx);
av_free(out_buffer_);
break;
}
auto frame = av_frame_alloc();
auto packet = av_packet_alloc();
struct SwsContext* data_convert_context = sws_getContext(
video_width_, video_height_, codec_ctx->pix_fmt,
video_width_, video_height_, AV_PIX_FMT_RGBA,
SWS_BICUBIC, nullptr, nullptr, nullptr);
while (!is_stop_) {
LOGD(TAG, "front seek time_ms_ = %ld, last_decode_time_ms_ = %ld", time_ms_,
last_decode_time_ms_);
if (!is_seeking_ && (time_ms_ > last_decode_time_ms_ + 1000 ||
time_ms_ < last_decode_time_ms_ - 50)) {
is_seeking_ = true;
LOGD(TAG, "seek frame time_ms_ = %ld, last_decode_time_ms_ = %ld", time_ms_,
last_decode_time_ms_);
// 渡すのは指定されたフレームのtime_baseを持つタイムスタンプであるため、元のtimes_msを上記で取得した計算方法に従って逆算する必要があります
av_seek_frame(av_format_context, video_stream_index,
time_ms_ * stream->time_base.den / 1000, AVSEEK_FLAG_BACKWARD);
}
// ビデオの1フレーム(完全なフレーム)を読み取ります。取得されるのは1フレームのビデオの圧縮データであり、次にデコードを行うことができます
ret = av_read_frame(av_format_context, packet);
if (ret < 0) {
avcodec_flush_buffers(codec_ctx);
av_seek_frame(av_format_context, video_stream_index,
time_ms_ * stream->time_base.den / 1000, AVSEEK_FLAG_BACKWARD);
LOGD(TAG, "ret < 0, condition_.wait(lock)");
// デコードの最後のフレームでビデオにデータがないのを防ぎます
on_decode_frame_(last_decode_time_ms_);
condition_.wait(lock);
}
if (packet->size) {
Decode(codec_ctx, packet, frame, stream, lock, data_convert_context,
rgba_frame);
}
}
// リソースを解放します
sws_freeContext(data_convert_context);
av_free(out_buffer_);
av_frame_free(&rgba_frame);
av_frame_free(&frame);
av_packet_free(&packet);
}
avcodec_close(codec_ctx);
avcodec_free_context(&codec_ctx);
} while (false);
avformat_close_input(&av_format_context);
avformat_free_context(av_format_context);
ANativeWindow_release(native_window_);
delete this;
}
bool VideoDecoder::DecodeFrame(long time_ms) {
LOGD(TAG, "DecodeFrame time_ms = %ld", time_ms);
if (last_decode_time_ms_ == time_ms || time_ms_ == time_ms) {
LOGD(TAG, "DecodeFrame last_decode_time_ms_ == time_ms");
return false;
}
if (last_decode_time_ms_ >= time_ms && last_decode_time_ms_ <= time_ms + 50) {
return false;
}
time_ms_ = time_ms;
condition_.notify_all();
return true;
}
void VideoDecoder::Release() {
is_stop_ = true;
condition_.notify_all();
}
/**
* 現在のミリ秒単位の時間を取得します
*/
int64_t VideoDecoder::GetCurrentMilliTime(void) {
struct timeval tv{};
gettimeofday(&tv, nullptr);
return tv.tv_sec * 1000.0 + tv.tv_usec / 1000.0;
}
}