吉安感知网项目-后端
xiebin
2026-01-06 d207a86cdf1ab52ef8cb7cd83bad8fceab8038cf
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
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);
        }
    }
 
 
}