首页 > 技术文章 > Android 获取视频画面方式整理

renhui 2020-12-23 16:13 原文

在进行Android音视频开发的时候,我们可能会遇到需要获取视频制定位置的图片的需求。针对这个问题,我们有几种解决方案:分别为Android官方提供的MediaMetadataRetriever、基于FFmpeg封装的FFmpegMediaMetadataRetriever、还有就是基于FFmpeg自研发。

下面我们基于这几个实现方式进行介绍和整理 :

一、MediaMetadataRetriever

 /**
     * 获取视频某帧的图像,但得到的图像并不一定是指定position的图像。
     *
     * @param path 视频的本地路径
     * @return Bitmap 返回的视频图像
     */
    public static Bitmap getVideoFrame(String path) {
        Bitmap bmp = null;
        MediaMetadataRetriever retriever = new MediaMetadataRetriever();
        try {
            retriever.setDataSource(path);
            String timeString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION);
            // 获取总长度
            long totalTime = Long.parseLong(timeString) * 1000;
            if (totalTime > 0) {
                // 这里为了实现简单,我们直接获取视频中间的画面
                bmp = retriever.getFrameAtTime(totalTime / 2, MediaMetadataRetriever.OPTION_CLOSEST);
            }
        } catch (RuntimeException ex) {
            ex.printStackTrace();
        } finally {
            try {
                retriever.release();
            } catch (RuntimeException ex) {
                ex.printStackTrace();
            }
        }
        return bmp;
    }

1.1 本方案优点:

实现方便,因为使用的是系统Api, 不会增加包体积

1.2 本方案缺点:

支持的格式较少,对网络的视频的支持度较低,且在获取指定位置的视频画面的时候,可能因为GOP的大小导致获取的视频位置不准确。

且可能在使用中遇到以下问题:

可能遇到因为视频格式导致的异常:

mindmaptopicMediaMetadataRetrieverJNI: getFrameAtTime: videoFrame is a NULL pointer<br>

可能遇到获取网络图片失败的问题:

java.lang.IllegalArgumentException
    at android.media.MediaMetadataRetriever.setDataSource(MediaMetadataRetriever.java:73)
或
java.lang.RuntimeException: setDataSource failed: status = 0x80000000

当使用MediaMetadataRetriever无法满足我们的需求实现的时候,这时候推荐使用FFmpegMediaMetadataRetriever。

二、FFmpegMediaMetadataRetriever

FFmpegMediaMetadataRetriever的开源项目地址为:https://github.com/wseemann/FFmpegMediaMetadataRetriever

FFmpegMediaMetadataRetriever的作者很有心,提供了同MediaMetadataRetriever相同的Api。

2.1 本方案集成方式:

在需要使用的module的build.gradle文件中添加如下配置:

dependencies {
    implementation 'com.github.wseemann:FFmpegMediaMetadataRetriever-core:1.0.15'
    implementation 'com.github.wseemann:FFmpegMediaMetadataRetriever-native-armeabi-v7a:1.0.15'
    implementation 'com.github.wseemann:FFmpegMediaMetadataRetriever-native-x86:1.0.15'
    implementation 'com.github.wseemann:FFmpegMediaMetadataRetriever-native-x86_64:1.0.15'
    implementation 'com.github.wseemann:FFmpegMediaMetadataRetriever-native-arm64-v8a:1.0.15'
}

使用方式非常像MediaMetadataRetriever,下面是使用方式的代码:

/**
     * 获取视频某帧的图像
     *
     * @param path 视频的路径
     * @return Bitmap 返回的视频图像
     */
    public static Bitmap getVideoFrameByFMMR(String path) {
        Bitmap bmp = null;
        FFmpegMediaMetadataRetriever retriever = new FFmpegMediaMetadataRetriever();
        try {
            retriever.setDataSource(path);
            String timeString = retriever.extractMetadata(FFmpegMediaMetadataRetriever.METADATA_KEY_DURATION);
            // 获取总长度
            long totalTime = Long.parseLong(timeString) * 1000;
            if (totalTime > 0) {
                bmp = retriever.getFrameAtTime(totalTime / 2, FFmpegMediaMetadataRetriever.OPTION_CLOSEST);
            }
        } catch (RuntimeException ex) {
            ex.printStackTrace();
        } finally {
            try {
                retriever.release();
            } catch (RuntimeException ex) {
                ex.printStackTrace();
            }
        }
        return bmp;
    }

