音视频(8)MediaCodec结合FFmpeg实现视频加图片水印

784 阅读6分钟

前言

最近在研究FFmepg滤镜方面的知识,索性就准备尝试一下代码给视频添加水印。

一开始想直接FFmpeg直接c代码加水印,写完后测试了一下比较慢,毕竟软解得看CPU即使设置了多线程编解码还是一个吊样,然后想到了另一条路硬解码然后ffmpeg数据处理水印接着送入硬编码这样效率会很高,毕竟GPU还是很快的。软解永远是兜底方案

注:这不是一篇单纯的FFmpeg水印命令文章
注:本篇使用JNI开发

效果

filter

流程

仅核心流程具体细节参照示例

graph LR
A[MediaCodec]
B[AVFilter]
C[MediaCodec]
D[MediaMuxer]
E[AudioAAC]
F[MPEG4]
E --原视频AAC--> D
A --解码H264--> B --编码YUV--> C --编码H264--> D --合成--> F

AVFilter是FFmpeg库下的一个流媒体过滤器,它用于对组件常用于多媒体处理与编辑,包含多种滤镜,比如旋转,加水印,多宫格等等,源码位于ffmpeg/libavfilter中。

准备

示例

1.提取音频/视频流轨道MediaFormat

//视频流提取器
MediaExtractor mediaExtractor = new MediaExtractor();
//设置视频源
mediaExtractor.setDataSource(path);
//寻找视频流
for (int i = 0; i < mediaExtractor.getTrackCount(); i++) {
    MediaFormat mediaFormat = mediaExtractor.getTrackFormat(i);
    if (mediaFormat.getString(MediaFormat.KEY_MIME).contains("video/")) {
        //视频流
        videoMediaFmt = mediaFormat;
        //选择当前视频轨道
        mediaExtractor.selectTrack(i);
    } else if (mediaFormat.getString(MediaFormat.KEY_MIME).contains("audio/")) {
        //音频流
        readAudioMediaFormat = mediaFormat;
        //记录音频流索引
        audioExtractorSelectIndex = i;
    }
}
//音频流提取器
MediaExtractor audioMediaExtractor = new MediaExtractor();
audioMediaExtractor.setDataSource(path);
//直接选择音频流
audioMediaExtractor.selectTrack(audioExtractorSelectIndex);

MediaExtractor视频信息的提取类:

  • selectTrack 选择轨道 完毕后所有API都将基于改轨道进行信息提取
  • getTrackFormat 根据索引获取当前轨道的MediaFormat

2.创建视频解码器

int colorFmtType = MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible;
//设置解码器解码的YUV类型
videoMediaFmt.setInteger(MediaFormat.KEY_COLOR_FORMAT, colorFmtType);
//重新设置缓冲区大小
videoMediaFmt.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, videoMediaFmt.getInteger(MediaFormat.KEY_WIDTH) * videoMediaFmt.getInteger(MediaFormat.KEY_HEIGHT) * 3 / 2);
//根据类型创建合适的硬解码器
MediaCodec decode = MediaCodec.createDecoderByType(videoMediaFmt.getString(MediaFormat.KEY_MIME));
finalVideoMediaFmt = videoMediaFmt;
decode.configure(finalVideoMediaFmt, null, null, 0);
//设置解码异步回调
decode.setCallback(mediaCallback);
decode.start();

3.创建视频编码器

MediaCodec encode = MediaCodec.createEncoderByType(videoMediaFmt.getString(MediaFormat.KEY_MIME));
MediaCodec mediaFormat = MediaFormat.createVideoFormat(videoMediaFmt.getString(MediaFormat.KEY_MIME),videoMediaFmt.getInteger(MediaFormat.KEY_WIDTH), videoMediaFmt.getInteger(MediaFormat.KEY_HEIGHT));
// 编码器接受的YUV格式
mediaFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible);
//设置比特率 越大越清晰
mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, videoMediaFmt.getInteger(MediaFormat.KEY_WIDTH) * videoMediaFmt.getInteger(MediaFormat.KEY_HEIGHT) * 3);
//设置帧率FPS
mediaFormat.setInteger(MediaFormat.KEY_FRAME_RATE, videoMediaFmt.getInteger(MediaFormat.KEY_FRAME_RATE));
//设置I帧
mediaFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
//设置采样率
mediaFormat.setInteger(MediaFormat.KEY_SAMPLE_RATE, 44100);
//设置csd-0 1 H264需要写入头部
mediaFormat.setByteBuffer("csd-0", videoMediaFmt.getByteBuffer("csd-0"));
mediaFormat.setByteBuffer("csd-1", videoMediaFmt.getByteBuffer("csd-1"));
encode.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
encode.start();

