Skip to content

Latest commit

 

History

History
executable file
·
393 lines (325 loc) · 14.7 KB

README.MD

File metadata and controls

executable file
·
393 lines (325 loc) · 14.7 KB

M3U8批量转换开发总结

废话不多说,先上图

首页

关于

开发时间表

  • 2020年1月13日
    开始java层开发,设计demoUI,制作文件搜索功能
  • 2020年1月24日
    开始cpp层开发,尝试使用ndk编译ffmpeg.so,尝试使用jni调用ffmpeg转码功能
  • 2020年2月10日
    开始制作最终版UI,结合前期完成的cpp层应用内核,制作第一版M3U8批量转换
  • 2020年2月16日
    发布第一版app
  • 2021年7月7日
    添加纯java解密合并m3u8功能,发布第二版app

背景知识

M3U8 是 Unicode 版本的 M3U,用 UTF-8 编码。"M3U" 和 "M3U8" 文件都是苹果公司使用的 HTTP Live Streaming(HLS) 协议格式的基础,这种协议格式可以在 iPhone 和 Macbook 等设备播放。

这种格式将一整段视频分割为多个数秒的ts分片文件,便于网络传输。其中还可以将ts分片文件加密。由于chrome开始放弃对flash的支持,最近互联网上很多视频都是使用m3u8格式进行推流的。除了分片文件外还有一个.m3u8格式的文件列表,用于保存所有分片文件的URI。

目前UC浏览器和QQ浏览器都支持将m3u8视频流缓存到本地,其中QQ浏览器还可以直接转码为MP4,但是只能一个一个转换,不能批量转换,因此我就开发了这个app

app原理分析

我们都知道ffmpeg是一个很强大的命令行音频视频处理工具,可以用于各种视频格式的相互转换。具体地我们要把一个m3u8视频文件转化成mp4格式的,命令行这样使用:

ffmpeg -allowed_extensions ALL -i index.m3u8 -c copy new.mp4
#-allowed_extensions ALL启用对m3u8格式的支持,否则会显示不识别的文件格式
#-i index.m3u8 设置输入文件
#-c copy 复制产生新的文件
#new.mp4 指定输出文件名

所以我们app开发的目的就是使用java-jni调用c层的ffmpeg函数,实现视频格式的转换

ffmpeg结构分析

按照网络上的教程编译出来的ffmpeg项目有两个可执行文件ffmpegffprobe,其中ffmpeg用来给视频转码,ffprobe用来查看视频文件的信息

这两个executable文件都需要使用7个动态链接库so文件

libavcodec.so #编解码
libavdevice.so #设备抽象
libavfilter.so #滤镜
libavformat.so #各种格式的支持
libavutil.so #项目自用工具集
libswresample.so #重新采样
libswscale.so #尺寸缩放

这些库我们使用编译好的现成的就可以了,不需要在android-studio中编译,网络上还有自己把这7个库编译成一个ffmpeg.so库文件的,这样在使用的时候也就方便了

我们只需要修改ffmpeg可执行文件的源码,实现jni调用ffmpeg的main函数(当然要改名),进行视频转码、获得转码进度和获得视频信息

创建android-studio NDK项目

不要选android-studio自带的c++项目,不好用,我们先创建一个empty activity,然后自己导入c++模块

在app模块的build.gradle中加入

android {
    ******
    defaultConfig {
        ******
        externalNativeBuild {
            cmake {
                cppFlags ""
            }
        }
        //指定要ndk编译的目标平台
        ndk {
            abiFilters 'armeabi-v7a', 'arm64-v8a'
        }
    }
    //指定cpp文件夹的路径
    sourceSets {
        main {
            jniLibs.srcDirs = ['src/main/cpp/ffmpeg/prebuilt/']
        }
    }
    //指定cmake文件的路径
    externalNativeBuild {
        cmake {
            path "src/main/cpp/CMakeLists.txt"
        }
    }
}

