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); } } }