编码器的设置有些值可以直接去MediaExtractor提取的原视频的MediaFormat进行读取填充编码器
例如: 帧率 KEY_FRAME_RATE 比特率 KEY_BIT_RATE csd-0以及csd-1这些信息在原视频文件已经给出不需要在自己设置。

注:csd-0以及csd-1在H264开头必须要写在头部的(在MediaFormat中写入setByteBuffer()),否则MediaMxure生成MP4会出现错误

4.解码与编码

MediaCodec工作流程图

mediacodec_buffers.svg

4.1 发送到解码器
     @Override
    public void onInputBufferAvailable(@NonNull MediaCodec codec, int index) {
        if (index >= 0) {
            ByteBuffer inputBuffer = codec.getInputBuffer(index);
            if (inputBuffer != null) {
                inputBuffer.clear();
            }
            int sampleSize = mediaExtractor.readSampleData(inputBuffer, 0);
            if (sampleSize < 0) {
            //读取完毕
                codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM);
            } else {
                codec.queueInputBuffer(index, 0, sampleSize, mediaExtractor.getSampleTime(), 0);
                //读取下一帧
                mediaExtractor.advance();
            }
        }
    }

getInputBuffer(index)根据可用索引获取一个缓冲区ByteBuffer mediaExtractor.readSampleData(inputBuffer, 0) 提取一帧数据到buffer中 codec.queueInputBuffer读取的数据发送到解码器中

queueInputBuffer 要正确填入时间戳PTS表示帧显示的时间

4.2 取出解码YUV
@Override
    public void onOutputBufferAvailable(@NonNull MediaCodec codec, int index, @NonNull MediaCodec.BufferInfo info) {
        //若达到文件尾部
        if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) {
            videoEncodeStop = true;
            if (audioEncodeStop && !isStop) {
                encode.stop();
                decode.stop();
                mediaMuxer.stop();
                isStop = true;
                playVideo();
            }
            return;
        }
        if (index >= 0) {
            //Image用于提取YUV
            Image image = decode.getOutputImage(index);
            if (image == null) {
                codec.releaseOutputBuffer(index, true);
                return;
            }
            //获取类型I420的YUV 
            byte[] i420 = getDataFromImage(image, COLOR_FormatI420);
             //...加水印 送入编码器
             //....
             //释放解码后数据
            codec.releaseOutputBuffer(index, false);
        }
    }

首先判断是否读取到视频尾部进行后续mp4输出和播放
getOutputImage拿到输出的Image类型,它包含了YUV各个分量数据基于上面解码器配置的COLOR类型COLOR_FormatYUV420Flexible这表示YUV420各个类型集合
接着转为I420类型

YUV类型可以参考: Camera2录制视频(音视频合成)及其YUV数据提取(二)- YUV提取及图像转化

4.3 native加水印后并同步执行编码
            //...加水印 送入编码器
            byte[] nv12 = native_filter(i420);
            // start   encode
            int inputBufferIndex = encode.dequeueInputBuffer(100000);
            if (inputBufferIndex >= 0) {
                ByteBuffer inputBuffer = encode.getInputBuffer(inputBufferIndex);
                inputBuffer.put(nv12);
                //数据送入编码器
                encode.queueInputBuffer(inputBufferIndex, 0, nv12.length, info.presentationTimeUs, 0);
            }
            // 获取编码后的输出数据
            MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
            int outputBufferIndex = encode.dequeueOutputBuffer(bufferInfo, 100000);
            if (outputBufferIndex >= 0) {
                ByteBuffer outputBuffer = encode.getOutputBuffer(outputBufferIndex);
                //处理编码后的数据
                if (isMediaMuxerStatr && !videoEncodeStop) {
                //写入视频轨道编码后的数据
                    mediaMuxer.writeSampleData(mVideoTrackIndex, outputBuffer, bufferInfo);
                }
                //释放缓冲区
                encode.releaseOutputBuffer(outputBufferIndex, false);
            }

这里重点要关注isMediaMuxerStatr这个变量因为在MediaMxure.start后才能writeSampleData