修改完成后,将cpp项目放到指定位置,同步一下gradle,android-studio就会出现cpp文件夹和jniLibs模块

设计demoUI界面,完成m3u8文件的搜索功能

这里主要是java层的开发,不会的百度谷歌一下就可,直接上界面图

m3u8demo界面图

java层调用c层实现m3u8视频的格式转换

这里我定义了一个工具类FFmpegCmd,用于模拟执行ffmpeg命令行,调用c层ffmpeg实现转换

  • 首先加载c库
    static {
        //首先加载ffmpeg库(我将7个库编译成了一个)
        System.loadLibrary("ffmpeg");
        //ffmpeg-cmd实际上就是ffmpeg这个二进制文件的源码构成的项目,包含这些文件和ffmpeg项目的很多头文件(我直接将所有的头文件都拷贝了进去)
        //cmdutils.c cmdutils.h cmdutils_opencl.c config.h ffmpeg.c
        //ffmpeg.h ffmpeg-cmd.cpp ffmpeg_filter.c ffmpeg_hw.c ffmpeg_opt.c
        System.loadLibrary("ffmpeg-cmd");
    }
  • 调用转码函数
    //模拟命令行执行的转码函数
    //srcPath:m3u8文件路径
    //outPath:输出文件路径
    //listener:回调接口用于获得进度
    public static void transcode(String srcPath, String outPath,ConvertListener listener) {
        ArrayList<String> cmd = new ArrayList<>();
        cmd.add("ffmpeg");
        cmd.add("-allowed_extensions");
        cmd.add("ALL");
        cmd.add("-i");
        cmd.add(srcPath);
        cmd.add("-c");
        cmd.add("copy");
        cmd.add(outPath);
        run(cmd,listener);
        }
  • run函数调用native层ffmpeg-cmd.cppexec函数,exec函数调用ffmpeg.c中的ffmpeg_exec执行转码(ffmpeg_exec就是原来的main函数)
exec(cmd.size(), cmd.toArray(new String[cmd.size()]));
extern "C"
JNIEXPORT jint JNICALL
Java_com_juju_m3u8converter_FFmpegCmd_exec(JNIEnv *env, jclass type, jint cmdLen,jobjectArray cmd) {
    //set java vm
    JavaVM *jvm = NULL;
    env->GetJavaVM(&jvm);
    av_jni_set_java_vm(jvm, NULL);
    //处理传入的参数,将jstring转为char *
    char *argCmd[cmdLen] ;
    jstring buf[cmdLen];
    for (int i = 0; i < cmdLen; ++i) {
        buf[i] = static_cast<jstring>(env->GetObjectArrayElement(cmd, i));
        char *string = const_cast<char *>(env->GetStringUTFChars(buf[i], JNI_FALSE));
        argCmd[i] = string;
        LOGD("argCmd=%s",argCmd[i]);
    }
    //执行ffmpeg_exec并获得返回值
    int retCode = ffmpeg_exec(cmdLen, argCmd);
    LOGD("ffmpeg-invoke: retCode=%d",retCode);
    return retCode;
}

这样确实能转码成功,但是看不到转换进度

获得视频总帧数等信息

ffmpeg_exec中可获得已经转换的视频的帧数,所以还要获得视频的总帧数,才能算出转换进度

总帧数又可以用fps和视频的总时间长度相乘获得,因此就是要获得视频的信息

