package org.sxkj.common.utils.ffmpeg;
|
|
import org.slf4j.Logger;
|
import org.slf4j.LoggerFactory;
|
|
import java.io.*;
|
import java.nio.file.Files;
|
import java.nio.file.Path;
|
import java.util.Arrays;
|
import java.util.UUID;
|
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.Executors;
|
import java.util.concurrent.TimeUnit;
|
|
/**
|
* @Description 音频视频转码工具类(使用FFmpeg)
|
* @Author AIX
|
* @Date 2025/5/22 14:11
|
* @Version 1.0
|
*/
|
public class FfmpegConverterUtils {
|
|
private static final Logger logger = LoggerFactory.getLogger(FfmpegConverterUtils.class);
|
private static final int PROCESS_TIMEOUT_SECONDS = 600;
|
|
|
|
/**
|
* 音频转码为Opus格式
|
* @param inputFile 待转换音频文件
|
* @param name 输出文件名(不含扩展名)
|
* @return 转换后的Opus文件对象
|
* @throws ConversionException 转换失败时抛出
|
*/
|
public static File convertToOpus(File inputFile, String name) throws ConversionException {
|
validateInputFile(inputFile);
|
if (name == null || name.trim().isEmpty()) {
|
throw new ConversionException("输出文件名不能为空");
|
}
|
|
File outputFile = createTempFile(name, "opus");
|
|
try {
|
ProcessBuilder pb = new ProcessBuilder(
|
"ffmpeg",
|
"-i", inputFile.getAbsolutePath(),
|
"-ar", "16000",
|
"-ac", "1",
|
"-b:a", "16k",
|
"-compression_level", "8",
|
"-y",
|
outputFile.getAbsolutePath()
|
);
|
|
executeConversion(pb, outputFile);
|
return outputFile;
|
} catch (Exception e) {
|
cleanupTempFile(outputFile);
|
throw new ConversionException("音频转Opus失败: " + e.getMessage(), e);
|
}
|
}
|
|
/**
|
* 将音频转换为PCM格式
|
*/
|
public static File convertToPcm(File inputFile, String name) throws ConversionException {
|
String fileType = getFileExtension(inputFile.getName());
|
|
if ("wav".equalsIgnoreCase(fileType)) {
|
return convertTo16kPcmWav(inputFile);
|
} else {
|
return convertTo16kRawPcm(inputFile);
|
}
|
}
|
|
/**
|
* 压缩视频文件
|
*/
|
public static File compressVideo(File inputFile, int targetSizeMB, String outputFileName) throws ConversionException {
|
validateInputFile(inputFile);
|
if (targetSizeMB <= 0) {
|
throw new ConversionException("目标大小必须大于0");
|
}
|
validateOutputFileName(outputFileName);
|
|
File outputFile = createTempFile(outputFileName, "mp4");
|
|
try {
|
int durationSeconds = getVideoDuration(inputFile);
|
if (durationSeconds <= 0) {
|
throw new ConversionException("无法获取视频时长或时长为0");
|
}
|
|
int targetBitrate = (int) ((targetSizeMB * 1024 * 8) / durationSeconds);
|
|
ProcessBuilder pb = new ProcessBuilder(
|
"ffmpeg",
|
"-i", inputFile.getAbsolutePath(),
|
"-threads", "1",
|
"-c:v", "libx264",
|
"-b:v", targetBitrate + "k",
|
"-c:a", "aac",
|
"-b:a", "128k",
|
"-movflags", "+faststart",
|
"-y",
|
outputFile.getAbsolutePath()
|
);
|
|
executeConversion(pb, outputFile);
|
return outputFile;
|
} catch (Exception e) {
|
cleanupTempFile(outputFile);
|
throw new ConversionException("视频压缩失败: " + e.getMessage(), e);
|
}
|
}
|
|
/**
|
* 从视频中抽取一帧作为图片
|
*/
|
public static File extractVideoFrame(File inputFile, String timePosition, String outputFileName, String imageFormat)
|
throws ConversionException {
|
validateInputFile(inputFile);
|
if (timePosition == null || timePosition.trim().isEmpty()) {
|
throw new ConversionException("时间位置不能为空");
|
}
|
validateOutputFileName(outputFileName);
|
|
String format = imageFormat.toLowerCase();
|
if (!Arrays.asList("jpg", "png").contains(format)) {
|
throw new ConversionException("不支持的图片格式: " + imageFormat);
|
}
|
|
File outputFile = createTempFile(outputFileName, format);
|
|
try {
|
ProcessBuilder pb = new ProcessBuilder(
|
"ffmpeg",
|
"-ss", timePosition,
|
"-noaccurate_seek",
|
"-i", inputFile.getAbsolutePath(),
|
"-map", "0:v:0",
|
"-an", "-sn", "-dn",
|
"-frames:v", "1",
|
"-q:v", "30",
|
"-y",
|
outputFile.getAbsolutePath()
|
);
|
|
executeConversion(pb, outputFile);
|
return outputFile;
|
} catch (Exception e) {
|
cleanupTempFile(outputFile);
|
throw new ConversionException("视频帧抽取失败: " + e.getMessage(), e);
|
}
|
}
|
|
/**
|
* 获取视频时长(秒)
|
*/
|
public static int getVideoDuration(File videoFile) throws ConversionException {
|
Process process = null;
|
try {
|
ProcessBuilder pb = new ProcessBuilder(
|
"ffprobe",
|
"-v", "error",
|
"-show_entries", "format=duration",
|
"-of", "default=noprint_wrappers=1:nokey=1",
|
videoFile.getAbsolutePath()
|
);
|
|
process = pb.start();
|
String output;
|
try (InputStream is = process.getInputStream()) {
|
output = new String(is.readAllBytes()).trim();
|
}
|
|
int exitCode = process.waitFor();
|
if (exitCode != 0) {
|
throw new ConversionException("获取视频时长失败,退出码: " + exitCode);
|
}
|
|
return (int) Math.round(Double.parseDouble(output));
|
} catch (Exception e) {
|
throw new ConversionException("获取视频时长失败: " + e.getMessage(), e);
|
} finally {
|
if (process != null) {
|
process.destroy();
|
}
|
}
|
}
|
|
/**
|
* 将音频转换为16kHz PCM WAV格式
|
*/
|
private static File convertTo16kPcmWav(File inputFile) throws ConversionException {
|
validateInputFile(inputFile);
|
File outputFile = createTempFile(UUID.randomUUID().toString(), "wav");
|
|
try {
|
ProcessBuilder pb = new ProcessBuilder(
|
"ffmpeg",
|
"-i", inputFile.getAbsolutePath(),
|
"-ar", "16000",
|
"-ac", "1",
|
"-acodec", "pcm_s16le",
|
"-f", "wav",
|
"-y",
|
outputFile.getAbsolutePath()
|
);
|
|
executeConversion(pb, outputFile);
|
return outputFile;
|
} catch (Exception e) {
|
cleanupTempFile(outputFile);
|
throw new ConversionException("转16k PCM WAV失败: " + e.getMessage(), e);
|
}
|
}
|
|
/**
|
* 将音频转换为原始16kHz PCM格式
|
*/
|
private static File convertTo16kRawPcm(File inputFile) throws ConversionException {
|
validateInputFile(inputFile);
|
File outputFile = createTempFile(UUID.randomUUID().toString(), "pcm");
|
|
try {
|
ProcessBuilder pb = new ProcessBuilder(
|
"ffmpeg",
|
"-i", inputFile.getAbsolutePath(),
|
"-ar", "16000",
|
"-ac", "1",
|
"-acodec", "pcm_s16le",
|
"-f", "s16le",
|
"-y",
|
outputFile.getAbsolutePath()
|
);
|
|
executeConversion(pb, outputFile);
|
return outputFile;
|
} catch (Exception e) {
|
cleanupTempFile(outputFile);
|
throw new ConversionException("转16k原始PCM失败: " + e.getMessage(), e);
|
}
|
}
|
|
// ============== 辅助方法 ==============
|
|
private static String getFileExtension(String fileName) {
|
int lastIndex = fileName.lastIndexOf('.');
|
return (lastIndex != -1) ? fileName.substring(lastIndex + 1).toLowerCase() : "";
|
}
|
|
private static void validateInputFile(File inputFile) throws ConversionException {
|
if (inputFile == null || !inputFile.exists()) {
|
throw new ConversionException("输入文件不能为空且必须存在");
|
}
|
if (!inputFile.canRead()) {
|
throw new ConversionException("无法读取输入文件: " + inputFile.getAbsolutePath());
|
}
|
}
|
|
private static void validateOutputFileName(String fileName) throws ConversionException {
|
if (fileName == null || fileName.trim().isEmpty()) {
|
throw new ConversionException("输出文件名不能为空");
|
}
|
}
|
|
private static File createTempFile(String name, String extension) throws ConversionException {
|
try {
|
Path tempDir = Files.createTempDirectory("ffmpeg_conv_");
|
registerTempDirForCleanup(tempDir);
|
return tempDir.resolve(name + "." + extension).toFile();
|
} catch (IOException e) {
|
throw new ConversionException("无法创建临时文件", e);
|
}
|
}
|
|
// 在 FfmpegConverterUtils 类中添加如下字段:
|
private static final ExecutorService streamConsumerPool = Executors.newCachedThreadPool();
|
|
/**
|
* 消费流数据
|
*/
|
private static void executeConversion(ProcessBuilder pb, File outputFile)
|
throws IOException, InterruptedException, ConversionException {
|
pb.redirectErrorStream(true);
|
Process process = pb.start();
|
|
try (InputStream stdout = process.getInputStream();
|
InputStream stderr = process.getErrorStream()) {
|
|
// 使用线程池提交任务消费输出流
|
streamConsumerPool.submit(() -> consumeStream(stdout));
|
streamConsumerPool.submit(() -> consumeStream(stderr));
|
|
process.waitFor();
|
|
// boolean finished = process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
// if (!finished) {
|
// process.destroyForcibly();
|
// throw new ConversionException("转换超时");
|
// }
|
|
}
|
|
if (process.exitValue() != 0) {
|
String errorOutput = new String(process.getInputStream().readAllBytes());
|
logger.error("FFmpeg命令执行失败,错误输出:\n{}", errorOutput);
|
throw new ConversionException("FFmpeg失败,退出码: " + process.exitValue());
|
}
|
|
if (!outputFile.exists() || outputFile.length() == 0) {
|
throw new ConversionException("输出文件无效");
|
}
|
}
|
|
private static void consumeStream(InputStream is) {
|
try (BufferedReader reader = new BufferedReader(new InputStreamReader(is))) {
|
String line;
|
while ((line = reader.readLine()) != null) {
|
System.out.println(line);
|
}
|
} catch (IOException e) {
|
logger.warn("流消费异常", e);
|
}
|
}
|
|
private static void registerTempDirForCleanup(Path dirPath) {
|
dirPath.toFile().deleteOnExit();
|
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
|
try {
|
Files.walk(dirPath)
|
.sorted((a, b) -> b.compareTo(a)) // 先删除文件再删除目录
|
.forEach(path -> {
|
try {
|
Files.deleteIfExists(path);
|
} catch (IOException e) {
|
logger.warn("临时文件清理失败: {}", path, e);
|
}
|
});
|
} catch (IOException e) {
|
logger.warn("临时目录清理失败: {}", dirPath, e);
|
}
|
}));
|
}
|
|
private static void cleanupTempFile(File file) {
|
if (file != null && file.exists()) {
|
try {
|
Path path = file.toPath();
|
Path parent = path.getParent();
|
|
Files.deleteIfExists(path);
|
|
if (parent != null && Files.isDirectory(parent)) {
|
try (var stream = Files.list(parent)) {
|
if (stream.count() == 0) {
|
Files.deleteIfExists(parent);
|
}
|
}
|
}
|
} catch (IOException e) {
|
logger.warn("清理临时文件失败: {}", file.getAbsolutePath(), e);
|
}
|
}
|
}
|
|
/**
|
* 自定义转换异常
|
*/
|
public static class ConversionException extends Exception {
|
public ConversionException(String message) {
|
super(message);
|
}
|
public ConversionException(String message, Throwable cause) {
|
super(message, cause);
|
}
|
}
|
|
|
}
|