4.4 native_filter水印函数实现
4.4.1 init初始化滤镜
void init_avfilter() {
    //buffer滤镜 负责将原始视频帧添加到滤镜图中
    buffer_filter = avfilter_get_by_name("buffer");
    //buffersink滤镜 用于从滤镜图中获取处理后的视频帧。
    buffersink_filter = avfilter_get_by_name("buffersink");

    char videoInfoArgs[256];
    //buffer滤镜参数
    snprintf(videoInfoArgs, sizeof(videoInfoArgs),
             "video_size=%dx%d:pix_fmt=%d:time_base=%d/%d:pixel_aspect=%d:%d",
             video_parametres->width, video_parametres->height, video_parametres->format,
             fmt_ctx->streams[videoIdx]->time_base.num, fmt_ctx->streams[videoIdx]->time_base.den,
             video_parametres->sample_aspect_ratio.num, video_parametres->sample_aspect_ratio.den);
    //创建滤镜图
    filterGraph = avfilter_graph_alloc();
    if (!filterGraph) {
        return;
    }
    //创建滤镜输入端口
    int ret = avfilter_graph_create_filter(&buffer_filter_ctx, buffer_filter, "in", videoInfoArgs,
                                           nullptr, filterGraph);
    if (ret < 0) {
        return;
    }
    ret = avfilter_graph_create_filter(&buffersink_filter_ctx, buffersink_filter, "out", nullptr,
                                       nullptr, filterGraph);
    if (ret < 0) {
        return;
    }

    enum AVPixelFormat pixelFormat[] = {AV_PIX_FMT_YUV420P,
                                        (AVPixelFormat) (video_parametres->format)};
    //它被用来设置 buffersink 滤镜的像素格式。 在处理视频帧时,buffersink 滤镜将尝试使用 AV_PIX_FMT_YUV420P 或 video_parametres->format 这两种像素格式之一。
    av_opt_set_int_list(buffersink_filter_ctx, "pix_fmts", pixelFormat, AV_PIX_FMT_YUV420P,
                        AV_OPT_SEARCH_CHILDREN);
    in_filter = avfilter_inout_alloc();
    in_filter->next = nullptr;
    in_filter->name = av_strdup("out");
    in_filter->filter_ctx = buffersink_filter_ctx;
    in_filter->pad_idx = 0;

    out_filter = avfilter_inout_alloc();
    out_filter->next = nullptr;
    out_filter->name = av_strdup("in");
    out_filter->filter_ctx = buffer_filter_ctx;
    out_filter->pad_idx = 0;
    
    char filters[256];
    snprintf(filters, sizeof(filters), "movie=%s,scale=200:-1[wm];[in][wm]overlay=50:10[out]",
             logo_path);
    ret = avfilter_graph_parse_ptr(filterGraph, filters, &in_filter, &out_filter, nullptr);
    if (ret < 0) {
        return;
    }
    ret = avfilter_graph_config(filterGraph, nullptr);

    if (ret < 0) {
        return;
    }
}

这里的需要一个输入端口buffer和输出端口bufferskin需要用到avfilter_get_by_name去获取AVFilter
滤镜输入端口需要设置视频的一些参数,这里参数用的是avformat_find_stream_infoFFmpeg的函数去查找视频信息 avfilter_graph_parse_ptr此函数将一串通过字符串描述的Graph添加到AVFilterGraph中,这里主要是filters参数即snprintf(filters, sizeof(filters), "movie=%s,scale=200:-1[wm];[in][wm]overlay=50:10[out]",logo_path);意思是:movie表示水印图片的路径,同事按比例缩放200自动缩放,在位置坐标x 50 y 10的地方显示一个logo水印

4.4.3 yuv加滤镜水印
extern "C"
JNIEXPORT jbyteArray JNICALL
Java_com_mt_mediacodec2demo_MainActivity_native_1filter(JNIEnv *env, jobject thiz, jbyteArray src,
                                                        jobject i_native_callback) {
    jbyteArray array;
    jclass cls = env->GetObjectClass(i_native_callback);
    jmethodID mid = env->GetMethodID(cls, "onFrame", "([B)V");

    jsize length = env->GetArrayLength(src);
    uint8_t *src_data = (uint8_t *) av_malloc(length);
    env->GetByteArrayRegion(src, 0, length, (jbyte *) src_data);
    av_image_fill_arrays(src_frame->data, src_frame->linesize, src_data,
                         (AVPixelFormat) video_parametres->format, video_parametres->width,
                         video_parametres->height, 1);
    src_frame->width = video_parametres->width;
    src_frame->height = video_parametres->height;
    src_frame->time_base = fmt_ctx->streams[videoIdx]->time_base;
    src_frame->sample_aspect_ratio = video_parametres->sample_aspect_ratio;
    src_frame->format = video_parametres->format;
    //pts = 0 表示每一帧显示的时间是0 直接显示
    src_frame->pts = 0;
    //添加到滤镜中
    int ret = av_buffersrc_add_frame_flags(buffer_filter_ctx, src_frame,
                                           AV_BUFFERSRC_FLAG_KEEP_REF);
    if (ret >= 0) {
        ret = av_buffersink_get_frame(buffersink_filter_ctx, filter_frame);
        if (ret >= 0) {
            //滤镜已经添加了
            int width = filter_frame->width;
            int height = filter_frame->height;
            int y_size = width * height;
            int uv_size = y_size / 4;
            AVFrame *i420_frame = av_frame_alloc();
            uint8_t *out_buf = (uint8_t *) av_malloc(
                    av_image_get_buffer_size(AV_PIX_FMT_YUV420P, width, height, 1));
            av_image_fill_arrays(i420_frame->data, i420_frame->linesize, out_buf,
                                 AV_PIX_FMT_YUV420P, width, height, 1);
            struct SwsContext *img_ctx = sws_getContext(
                    filter_frame->width, filter_frame->height,
                    (AVPixelFormat) filter_frame->format, //源地址长宽以及数据格式
                    filter_frame->width, filter_frame->height, AV_PIX_FMT_YUV420P,  //目的地址长宽以及数据格式
                    SWS_BICUBIC, NULL, NULL, NULL);
            sws_scale(img_ctx, filter_frame->data, filter_frame->linesize, 0, height,
                      i420_frame->data, i420_frame->linesize);


            uint8_t *i420_data = (uint8_t *) malloc(y_size * 3 / 2);
            memcpy(i420_data, i420_frame->data[0], y_size);
            memcpy(i420_data + y_size, i420_frame->data[1], uv_size);
            memcpy(i420_data + y_size + uv_size, i420_frame->data[2], uv_size);
            jbyteArray array_nv12 = env->NewByteArray(y_size * 3 / 2);
            jbyte *nv12 = env->GetByteArrayElements(array_nv12, 0);
            i420ToNv12(i420_data, video_parametres->width, video_parametres->height, nv12);
            env->SetByteArrayRegion(array_nv12, 0, y_size * 3 / 2,
                                    (jbyte *) (nv12));
            env->CallVoidMethod(i_native_callback, mid, array_nv12);
            array = array_nv12;
            free(i420_data);
            av_frame_free(&i420_frame);
            free(out_buf);
            sws_freeContext(img_ctx);
        }
    }
    av_frame_unref(filter_frame);
    av_frame_unref(src_frame);
    av_free(src_data);
    return array;
}