extern "C"
JNIEXPORT jstring JNICALL
Java_com_juju_m3u8converter_FFmpegCmd_retrieveInfo(JNIEnv *env, jclass clazz, jstring _path) {
    //此函数的执行和ffmpeg_exec内部执行流程类似,都是先解析传入的视频信息
    const char* path=env->GetStringUTFChars(_path, JNI_FALSE);
    AVFormatContext* ctx = nullptr;
    //ffmpeg初始化执行环境
    av_register_all();
    avcodec_register_all();
    avfilter_register_all();
    avformat_network_init();
    //相当于是执行了没有带传入参数的ffmpeg_exec,所以要自己加入参数,否则报错文件不识别
    //初始化词典,加入参数-allowed_extensions ALL
    //AVDictionary dic;
    AVDictionary *format_opts= nullptr;//初始化为空就行了
    av_dict_set(&format_opts, "allowed_extensions", "ALL", 0);//会自动分配内存
    //初始化ic,不能为空,否则m3u8文件无法解析
    /* get default parameters from command line */
    const char* filename=path;
    AVFormatContext* ic = avformat_alloc_context();
    if (!ic) {
        print_error(filename, AVERROR(ENOMEM));
        return nullptr;
    }
    ic->flags |= AVFMT_FLAG_KEEP_SIDE_DATA;
    ic->flags |= AVFMT_FLAG_NONBLOCK;
    //ic->interrupt_callback = int_cb;//暂停回调函数先不使用
    //扫描全部的ts流的"Program Map Table"表
    if (!av_dict_get(format_opts, "scan_all_pmts", NULL, AV_DICT_MATCH_CASE)) {
        av_dict_set(&format_opts, "scan_all_pmts", "1", AV_DICT_DONT_OVERWRITE);
        //scan_all_pmts_set = 1;
    }
    //打开了输入文件
    ctx=ic;
    int ret = avformat_open_input(&ctx, path, nullptr, &format_opts);
    if (ret != 0) {
        LOGE("avformat_open_input() open failed! path:%s, err:%s", path, av_err2str(ret));
        return nullptr;
    }
    //获取了输入视频文件的信息
    ret=avformat_find_stream_info(ctx, nullptr);
    if (ret < 0) {
        av_log(NULL, AV_LOG_ERROR, "Cannot find stream information\n");
        LOGE("avformat_find_stream_info() failed! path:%s, err:%s", path, av_err2str(ret));
        return nullptr;
    }
    env->ReleaseStringUTFChars(_path,path);
    int nStreams = ctx->nb_streams;

    AVStream **pStream = ctx->streams;
    AVStream *vStream = nullptr;

    for (int i = 0; i < nStreams; i++) {
        //if (pStream[i]->codec->codec_type == AVMEDIA_TYPE_VIDEO) {
        if (pStream[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {
            vStream = pStream[i];
        }
    }
    //对视频文件信息整理输出
    int width = 0;
    int height = 0;
    int rotation = 0;
    long fps = 0;
    char *vCodecName = nullptr;
    if(vStream!=nullptr){
        width = vStream->codecpar->width;
        height = vStream->codecpar->height;
        rotation = static_cast<int>(get_rotation(vStream));
        int num = vStream->avg_frame_rate.num;
        int den = vStream->avg_frame_rate.den;
        if (den > 0) {
            fps = lround(num * 1.0 / den);
        }

        const char *codecName = avcodec_get_name(vStream->codecpar->codec_id);
        vCodecName = const_cast<char *>(codecName);
    }

    long bitrate = ctx->bit_rate;
    long duration = ctx->duration / 1000;//ms
    //最后一定记得关闭,不然会出错
    avformat_close_input(&ctx);
    //返回json格式字符串
    std::ostringstream buffer;
    buffer << "{\"rotation\":"<<rotation<<",\"width\":"<<width<<",\"height\":"<<height<<",\"duration\":"<<duration<<",\"bitrate\":"<< bitrate<<",\"fps\":"<<fps<<R"(,"videoCodec":")"<<vCodecName<<"\"}";
    std::string result = buffer.str();
    return env->NewStringUTF(result.c_str());
}

对一个坑的回忆

这个地方我踩到了一个坑,根据ffmpeg官方文档

avformat_open_input
avformat_find_stream_info
avformat_close_input

一套下来就完成了,但是程序一运行就崩溃,说不识别m3u8格式的文件,我就觉得很奇怪,你直接ffmpeg_exec就没问题,我照着ffmpeg_exec写的retrieveInfo就报错了

想来想去搞不懂,看官方文档也没说清楚,最后用debug,执行一个ffmpeg转码命令,在ffmpeg_exec中加断点,才明白要怎么在cpp代码中加传入的参数

    //初始化词典,加入参数-allowed_extensions ALL
    //AVDictionary dic;
    AVDictionary *format_opts= nullptr;//初始化为空就行了
    av_dict_set(&format_opts, "allowed_extensions", "ALL", 0);//会自动分配内存

这个坑解决了,就可以获得文件的总帧数了

获得转码进度

启动一个java新线程,循环调用getProgress方法获得实时已转换帧数,再启动一个新线程执行exec转码方法,防止阻塞主线程

public static void run(final ArrayList<String> cmd, final ConvertListener listener) {
        Log.d("FFmpegCmd", "run: " + cmd.toString());
        new Thread()
        {
            @Override
            public void run() {
                while(getProgress()==0);
                listener.onStart();
                for(;;)
                {
                    if(getProgress()==0)
                    {
                        listener.onStop();
                        return;
                    }
                    //Log.d("isWorking:", "getprogress: "+String.valueOf(getProgress()));
                    listener.onProgress(getProgress());
                    try{
                        sleep(500);
                    }
                    catch (Exception e)
                    {
                        e.printStackTrace();
                    }
                }
            }
        }.start();
        new Thread()
        {
            @Override
            public void run()
            {
                exec(cmd.size(), cmd.toArray(new String[cmd.size()]));
            }
        }.start();
    }

读取m3u8文件,获得分片文件个数

用java代码解析m3u8文件也是可以的,要是java能实现解密,就可以用纯java实现m3u8格式的转码,但是我研究了几天,发现java8对aes-128的加密解密和之前的不一样,我写了几个demo都没有成功,所以就放弃了

但是目前可以获得分片文件数,但是这也不是必要的

//循环读取每一行
while (true)
{
    try {
        line = reader.readLine();
    } catch (IOException e) {
        e.printStackTrace();
    }
    if(line.startsWith("#"))//#开头说明为标签
    {
        if (line == null || line.equals("#EXT-X-ENDLIST"))
            break;
            // 如果有#EXT-X-KEY匹配,说明是加密的视频,获得keymap
        else if (line.matches("#EXT-X-KEY:.*")) {
            String s = line.replaceAll("#EXT-X-KEY:(.*)", "$1");
            // System.out.println("key:"+s);
            String[] sarray = s.split(",");
            for (int i = 0; i < sarray.length; i++) {
                String[] kv = sarray[i].split("=");
                if (kv[0].equals("URI")) {
                    String kpath = kv[1].replaceAll("\"(\\w://)?(.*)\"", "$2");
                    kv[1] = kpath;
                }
                keyMap.put(kv[0], kv[1]);
            }
        }
    }
}

感悟

  1. 一定要用google搜索,看英文文档,这是成为大神的必须之路

  2. 不懂的话多看看别人的代码,看看大神们是怎么写的,他们不一定会像我这样写技术文档,知识都在代码中

  3. 这次开发经历,学习了android-studio的使用,学习了android-java知识,学习了ffmpeg的编译和调用,历时两个月,过得很充实,最后做出了成果,很有成就感

java解析m3u8文件

我参考了ffmpeg源代码里面的hls.c来写java,同时参考了网络上的一些对m3u8标签的解析博客和RFC 8216 HTTP Live Streaming标准,最终比较完美全面地完成了对m3u8标签的解析。

由于java不支持aes-128的加密解密,我参考这个博客java下载m3u8视频,解密并合并ts引入第三方bcprov-jdk16-139.jar进行解密

目前java解密存在对SAMPLE-AES加密方式无法处理的情况,我还没自己研究这个加密方式,不过目前看来这个加密方式碰到的比较少,如果碰到了就使用ffmpeg方式转码吧

项目代码和demo.apk查看和下载

https://github.com/q77190858/m3u8demo