需要注意的是,直播流的话,不能使用retriever.getFrameAtTime(long timeUs, int option)的方式获取指定时间的图像。但是可以使用retriever.getFrameAtTime()获取当前时间的画面。

2.2 本方案优点:

支持的格式多;

对网络的视频的支持度好;

且在获取指定位置的视频画面的时候,定位相对准确。

2.3 本方案缺点:

引入了FFmpeg库,会导致打包出来的Apk出现爆炸式的大小增加(native层的库可据实际需要进行精简);

当视频的时长较长或者分辨率较大的时候,可能会导致获取视频画面的耗时较长。

三、基于FFmpeg自研发

基于FFmpeg自研发的方式实现起来和实现播放器差不多,准确来说就是通过seek定位指定的Frame,然后保存为本地图片,执行完成后,由java层的代码加载本地图片为Bitmap。

方案优点:获取画面定位精确,且可根据实际需要实现库的裁剪,且实现流程可控,可定制逻辑。

方案优点缺点:实现起来难度较大。

实现代码:

Java层代码:

/**
 * 基于FFmpeg实现的缩略图获取工具类
 */
public class FFmpegThumbnailHelper {

    public static Bitmap getVideoThumbnail(String path) {
        FFmpegThumbnailHelper helper = new FFmpegThumbnailHelper();
        String filePath = AppContextHelper.context.getCacheDir() + File.separator + "11122.jpg";
        Log.e(GlobalConfig.LOG_TAG, "filePath = " + filePath);
        helper.getThumbnail(path, filePath);
        return BitmapFactory.decodeFile(filePath);
    }

    private native void getThumbnail(String path, String picturePath);

    // 加载底层so库
    static {
        System.loadLibrary("media-editor-lib");
    }

}

Native层代码:

#include "iostream"
#include "cstring"
#include "jni.h"

extern "C" {
#include <string>
#include "libswscale/swscale.h"
#include "libavutil/imgutils.h"
#include "libavutil/opt.h"
#include "libavutil/channel_layout.h"
#include "libavutil/common.h"
#include "libavutil/imgutils.h"
#include "libavutil/mathematics.h"
#include "libavutil/samplefmt.h"
#include "libavutil/time.h"
#include "libavutil/fifo.h"
#include "libavcodec/avcodec.h"
#include "libavformat/avformat.h"
#include "libavformat/avio.h"
#include "libavfilter/avfilter.h"
#include "libavfilter/buffersink.h"
#include "libavfilter/buffersrc.h"
#include "libswscale/swscale.h"
#include "libswresample/swresample.h"
}

#include <../AndroidLog.h>

AVFormatContext *inputContext;
AVFormatContext *outputContext;

// 用于解码
AVCodecContext *deCodecContext;
// 用于编码
AVCodecContext *enCodecContext;

int video_index = -1;

AVStream *in_stream;
AVStream *out_stream;

void init() {
    av_register_all();
    avformat_network_init();
    av_log_set_level(AV_LOG_INFO);
}

void openInputForDecodec(const char *inputUrl) {
    avformat_open_input(&inputContext, inputUrl, NULL, NULL);

    avformat_find_stream_info(inputContext, NULL);

    for (int i = 0; i < inputContext->nb_streams; i++) {
        AVStream *stream = inputContext->streams[i];
        /**
         * 对于jpg图片来说,它里面就是一路视频流,所以媒体类型就是AVMEDIA_TYPE_VIDEO
         */
        if (stream->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
            video_index = i;
            AVCodec *codec = avcodec_find_decoder(stream->codecpar->codec_id);
            // 初始化解码器上下文
            deCodecContext = avcodec_alloc_context3(codec);
            // 设置解码器参数,从源视频拷贝参数
            avcodec_parameters_to_context(deCodecContext, stream->codecpar);
            // 初始化解码器
            avcodec_open2(deCodecContext, codec, NULL);
        }
    }
}