av_buffersrc_add_frame_flags将yuv发送到滤镜器中
av_buffersink_get_frame 将加了水印的yuv取出保存在AVFrame结构体中
i420ToNv12利用libyuv库进行yuv格式转换,libyuv是google开源的yuv转换库效率比较高 av_image_fill_arrays将传来的I420格式YUV对齐并填充到src_frame的data数据中
sws_getContext获取转换ctx
sws_scale保险起见将滤镜后的数据再次转换到I420格式
然后通过I420的YUV分量排列形式以此从AVFrame结构中的data[0][1][2]存储的YUV分量中拷贝到新的内存区域

  • 要设置pts不然水印图片无法显示出来
  • 这里本来用callback形式将数据传回到Java层,后来考虑到要同步执行就放弃了
4.5 添加视频轨道
@Override
public void onOutputFormatChanged(@NonNull MediaCodec codec, @NonNull MediaFormat format) {
    if (mVideoTrackIndex == -1) {
        MediaFormat outputFormat = encode.getOutputFormat();
        mVideoTrackIndex = mediaMuxer.addTrack(outputFormat);
        //然后添加音频轨道再写入AAC源数据
        writeAAC();
    }
}
4.6 写入音频源数据

因为水印只需要给视频帧添加,故而音频内容不需要任何变动所以不需要再次解码然后编码合成

private void writeAAC(){
    new Thread(new Runnable() {
        @Override
        public void run() {
            mAudioTrackIndex = mediaMuxer.addTrack(audioFormat);
            mediaMuxer.start();
            isMediaMuxerStatr = true;
            audioBuf.clear();
            int size = audioMediaExtractor.readSampleData(audioBuf,0);
            MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
            info.size = size;
            info.presentationTimeUs = audioMediaExtractor.getSampleTime();
            info.flags = 0;
            mediaMuxer.writeSampleData(mAudioTrackIndex,audioBuf,info);
            while (audioMediaExtractor.advance()) {
                size = audioMediaExtractor.readSampleData(audioBuf,0);
                info.size = size;
                info.presentationTimeUs = audioMediaExtractor.getSampleTime();
                info.flags = 0;
                //写入音频数据
                mediaMuxer.writeSampleData(mAudioTrackIndex,audioBuf,info);
            }
            audioEncodeStop = true;
            if (videoEncodeStop && !isStop) {
                encode.stop();
                decode.stop();
                mediaMuxer.stop();
                isStop = true;
                endTime = System.nanoTime();
                duration = (endTime - startTime) / 1000000000; // 计算结果为秒
                Log.d(TAG, "extime = " + duration + " s");
                playVideo();
            }
        }
    }).start();
}

audioMediaExtractor.getSampleTime()获取当前轨道的帧时间戳 audioMediaExtractor.advance()指向下一帧

音频和视频流要对齐时间戳,不然会出现音画不同步的问题。

Github地址

Android-AddImageWatermarkToVideo