Protogalaxy

Planet #0

PHSS-Core开发日志#10 FFmpeg与其JavaCPP实现踩坑纪录

Ps.PHSS的这部分开发过程中还衍生出了一个基于FFmpeg的java原生音视频metadata获取的轮子,相关文章可以参考这里

前言

在完成了简单的安全层架构,实现了基本的登录功能之后,项目中于是进入了较为核心的音视频管理功能的开发阶段,在前期技术选型时,考虑到项目的可维护性与稳定性,我最终选择了FFmpeg作为PHSS的多媒体管理外部库,作为老牌多媒体处理软件,FFmpeg的可用性与稳定性都是经过了多年生产环境检验的。

但是在编写专辑音轨管理功能的具体实现过程中,我发现FFmpeg这个东西竟然找不太到全面且易读的开发相关的Guide和Documentation。并且实际使用过程中所用到的FFmpeg的Java实现(FFmpeg-JavaCPP-Presents,基于JavaCPP,由Bytedeco编写)更是只有一个基本的demo,FFmpeg的其他更深度的内容也是一点都没提,在Google上能找到的也大多都是命令行版的FFmpeg的使用方法,在Github上能找到的音视频metadata获取相关的项目也大多都是基于命令行版FFmpeg或者是其他一些奇奇怪怪的,早已停止更新的第三方库,看起来非常的不靠谱。由于FFmpeg的Java实现是基于JavaCV的子项目,更新一直都非常稳定,所以我还是坚定不移的选择了JavaCPP版FFmpeg作为项目的多媒体处理库,因此我只能自己一点点看那个JavaDoc和网上能找到的一些零星的老版本的开发示例来一点点学习相关的开发知识,在这里记录以下,方便自己和其他有需要的老哥们日后有个参考。(以下叙述是以FFmpeg-JavaCPP作为基础,其基础技术JNI的相关介绍可以点这里

FFmpeg的架构

FFmpeg包含以下几个库:

  1. libavutil:工具库,用来辅助进行简单的多媒体编程。包括安全的字符串函数,随机数生成器,一些数据结构,扩展的数学函数,基于编码与多媒体的功能。
  2. libavcodec:编解码相关的库,提供通用的编解码框架,并且包含了多种用于音频流,视频流,字幕流的编解码器,并且包含了一些比特流过滤器(filter)。
  3. libavformat:一个为音频流,视频流,字幕流提供通用多路复用(multiplexing/muxing)与反多路复用(demultiplexing/demuxing)框架的库。包含很多适合各种多媒体格式的复用器(muxer)与解复用器(demuxer)。
  4. libavdevice:一个通用框架库,用来从多媒体设备中抓取多媒体流,或将其渲染至多媒体设备,并且支持许多种设备,包括Video4Linux2, VfW, DShow, ALSA等。
  5. libavfilter:通用音视频过滤(filtering)库,包含多种过滤器,源与接收器。
  6. libswscale:此库提供高度优化的图像缩放,颜色空间与像素格式转换操作。
  7. libswresample:此库提供高度优化的音频重采样,缩放(rematrix)与采样格式转换等操作。

常用的数据结构

Ps.数据结构中包含的变量与数据结构列表不做赘述,再用到的时候在做介绍,简介见JavaDoc

AVCodecContext

FFmpeg中主要的扩展数据结构,包含了编解码信息,音频/视频/字幕流信息等,并且提供了一系列函数来访问或修改这些数据。

AVFormatContext

FFmpeg中另外一个重要的数据结构,包含格式化的I/O上下文,主要用来进行I/O相关的操作,默认使用avformat_alloc_context()来初始化,用处与其字面意思一样,由于Java上的FFmpeg本质上还是JNI技术的一种实现,所以原理同C差不多,也是需要先开辟内存。

AVIOContext

包含字节流I/O上下文,用来管理输入输出数据,同样默认需要使用avio_alloc_context()来进行初始化。

AVFrame

这个数据结构包含了已解码的(raw)音频或视频数据。AVFrame必须使用av_frame_alloc()来初始化,需要注意的是这只会初始化AVFrame自身,具体的缓冲区数据需要使用其他方式来管理。在使用过后,AVFrame还需要使用av_frame_free()来释放。

音视频的编解码

对于多媒体编程来说,最为重要的就是多媒体数据的编解码,而编解码器(Codec)是音视频编解码的核心部分,主要涉及到了AVCodecContext这个数据结构。

容器的概念

容器(Container)封装了一个多媒体文件所需要的所有多媒体数据,包括音频,视频,字幕等,需要注意的是,容器的封装格式仅仅描述了多媒体文件以什么方式保存,外部表现为文件的后缀名,例如mp3,mp4,mkv等,并没有描述多媒体数据的具体编码方式,所以只看多媒体文件的后缀名,并不能确定其编码方式。

流与帧

流(Stream)是一种有序的数据载体,在FFmpeg中,可以表现为视频流,音频流,字幕流。流中的某一具体数据元素称为帧(Frame)。

FFmpeg的视频解码过程

一般来说,FFmpeg的视频解码分为以下几个步骤:

  1. 注册所有的容器格式以及其相对应的Codec  av_register_all()
  2. 打开文件,将其读入AVFormatContext中      avformat_open_input()
  3. 从文件中提取流信息                                      avformat_find_stream_info()
  4. 从多个数据流中找到需要的流
  5. 查询流所对应的解码器                                  avcodec_find_decoder()
  6. 打开解码器                                                    avcodec_open2()
  7. 为解码帧分配内存                                         av_frame_alloc()
  8. 将数据从流读入至Packet                               av_read_frame()
  9. 对视频帧进行解码                                          avcodec_decode_video2()

音视频Metadata的获取

Ps.以下是FFmpeg-JavaCPP的Metadata获取方式。

由于原FFmpeg是C编写的,而其JavaCPP实现是运用了JNI技术将其移植至Java,所以在写法上Metadata的获取还是与C有着很大的相似的。PHSS的Metadata读取函数如下:

public Map<String, Object> readMetaData(Path path) throws Exception {
    av_register_all();
    Map<String, Object> metadataFullMap = new HashMap<>();
    Map<String, Object> metadataCurrentMap = new HashMap<>();
    AVFormatContext avFormatContext = avformat_alloc_context();
    AVDictionaryEntry entry = null;
    avformat_open_input(avFormatContext, path.toString(), null, null);
    avformat_find_stream_info(avFormatContext, ((PointerPointer) null));
    while ((entry = av_dict_get(avFormatContext.metadata(), "", entry, AV_DICT_IGNORE_SUFFIX)) != null) {
        metadataFullMap.put(entry.key().getString(), entry.value().getString());
    }
    for (String key : metadataList) {
        if (metadataFullMap.get(key) != null) {
            metadataCurrentMap.put(key, metadataFullMap.get(key));
        } else {
            metadataCurrentMap.put(key, "");
        }
    }
    metadataCurrentMap.put("duration", formatDuration(avFormatContext.duration()));
    metadataCurrentMap.put("bitrate", formatBitrate(avFormatContext.streams(0).codecpar().bit_rate()));
    metadataCurrentMap.put("sample_rate", avFormatContext.streams(0).codecpar().sample_rate());
    metadataCurrentMap.put("bit_depth", avFormatContext.streams(0).codecpar().bits_per_raw_sample());
    metadataCurrentMap.put("size", formatSize(path.toFile().length()));
    avformat_close_input(avFormatContext);
    return metadataCurrentMap;
}

首先是通过av_register_all()函数来注册所有相关的组件,然后使用avformat_alloc_context()函数来新建一个AVFormatContext对象,此对象用来存储多媒体对象的基本构成信息。声明一个AVDictionaryEntry来储存音轨的元数据信息。

使用avformat_open_input()函数来将音轨写入avFormatContext的buffer中,然后使用av_dict_get()函数来将avFormatContext中结构化的metadata数据读入AVDictionaryEntry,然后便可以将AVDictionaryEntry中的元数据读出。

音频封面的获取

由于metadata只包含文字形式的数据,所以需要通过别的方法来获取音频的封面。由于处于FFmpeg的版本更替中,所以封面获取的相关类非常不稳定,因此PHSS暂时选择了FFmpeg-JavaCPP的上层实现,也就是JavaCV来实现音频封面的获取。

public byte[] getArtwork(Path path) throws Exception {
    FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(path.toFile());
    Java2DFrameConverter converter = new Java2DFrameConverter();
    grabber.start();
    BufferedImage bufferedImage = converter.getBufferedImage(grabber.grabImage());
    ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
    ImageIO.write(bufferedImage, "jpg", outputStream);
    grabber.close();
    return outputStream.toByteArray();
}

FFmpeg的Time base系统以及duration的计算

对于time base(时基)的概念,首先需要介绍关于音视频同步的简单知识。

发表评论