相信有许多同学都遇到过多媒体文件处理的相关需求,也肯定有许多同学面对这种需求感到非常挠头;多媒体文件处理由于数量众多的格式和复杂的概念,曾经让我感到望而却步。但是,在不断的学习中,我也摸索到一些自己的方法,逐渐找到了一些思路。接下来,我将在这篇文章中详细介绍javaCV-FFmpeg的使用细节,希望对正在阅读的你有所帮助。
众所周知,opencv库是多媒体处理方面的利器;而我们java人自然也有语言适配版本——javaCV. javaCV的使用非常简单,仅需通过maven引入org.bytedeco.javacv即可。我是用的版本是1.5.6,坐标如下
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacv</artifactId>
<version>1.5.6</version>
</dependency>
在此基础上,我们本次重点讲解使用FFmpeg,在java中引入FFmpeg也比较简单,仅需在maven中引入org.bytedeco.ffmpeg .
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>ffmpeg</artifactId>
<version>4.4-1.5.6</version>
<classifier>windows-x86</classifier>
</dependency>
此外,还需由于ffmpeg依赖cpp,所以在java项目中还需引入javacpp。
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacpp</artifactId>
<version>1.5.6</version>
<classifier>windows-x86</classifier>
</dependency>
需要注意的是,在maven坐标中,我添加了<classifier>标签,该标签标识了引入ffmpeg的平台版本,这是因为ffmpeg由c语言编写,在不同平台上的编译结果不同。如果不想这么麻烦,或者程序需要全平台适配,可以直接引入ffmpeg-platform的依赖
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>ffmpeg-platform</artifactId>
<version>4.4-1.5.6</version>
</dependency>
该依赖会将所有平台的ffmpeg全部引入,缺点是体积比较大。
关于platform依赖,可以看我之前的一篇文章 javaCV的简单使用——一次文件音画同步需求和依赖精简
引入依赖后,我们即可通过ffmpeg读写多媒体文件了。下面给出读取文件的简单示例:
Path filePath=Path.of("myTestVideo.mp4");
try (FFmpegFrameGrabber videoGrabber = new FFmpegFrameGrabber(filePath.toFile())) {
videoGrabber.start();
while(true){
try {
Frame frame = videoGrabber.grab();
//do someting with frame
if (frame == null) {
break;
}catch(Exception e){ e.printStackTrace();}
}
}
}
在这个示例中,遍历获取了文件中的每一帧(frame)。这里的帧并不是严格指播放视频时与帧率对应的某一个图像帧,而是指一个组成多媒体流的最小单元;在我的实际使用中,观察到frame的数量与多媒体使用的编码方式有关,并且视频可分为CFR(固定帧率)和VFR(可变帧率)。所以在处理frame时,不能简单地将其理解为一张图片;将其理解为字符流里的一个字符可能比较接近。
获取到Frame对象后,可以观察到Frame对象具有很多属性:
/** Information associated with the {@link #image} field. */
public int imageWidth, imageHeight, imageDepth, imageChannels, imageStride;
/**
* Buffers to hold image pixels from multiple channels for a video frame.
* Most of the software supports packed data only, but an array is provided
* to allow users to store images in a planar format as well.
*/
public Buffer[] image;
/** Information associated with the {@link #samples} field. */
public int sampleRate, audioChannels;
/** Buffers to hold audio samples from multiple channels for an audio frame. */
public Buffer[] samples;
/** Buffer to hold a data stream associated with a frame. */
public ByteBuffer data;
/** Stream number the audio|video|other data is associated with. */
public int streamIndex;
/** The underlying data object, for example, Pointer, AVFrame, IplImage, or Mat. */
public Object opaque;
/** Timestamp of the frame creation in microseconds. */
public long timestamp;
还有一个比较重要的getter:
public EnumSet<Type> getTypes() {
EnumSet<Type> type = EnumSet.noneOf(Type.class);
if (image != null) type.add(Type.VIDEO);
if (samples != null) type.add(Type.AUDIO);
if (data != null) type.add(Type.DATA);
return type;
}
从getTypes这个方法来看,Frame会返回一个Type对象的集合,表示该Frame对象的类型,有VIDEO、AUDIO、DATA一共3种。从Frame对象的属性也可看出,imageWidth, imageHeight...image等属性是表示Frame的图像属性,sampleRate, audioChannels,samples等属性是表示Frame的音频属性,data是表示Frame的数据属性。
然后,再来看一个生成多媒体文件的简单示例:
Path targetPath = Path.of("myGeneratedMedia.mp4");
//预设视频属性
int videoCodec=avcodec.AV_CODEC_ID_H264;
int imageWidth=1280;
int imageHeight=720;
double frameRate=30.0;
//预设音频属性
int audioCodec=avcodec.AV_CODEC_ID_AAC;
int audioChannels=2;
int audioSampleRate=44100;
//构造recorder
FFmpegFrameRecorder recorder = new FFmpegFrameRecorder(targetPath.toFile(),
imageWidth,
imageHeight,
audioChannels);
recorder.setVideoCodec(videoCodec);
recorder.setAudioCodec(audioCodec);
recorder.start();
while(true){
Frame frame=fetchFrame();//获取到要写入的frame对象
if(frame!=null){
recorder.record(frame);
}else{
break;
}
}
recorder.close();
从例子可以看到,生成一个多媒体文件比较简单,需要构造一个FFmpegFrameRecorder对象,调用start()后,通过record()方法写入Frame对象,最后调用close()方法关闭流。这里我使用了一些比较常用的参数进行构造,例如mp4文件经常使用的H264视频编码和AAC音频编码格式,视频使用1280*720 30fps的720p规格,音频使用2通道44100Hz采样率。多媒体封装格式使用mpeg4,在文件名中需要使用正确的后缀名,构造recorder时会自动识别。如果要生成特定后缀名或没有后缀名的文件,需要额外调用recorder.setFormat()设置格式。
通过这两个简单的例子,可以对多媒体文件的读取和写入有一些基本了解。我们可以将它们组合起来实现基本的格式转换:
Path sourceFilePath=Path.of("sourceVideo.flv");
Path targetFilePath = Path.of("targetVideo.mp4");
//预设视频属性
int videoCodec=avcodec.AV_CODEC_ID_H264;
int imageWidth=1280;
int imageHeight=720;
double frameRate=30.0;
//预设音频属性
int audioCodec=avcodec.AV_CODEC_ID_AAC;
int audioChannels=2;
int audioSampleRate=44100;
//构造recorder
try (FFmpegFrameGrabber videoGrabber = new FFmpegFrameGrabber(filePath.toFile());
FFmpegFrameRecorder recorder = new FFmpegFrameRecorder(targetPath.toFile(),imageWidth,imageHeight,audioChannels);) {
recorder.setVideoCodec(videoCodec);
recorder.setAudioCodec(audioCodec);
videoGrabber.start();
recorder.start();
while(true){
try {
Frame frame = videoGrabber.grab();
if (frame != null) {
recorder.record(frame);
}else{
break;
}
}catch(Exception e){ e.printStackTrace();}
}
}
此处给出的例子来源于一个真实的业务场景,我们通过某些方式得到的多媒体文件出于编码或格式原因无法在浏览器播放,需要转换成mp4.此处构造recorder时做了简化,实际上应该预读源文件,获取音视频属性如分辨率、帧率、采样率、通道等再构造recorder对象。可见基本的格式转换并不复杂,仅需将从源文件grabber获取的frame对象写入目标文件recorder中。
接下来增加一些难度。如果需要将多个视频文件或音频文件进行首尾相连该如何处理呢?这里我们只考虑视频文件的规格相同,音频的采样率和通道数相同的情况。不难想到,仅需将需要连接的文件读取到frame并按顺序写入recorder即可。这里给出一个简单的示例:
List<Path> sourcePaths=List.of(Path.of("source1.flv"),Path.of("source2.flv"),Path.of("source3.flv"));
Path targetPath = Path.of("myGeneratedMedia.mp4");
//预设视频属性
int videoCodec=avcodec.AV_CODEC_ID_H264;
int imageWidth=1280;
int imageHeight=720;
double frameRate=30.0;
//预设音频属性
int audioCodec=avcodec.AV_CODEC_ID_AAC;
int audioChannels=2;
int audioSampleRate=44100;
//构造recorder
try(FFmpegFrameRecorder recorder = new FFmpegFrameRecorder(targetPath.toFile(),
imageWidth,
imageHeight,
audioChannels)){
recorder.setVideoCodec(videoCodec);
recorder.setAudioCodec(audioCodec);
recorder.start();
for(Path path:sourcePaths){
try(FFmpegFrameGrabber videoGrabber = new FFmpegFrameGrabber(path.toFile())){
while(true){
try {
Frame frame = videoGrabber.grab();
if (frame != null) {
recorder.record(frame);
}else{
break;
}
}catch(Exception e){ e.printStackTrace();}
}
}
再进一步。如果不是单纯的拼接,而是需要指定时间长度进行拼接呢?
从这里开始有两种思路,我们先谈比较容易想到的:对于可变帧率视频,获取到的frame对象中一定有时间戳timestamp,可以根据时间戳判断当前的frame是否要写入recorder。对于固定帧率视频,获取到的frame(从我具体使用的情况来看)是与固定帧率对应的,因此也可以通过数量计算当前帧对应的时间。另一种思路则是通过FFmpeg的filter,用filter描述语句截断视频,并通过filter将多个视频、音频流混合后,从filter获取frame并写入recorder。在需求比较简单的时候,通过控制和计算frame来处理视频比较容易。但是对于较复杂的音视频混合需求,仅操作frame就比较困难了。
比如需求进一步升级:拼接时,多段视频之间要插入指定长度的空视频。这其实与视频截短拼接比较类似,但是需要无中生有构造出空白的视频。在我之前的文章javaCV的简单使用——一次文件音画同步需求和依赖精简中,这个需求就是以固定帧率视频的思路来实现的,通过构造空白视频帧,在合适的时机将空白视频frame传入recorder中,但是这只适用于固定帧率的视频。
这一次需求更加升级:有多个音视频需要拼接,并且这些音视频在时间上可能会有重叠,并且不确定数量。为此,需要将视频处理成多宫格形式,以便展示在时间上的重叠。到了这一步,就只能通过filter来实现了,简单的frame操作不能实现这种功能。
filter是FFmpeg提供的功能强大的滤镜,可以构造和混合多个音视频流,可以在时间和空间上进行拼接和裁剪,而且不限制视频是否是固定帧率还是可变帧率的。简单来说,就是将音视频流作为输入传入滤镜后,滤镜会按照我们指定的方式生成新的音视频流。下面给出一个简单的filter使用例:
String filterString="[0:v]setpts=PTS-STARTPTS[v_0];color=c=black:s=768x320:d=1.0:r=20[black_0];[1:v]setpts=PTS-STARTPTS[v_1];[v_0][black_0][v_1]concat=n=3:v=1:a=0[v]";
FFmpegFrameFilter fFmpegFrameFilter = new FFmpegFrameFilter(filterString,768,320);
fFmpegFrameFilter.setVideoInputs(2);
在上述构造filter的过程中,核心的入参是filterString,描述了该filter的作用。对于例子中的filterString解读如下:
该filter的目的是将两个多媒体文件的视频拼接到一起,并且中间插入一段1秒的黑屏。整个字符串以分号进行分割,每一段都描述了一部分视频流的操作。在每一段中,语句前面的方括号表示输入,语句后面的方括号表示输出,语句中的冒号表示属性分割。其中,[0:v]setpts=PTS-STARTPTS[v_0];[1:v]setpts=PTS-STARTPTS[v_1];描述了两个视频流的相关操作,color=c=black:s=768x320:d=1.0:r=20[black_0];描述了黑屏的相关操作,[v_0][black_0][v_1]concat=n=3:v=1:a=0[v]描述了合并动作的相关操作。[0:v]中的0表示第一个输入,v表示视频(类似地,a表示音频),setpts=PTS-STARTPTS是设置播放起始时间,[v_1][black_0][v_2]是自定义的临时视频流标签(可以是任意简明易懂的值)。[v_0][black_0][v_1]concat=n=3:v=1:a=0[v]语句中,[v_0][black_0][v_1]表示按顺序将这三个视频流作为输入,n=3表示合并总数量为3的视频流,v=1表示保留视频,a=0表示丢弃音频,[v]表示filter最终输出的视频流标签。
类似地,我们也可以构造出音频filter的构造语句:
String audioFilterString="[0:a]asetpts=PTS-STARTPTS[a_0];aevalsrc=0:d=1.996[silence_0];[1:a]asetpts=PTS-STARTPTS[a_1];aevalsrc=0:d=0.996[silence_1];[2:a]asetpts=PTS-STARTPTS[a_2];[a_0][silence_0][a_1][silence_1][a_2]concat=n=5:v=0:a=1[a]";
不难理解,该filter将3个多媒体输入的音频流与两段静默音频组合到一起,最后生成一个音频流。
构造filter之后,需要调用filter.start()启动,并将从多媒体文件构造的grabber中取得的frame对象传入。传入的同时,还要从filter中通过filter.pull()将filter生成的frame对象取出,并传入recorder。这个过程略显复杂,样例代码如下
//grabbers是已经构造好的两个文件的grabber的列表
while (true) {
int videoNullFrameSize = 0;
int audioNullFrameSize = 0;
for (int i = 0; i < grabbers.size(); i++) {
FFmpegFrameGrabber grabber = grabbers.get(i);
Frame frame = null;
try {
frame = grabber.grab();
} catch (Exception e) {
e.printStackTrace();
} finally {
if (frame != null) {
if (frame.getTypes().contains(Frame.Type.VIDEO) && videoFilter != null) {
videoFilter.push(videoIdxMap.get(i), frame);
} else if (frame.getTypes().contains(Frame.Type.AUDIO) && audioFilter != null) {
audioFilter.push(audioIdxMap.get(i), frame);
}
} else {
if (videoFilter != null) {
videoNullFrameSize += 1;
if (videoIdxMap.containsKey(i)) {
videoFilter.push(videoIdxMap.get(i), null);
}
}
if (audioFilter != null) {
audioNullFrameSize += 1;
if (audioIdxMap.containsKey(i)) {
audioFilter.push(audioIdxMap.get(i), null);
}
}
}
}
}
while (true) {
boolean pullVideo = false;
boolean pullAudio = false;
if (videoFilter != null) {
Frame frame = videoFilter.pull();
if (frame != null) {
pullVideo = true;
recorder.record(frame);
}
}
if (audioFilter != null) {
Frame frame = audioFilter.pull();
if (frame != null) {
pullAudio = true;
recorder.record(frame);
}
}
if (!pullVideo && !pullAudio) {
break;
}
}
if ((videoFilter == null || videoNullFrameSize == grabbers.size())
&& (audioFilter == null || audioNullFrameSize == grabbers.size())) {
break;
}
}
不难理解,这段代码的流程是 从grabber中取得视频或音频帧,传入对应的视频或音频filter,同时从filter中取得滤镜后的视频或音频帧,传入recorder,整个流程在所有grabber都取尽且所有filter都取尽后结束。
对于上文提到的更复杂的多宫格需求,则也在filter中实现。对于视频多宫格的拼接,可以使用xstack命令;音频则使用amix命令,例如
String videoFilterStr="[0:v]setpts=PTS-STARTPTS[v_0];color=c=black:s=768x320:d=1.0:r=20[black_0];[1:v]setpts=PTS-STARTPTS[v_1];[v_0][black_0][v_1]concat=n=3:v=1:a=0[v_group_0];color=c=black:s=768x320:d=1.0:r=20[black_1];[2:v]setpts=PTS-STARTPTS[v_2];color=c=black:s=768x320:d=1.0:r=20[black_2];[3:v]setpts=PTS-STARTPTS[v_3];[black_1][v_2][black_2][v_3]concat=n=4:v=1:a=0[v_group_1];[v_group_0][v_group_1]xstack=inputs=2:layout=0_0|0_320[v]";
String audioFilterStr="[0:a]asetpts=PTS-STARTPTS[a_0];aevalsrc=0:d=1.996[silence_0];[1:a]asetpts=PTS-STARTPTS[a_1];aevalsrc=0:d=0.996[silence_1];[2:a]asetpts=PTS-STARTPTS[a_2];[a_0][silence_0][a_1][silence_1][a_2]concat=n=5:v=0:a=1[a_group_0];aevalsrc=0:d=1.0[silence_2];[3:a]asetpts=PTS-STARTPTS[a_3];aevalsrc=0:d=1.996[silence_3];[4:a]asetpts=PTS-STARTPTS[a_4];aevalsrc=0:d=0.996[silence_4];[5:a]asetpts=PTS-STARTPTS[a_5];[silence_2][a_3][silence_3][a_4][silence_4][a_5]concat=n=6:v=0:a=1[a_group_1];[a_group_0][a_group_1]amix=inputs=2:duration=longest[a]";
容易看出,这两个filter都是将输入的多个视频流、音频流分组拼合后再使用xstack和amix组合成最终的视频和音频流。至此我们便可以完成一些比较复杂的剪辑和拼接了。
不过,这篇文章的优质内容在于,接下来我会详细列出难以察觉的隐含条件,以帮助和我同样百思不得其解的各位读者。
敲黑板,重点来啦!
1.filter构造后,必须通过setVideoInputs()或setAudioInputs()指定输入音视频流的数量,否则会提示 “Error: Output pad "default" with type audio/video of the filter instance "xxxx" of xxxx not connected to any destination”
2.对于多个视频/音频输入到filter,编写filter描述语句时必须使用[索引:a或v]的格式来指定输入,但当只有1个输入时,不能用[0:a或v]来指定,而必须是[in]
3.对于多个视频/音频输入到filter,filter最后得到输出音视频流的的标签必须是[a]或[v]。但是,只有一个音频或视频输入时,输出音视频流的标签必须是[out]
4.从filter中通过pull获取音视频frame时,可能需要向filter中传入空值(null)的frame对象才能继续获取到filter中缓存的frame。如果你的grabber中所有帧已经传入filter但最后生成的多媒体文件有残缺,并且提示有残留的frame,可以试试继续向filter中提交null值。
5.在我的使用过程中,尝试了很久用一个filter同时处理音频和视频,但是没有成功,所以最后是分开成音频视频各一个filter来实现的。
6.直接使用ffmpeg程序与通过JavaCV使用FFmpeg,指定filter的语句不能完全照搬;例如直接使用例子中的字符串
ffmpeg -i input1.mp4 -i input2.mp4 -filter_complex "[0:v]setpts=PTS-STARTPTS[v_0];color=c=black:s=768x320:d=1.0:r=20[black_0];[1:v]setpts=PTS-STARTPTS[v_1];[v_0][black_0][v_1]concat=n=3:v=1:a=0[v]" -map "[v]" output.mp4
在我的样例中,该语句无法正确执行,必须要通过-r指定帧率,即
ffmpeg -i input1.mp4 -i input2.mp4 -filter_complex "[0:v]setpts=PTS-STARTPTS[v_0];color=c=black:s=768x320:d=1.0:r=20[black_0];[1:v]setpts=PTS-STARTPTS[v_1];[v_0][black_0][v_1]concat=n=3:v=1:a=0[v]" -r 20 -map "[v]" output.mp4
并且在直接使用ffmpeg程序时,一个filter中可以同时处理音频和视频。
参考文章:
https://ffmpeg.org/ffmpeg-filters.html
https://video.stackexchange.com/questions/22769/ffmpeg-filter...
JavaCV 视频滤镜(LOGO、滚动字幕、画中画、NxN宫格)
https://superuser.com/questions/880807/crossfading-video-and-...
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。