前言
基于jave的FFMPEGLocator实现自定义ffmpeg工具类
项目中遇到的问题,大致如下:
1.前端页面获取到微信录音保存的amr文件,但是 <audio>
标签不支持直接播放amr文件。
2.采用openCV和jave转成mp3文件,但这两个jar提供的功能过于丰富,且引入后项目打包偏大。
3.引用其他jar包提供的功能不支持输入流指定到ffmpeg进程,并用输出流接收ffmpeg进程的返回,而是必须指定到本地文件,除非使用临时文件暂存,但这样增加了临时文件读写的消耗。
4.网上没看见其他的java输入/出流方式对接ffmpeg。
由于以上的问题,被安排来写个ffmpeg工具类。
一、ffmpeg介绍
FFmpeg是一个开源的跨平台多媒体处理框架,用于录制、转换、流化数字音频和视频,并能够处理这些媒体的后期处理。
它包含了先进的音频/视频编解码库libavcodec,这个库中的许多编解码器都是从头开始开发的,以确保高可移植性和编解码质量。FFmpeg支持几乎所有视频和音频格式,包括mkv、flv、mov等,并且它的功能非常强大,甚至可以播放一些VLC媒体播放器不支持的流媒体格式。
此外,FFmpeg还支持将媒体文件转化为流,使其能够在网络上进行传输。它可以用命令行或编程语言(如C、Java、Python等)调用,使得自动化处理视频和音频成为可能。
二、jave介绍
JAVE (Java Audio Video Encoder) 类库是一个 ffmpeg 项目的 Java 语言封装。开发人员可以使用 JAVE 在不同的格式间转换视频和音频。例如将 AVI 转成 MPEG 动画,等等 ffmpeg 中可以完成的在 JAVE 都有对应的方法。
三、代码使用
依赖引入:
<dependency>
<groupId>com.github.dadiyang</groupId>
<artifactId>jave</artifactId>
<version>1.0.5</version>
</dependency>
java代码
记录一些比较重要的代码,相关实体类、配置、整体工具类代码可以看最后的案例仓库
反射获取jave的locator中可执行文件的地址
import it.sauronsoftware.jave.DefaultFFMPEGLocator;
import it.sauronsoftware.jave.FFMPEGLocator;
import java.lang.reflect.Field;
/**
* ffmpeg执行文件相关参数
*
* @author suix
* @date 2024-01-31
*/
public class CustomFfmpegExecAttributes {
/**
* ffmpeg定位
*/
private FFMPEGLocator locator;
/**
* ffmpeg执行文件路径
*/
private String execPath;
public CustomFfmpegExecAttributes() {
this.locator = new DefaultFFMPEGLocator();
try {
// 通过反射获取jave的locator中可执行文件的地址,该jar包未提供get方法
Field field = this.locator.getClass().getDeclaredField("path");
field.setAccessible(true);
this.execPath = String.valueOf(field.get(locator));
} catch (Exception e) {
throw new RuntimeException("获取ffmpeg可执行文件异常: " + e.getMessage());
}
}
public FFMPEGLocator getLocator() {
return locator;
}
public String getExecPath() {
return execPath;
}
}
编码(输入/出流)参数构造
/**
* 编码(输入/出流)参数构造
*
* @param attributes 自定义命令参数
* @param inputStream 输入流
* @return cmd命令参数列表
* @author suix
*/
public static List<String> encode(CustomAttributes attributes, InputStream inputStream) throws Exception {
List<String> argList = new ArrayList<>();
if (null == attributes) {
throw new RuntimeException("参数不能为空");
}
if (null == inputStream || inputStream.available() == 0) {
throw new RuntimeException("输入流不可用");
}
// 指定执行器
CustomFfmpegExecAttributes execAttributes = new CustomFfmpegExecAttributes();
argList.add(execAttributes.getExecPath());
// -i - 输入流指定至ffmpeg入参
argList.add(CustomArgs.INPUT);
argList.add("pipe:0");
// 构造输入/出路径之外的参数
generateArgsWithoutPath(attributes, argList);
// 输出流接收ffmpeg返回
argList.add("pipe:1");
// 打印命令
for (String arg : argList) {
System.out.print(arg + " ");
}
return argList;
}
构造输入/出路径之外的参数
/**
* 构造不包含输入/出文件路径的ffmpeg参数命令
*
* @param attributes 自定义命令参数
* @param argList ffmpeg命令行参数
* @author suix
*/
private static void generateArgsWithoutPath(CustomAttributes attributes, List<String> argList) {
CustomVideoAttributes videoAttributes = attributes.getVideoAttributes();
CustomAudioAttributes audioAttributes = attributes.getAudioAttributes();
// 视频流相关
if (null == videoAttributes) {
// 不输出视频
argList.add(CustomArgs.UN_VIDEO);
} else {
// 视频流编码器参数
generateVideoArgs(videoAttributes, attributes.isSubtitles(), argList);
}
// 音频流相关
if (null == audioAttributes) {
// 不输出音频
argList.add(CustomArgs.UN_AUDIO);
} else {
// 音频流编码器参数
generateAudioArgs(audioAttributes, argList);
}
// 指定输出文件编码格式
argList.add(CustomArgs.FORMAT);
String format = attributes.getFormat();
if (null == format || format.isBlank()) {
// 文件编码格式默认为mp4/mp3
format = attributes.isVideo() ? "mp4" : "mp3";
}
argList.add(format);
// 指定编码速度和质量的预设值
String presetName = attributes.getPresetName();
if (null != presetName && !presetName.isBlank()) {
argList.add(CustomArgs.PRESET);
argList.add(presetName);
}
// 指定编码器的调整参数
String tuningParameter = attributes.getTuningParameter();
if (null != tuningParameter && !tuningParameter.isBlank()) {
argList.add(CustomArgs.TUNE);
argList.add(tuningParameter);
}
// 指定线程数
String threadsCount = attributes.getThreadsCount();
if (null != threadsCount && !threadsCount.isBlank()) {
argList.add(CustomArgs.THREADS_COUNT);
argList.add(threadsCount);
}
// 指定从输入文件的哪个时间点开始转码
String startTime = attributes.getStartTime();
if (null != startTime && !startTime.isBlank()) {
argList.add(CustomArgs.START_TIME);
argList.add(startTime);
}
// 指定转码的时长,默认单位为秒
String duration = attributes.getDuration();
if (null != duration && !duration.isBlank()) {
argList.add(CustomArgs.DURATION);
argList.add(duration);
}
}
java输入/出流对接ffmpeg进程
/**
* 输入/出流方式,执行ffmpeg转码amr文件为mp3文件,并获取ffmpeg返回输出流
*
* @param attributes CustomAttributes 自定义ffmpeg参数
* @param inputStream 文件输入流
* @return ByteArrayOutputStream
* @author suix
*/
public static ByteArrayOutputStream execFfmpegCommand(CustomAttributes attributes,
InputStream inputStream) throws Exception {
// 命令参数
List<String> commands = CustomEncoder.encode(attributes, inputStream);
ProcessBuilder processBuilder = new ProcessBuilder(commands);
Process process = processBuilder.start();
/*
当同时自定义了ffmpeg的输入和输出为管道时,需要确保两边的读写操作都是异步且适当处理缓冲,否则会出现堵塞现象
采用多线程的方式实现
*/
ExecutorService executor = Executors.newFixedThreadPool(2);
// 线程1 执行文件流指定至ffmpeg进程的输入流
Future<?> stdinWriterFuture = executor.submit(() -> {
try (OutputStream ffmpegStdin = process.getOutputStream()) {
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
ffmpegStdin.write(buffer, 0, bytesRead);
ffmpegStdin.flush(); // 防止缓冲区满造成阻塞
}
} catch (IOException e) {
e.printStackTrace();
}
});
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
// 线程2 指定输出流接取ffmpeg进程的输出流
Future<?> stdoutReaderFuture = executor.submit(() -> {
try (InputStream ffmpegStdout = process.getInputStream();
InputStream ffmpegStderr = process.getErrorStream()) {
// 合并stdout和stderr的流以同时处理
InputStream combinedStream = new SequenceInputStream(ffmpegStdout, ffmpegStderr);
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = combinedStream.read(buffer)) != -1) {
byteArrayOutputStream.write(buffer, 0, bytesRead);
byteArrayOutputStream.flush();
}
} catch (IOException e) {
e.printStackTrace();
}
});
// 等待所有任务完成
stdinWriterFuture.get();
stdoutReaderFuture.get();
// 关闭线程池
executor.shutdown();
return byteArrayOutputStream;
}
其中有个坑,本地测试的时候最初采用单线程的方式。
当只有输入流或只有输出流对接ffmpeg进程的时候,可以正常使用,符合预期。
但当输入流和输出流同时对接ffmpeg进程的时候,输入流读取中途线程会堵塞。
看见一篇c++对接ffmpeg的文章,看见使用多线程对接,突然有了灵感改为异步操作。
原因大致如下:
问题可能出在Java进程与FFmpeg子进程之间的输入输出流同步上。当同时自定义了ffmpeg的输入和输出为管道时,需要确保两边的读写操作都是异步且适当处理缓冲。
这里的一个常见问题是,在向ffmpeg的stdin(标准输入)写入数据的同时,如果没有正确处理stdout(标准输出)和stderr(标准错误输出),可能会导致缓冲区满进而阻塞ffmpeg进程或者Java进程。
四、工具类代码仓库
五、参考文章
修复com.github.dadiyang-jave-1.0.5获取部分mp4视频高度宽度出现NPM异常
FFmpeg 的使用
使用ffmpeg实现管道输入输出,并连接在代码中
总结
几步比较重要的地方就在于,java反射获取执行器地址,构造ffmpeg命令行参数列表,输入/出流异步对接ffmpeg进程。
第一次在csdn写博客,有点小麻烦 0.0