// 初始化编码器
void initEncodecContext() {
    // 初始化编码器;因为最终是要写入到JPEG,所以使用的编码器ID为AV_CODEC_ID_MJPEG
    AVCodec *codec = avcodec_find_encoder(AV_CODEC_ID_MJPEG);
    enCodecContext = avcodec_alloc_context3(codec);

    // 设置编码参数
    in_stream = inputContext->streams[video_index];
    enCodecContext->width = in_stream->codecpar->width;
    enCodecContext->height = in_stream->codecpar->height;
    // 如果是编码后写入到图片中,那么比特率可以不用设置,不影响最终的结果(也不会影响图像清晰度)
    enCodecContext->bit_rate = in_stream->codecpar->bit_rate;
    // 如果是编码后写入到图片中,那么帧率可以不用设置,不影响最终的结果
    enCodecContext->framerate = in_stream->r_frame_rate;
    enCodecContext->time_base = in_stream->time_base;
    // 对于MJPEG编码器来说,它支持的是YUVJ420P/YUVJ422P/YUVJ444P格式的像素
    enCodecContext->pix_fmt = AV_PIX_FMT_YUVJ420P;

    // 初始化编码器
    avcodec_open2(enCodecContext, codec, NULL);
}

void openOutputForEncode(const char *pictureUrl) {
    avformat_alloc_output_context2(&outputContext, NULL, NULL, pictureUrl);
    out_stream = avformat_new_stream(outputContext, NULL);
    avcodec_parameters_from_context(out_stream->codecpar, enCodecContext);
    avio_open2(&outputContext->pb, pictureUrl, AVIO_FLAG_READ_WRITE, NULL, NULL);
    /** 为输出文件写入头信息 **/
    avformat_write_header(outputContext, NULL);
}

extern "C"
JNIEXPORT void JNICALL
Java_com_renhui_player_utils_FFmpegThumbnailHelper_getThumbnail(JNIEnv *env, jobject clazz,
                                                                jstring path, jstring picturePath) {
    const char *url = env->GetStringUTFChars(path, 0);
    const char *pictureUrl = env->GetStringUTFChars(picturePath, 0);
    // 设置要截取的时间点
    int64_t start_pts = 30;

    // FFmpeg的初始化工作
    init();

    openInputForDecodec(url);
    initEncodecContext();
    openOutputForEncode(pictureUrl);

    // 创建编码解码用的AVFrame
    AVFrame *deFrame = av_frame_alloc();

    AVPacket *in_pkt = av_packet_alloc();
    AVPacket *ou_pkt = av_packet_alloc();
    AVRational time_base = inputContext->streams[video_index]->time_base;
    AVRational frame_rate = inputContext->streams[video_index]->r_frame_rate;

    // 一帧的时间戳
    int64_t delt = time_base.den / frame_rate.num;
    start_pts *= time_base.den;

    /** 因为想要截取的时间处的AVPacket并不一定是I帧,所以想要正确的解码,得先找到离想要截取的时间处往前的最近的I帧
     *  开始解码,直到拿到了想要获取的时间处的AVFrame
     *  AVSEEK_FLAG_BACKWARD 代表如果start_pts指定的时间戳处的AVPacket非I帧,那么就往前移动指针,直到找到I帧,那么
     *  当首次调用av_frame_read()函数时返回的AVPacket将为此I帧的AVPacket
     */
    av_seek_frame(inputContext, video_index, start_pts, AVSEEK_FLAG_BACKWARD);

    bool found = false;

    while (av_read_frame(inputContext, in_pkt) == 0) {
        if (in_pkt->stream_index != video_index) {
            continue;
        }
        if (found) {
            break;
        }
        // 先解码
        avcodec_send_packet(deCodecContext, in_pkt);
        while (avcodec_receive_frame(deCodecContext, deFrame) >= 0) {
            int got_packet = 0;
            avcodec_encode_video2(enCodecContext, ou_pkt, deFrame, &got_packet);
            // 因为只编码一帧,所以发送一帧视频后立马清空缓冲区
            av_write_frame(outputContext, ou_pkt);
            av_packet_unref(in_pkt);
            found = true;
            break;
        }
    }

    // 写入文件尾对于写入视频文件来说,此函数必须调用,但是对于写入JPG文件来说,不调用此函数也没关系;
    av_write_trailer(outputContext);
}

推荐阅读