doum
2026-06-11 d9c657aa78cf0ebe31933a87e63ca92edd8a8da3
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
package com.doumee.core.utils;
 
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
 
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.TimeUnit;
 
/**
 * 将海康等设备下载的媒体流转为浏览器 video 标签可播的 MP4(H.264 + AAC)。
 * 海康 MP4 常见损坏的 MP2 音频轨,默认丢弃异常音频仅保留视频。
 */
@Slf4j
public final class VideoTranscodeUtil {
 
    private static final long TRANSCODE_TIMEOUT_MINUTES = 30;
 
    private VideoTranscodeUtil() {
    }
 
    /**
     * 将 MP4 视频(含海康 .mp4 扩展名但内容为 MPEG-PS 的情况)转为浏览器可播 MP4。
     * 调用方需已通过文件名等条件确认是 MP4 视频,本方法不再因缺少 ftyp 头而跳过。
     */
    public static boolean transcodeToBrowserMp4(String ffmpegPath, File source, File target) {
        if (source == null || !source.exists() || source.length() <= 0) {
            return false;
        }
        String ffmpeg = resolveExecutable(ffmpegPath, "ffmpeg");
        String ffprobe = resolveFfprobe(ffmpegPath);
        boolean mpegPs = false;
        boolean mp4Container = false;
        try {
            mpegPs = isMpegPs(source);
            mp4Container = hasMp4Header(source);
        } catch (IOException e) {
            log.warn("视频文件头检测失败,继续尝试 FFmpeg 转码: {}", e.getMessage());
        }
        if (mpegPs) {
            log.info("检测到 MPEG-PS 流(扩展名可能为 .mp4),将转码为标准 MP4: size={}", source.length());
        } else if (!mp4Container) {
            log.info("文件无 MP4 容器头(无 ftyp),尝试 FFmpeg 按视频流转码: size={}", source.length());
        }
 
        try {
            if (mp4Container && isBrowserPlayableMp4(ffmpeg, source)) {
                Files.copy(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING);
                log.info("视频已是浏览器可播 MP4,跳过转码 size={}", source.length());
                return target.exists() && target.length() > 0;
            }
        } catch (Exception e) {
            log.warn("检测视频编码失败,将尝试转码: {}", e.getMessage());
        }
 
        String inputPath = source.getAbsolutePath();
        String outputPath = target.getAbsolutePath();
        String videoCodec = probeStreamCodec(ffprobe, source, "v:0");
 
        // 1. MP4 容器 + H.264:重封装并丢弃损坏音频
        if (mp4Container && "h264".equalsIgnoreCase(videoCodec)) {
            if (runFfmpeg(buildRemuxCommand(ffmpeg, source, inputPath, outputPath), "重封装(H.264 copy, 无音频)")) {
                return isValidOutput(target);
            }
        }
 
        // 2. 转码视频轨(适用于 MPEG-PS、损坏 MP4 等)
        if (runFfmpeg(buildEncodeVideoNoAudioCommand(ffmpeg, source, inputPath, outputPath), "转码(仅视频)")) {
            return isValidOutput(target);
        }
 
        // 3. 音频轨正常时才尝试带上音频
        String audioCodec = probeStreamCodec(ffprobe, source, "a:0");
        if (isDecodableAudio(audioCodec)) {
            if (runFfmpeg(buildEncodeVideoWithAudioCommand(ffmpeg, source, inputPath, outputPath), "转码(视频+音频)")) {
                return isValidOutput(target);
            }
        }
 
        log.error("视频转码全部策略失败 source={}", source.getAbsolutePath());
        return false;
    }
 
    public static boolean isBrowserPlayableMp4(String ffmpegPath, File file) throws IOException {
        if (!hasMp4Header(file)) {
            return false;
        }
        if (isMpegPs(file)) {
            return false;
        }
        String ffprobe = resolveFfprobe(ffmpegPath);
        String videoCodec = probeStreamCodec(ffprobe, file, "v:0");
        if (!"h264".equalsIgnoreCase(videoCodec)) {
            return false;
        }
        String audioCodec = probeStreamCodec(ffprobe, file, "a:0");
        if (StringUtils.isBlank(audioCodec)) {
            return true;
        }
        return "aac".equalsIgnoreCase(audioCodec);
    }
 
    public static boolean hasMp4Header(File file) throws IOException {
        byte[] head = readHead(file, 12);
        return head.length >= 8 && head[4] == 'f' && head[5] == 't' && head[6] == 'y' && head[7] == 'p';
    }
 
    public static boolean isMpegPs(File file) throws IOException {
        byte[] head = readHead(file, 4);
        return head.length >= 4 && head[0] == 0 && head[1] == 0 && head[2] == 1 && (head[3] & 0xFF) == 0xBA;
    }
 
    private static boolean isDecodableAudio(String codec) {
        if (StringUtils.isBlank(codec)) {
            return false;
        }
        return "aac".equalsIgnoreCase(codec)
                || "mp3".equalsIgnoreCase(codec)
                || codec.toLowerCase().startsWith("pcm");
    }
 
    private static boolean isValidOutput(File target) {
        return target != null && target.exists() && target.length() > 0;
    }
 
    private static List<String> buildInputArgs(String ffmpeg, File source, String inputPath) {
        List<String> cmd = new ArrayList<>();
        cmd.add(ffmpeg);
        cmd.add("-y");
        cmd.add("-fflags");
        cmd.add("+discardcorrupt");
        cmd.add("-err_detect");
        cmd.add("ignore_err");
        try {
            if (isMpegPs(source)) {
                cmd.add("-f");
                cmd.add("mpeg");
            }
        } catch (IOException ignored) {
        }
        cmd.add("-i");
        cmd.add(inputPath);
        return cmd;
    }
 
    /** 重封装:复制 H.264 视频,丢弃损坏音频 */
    private static List<String> buildRemuxCommand(String ffmpeg, File source, String inputPath, String outputPath) {
        List<String> cmd = buildInputArgs(ffmpeg, source, inputPath);
        cmd.add("-map");
        cmd.add("0:v:0");
        cmd.add("-an");
        cmd.add("-c:v");
        cmd.add("copy");
        cmd.add("-movflags");
        cmd.add("+faststart");
        cmd.add(outputPath);
        return cmd;
    }
 
    /** 转码:仅视频轨,无音频 */
    private static List<String> buildEncodeVideoNoAudioCommand(String ffmpeg, File source, String inputPath, String outputPath) {
        List<String> cmd = buildInputArgs(ffmpeg, source, inputPath);
        cmd.add("-map");
        cmd.add("0:v:0");
        cmd.add("-an");
        cmd.add("-c:v");
        cmd.add("libx264");
        cmd.add("-preset");
        cmd.add("fast");
        cmd.add("-crf");
        cmd.add("23");
        cmd.add("-pix_fmt");
        cmd.add("yuv420p");
        cmd.add("-movflags");
        cmd.add("+faststart");
        cmd.add(outputPath);
        return cmd;
    }
 
    /** 转码:视频 + 可解码音频 */
    private static List<String> buildEncodeVideoWithAudioCommand(String ffmpeg, File source, String inputPath, String outputPath) {
        List<String> cmd = buildInputArgs(ffmpeg, source, inputPath);
        cmd.add("-map");
        cmd.add("0:v:0");
        cmd.add("-map");
        cmd.add("0:a:0?");
        cmd.add("-c:v");
        cmd.add("libx264");
        cmd.add("-preset");
        cmd.add("fast");
        cmd.add("-crf");
        cmd.add("23");
        cmd.add("-pix_fmt");
        cmd.add("yuv420p");
        cmd.add("-c:a");
        cmd.add("aac");
        cmd.add("-b:a");
        cmd.add("128k");
        cmd.add("-ac");
        cmd.add("2");
        cmd.add("-movflags");
        cmd.add("+faststart");
        cmd.add(outputPath);
        return cmd;
    }
 
    private static boolean runFfmpeg(List<String> command, String label) {
        try {
            int exitCode = runCommand(command, TRANSCODE_TIMEOUT_MINUTES);
            if (exitCode == 0) {
                log.info("FFmpeg {} 成功", label);
                return true;
            }
            log.warn("FFmpeg {} 失败 exitCode={}", label, exitCode);
        } catch (Exception e) {
            log.warn("FFmpeg {} 异常: {}", label, e.getMessage());
        }
        return false;
    }
 
    private static byte[] readHead(File file, int len) throws IOException {
        byte[] head = new byte[len];
        try (FileInputStream in = new FileInputStream(file)) {
            int n = in.read(head);
            if (n <= 0) {
                return new byte[0];
            }
            if (n < len) {
                byte[] actual = new byte[n];
                System.arraycopy(head, 0, actual, 0, n);
                return actual;
            }
        }
        return head;
    }
 
    private static String probeStreamCodec(String ffprobe, File file, String stream) {
        List<String> command = Arrays.asList(
                ffprobe,
                "-v", "error",
                "-select_streams", stream,
                "-show_entries", "stream=codec_name",
                "-of", "default=noprint_wrappers=1:nokey=1",
                file.getAbsolutePath()
        );
        try {
            ProcessBuilder builder = new ProcessBuilder(command);
            builder.redirectErrorStream(true);
            Process process = builder.start();
            String output = readStream(process.getInputStream());
            boolean finished = process.waitFor(2, TimeUnit.MINUTES);
            if (!finished) {
                process.destroyForcibly();
                return null;
            }
            if (process.exitValue() != 0) {
                return null;
            }
            return output.trim();
        } catch (Exception e) {
            log.warn("ffprobe 检测失败 stream={}: {}", stream, e.getMessage());
            return null;
        }
    }
 
    private static String resolveExecutable(String configuredPath, String defaultName) {
        if (StringUtils.isNotBlank(configuredPath)) {
            return configuredPath.trim();
        }
        return defaultName;
    }
 
    private static String resolveFfprobe(String ffmpegPath) {
        if (StringUtils.isNotBlank(ffmpegPath)) {
            String path = ffmpegPath.trim();
            if (path.toLowerCase().endsWith("ffmpeg.exe") || path.toLowerCase().endsWith("ffmpeg")) {
                int idx = path.lastIndexOf('/');
                if (idx < 0) {
                    idx = path.lastIndexOf('\\');
                }
                String dir = idx >= 0 ? path.substring(0, idx + 1) : "";
                String name = path.substring(idx + 1);
                if (name.toLowerCase().endsWith(".exe")) {
                    return dir + name.replaceAll("(?i)ffmpeg\\.exe$", "ffprobe.exe");
                }
                return dir + name.replaceAll("(?i)ffmpeg$", "ffprobe");
            }
            return path.replaceAll("(?i)ffmpeg", "ffprobe");
        }
        return "ffprobe";
    }
 
    private static int runCommand(List<String> command, long timeoutMinutes) throws IOException, InterruptedException {
        ProcessBuilder builder = new ProcessBuilder(command);
        builder.redirectErrorStream(true);
        Process process = builder.start();
        String output = readStream(process.getInputStream());
        boolean finished = process.waitFor(timeoutMinutes, TimeUnit.MINUTES);
        if (!finished) {
            process.destroyForcibly();
            log.error("FFmpeg 执行超时: {}", String.join(" ", command));
            return -1;
        }
        if (StringUtils.isNotBlank(output)) {
            if (process.exitValue() != 0) {
                log.warn("FFmpeg 输出: {}", output.length() > 2000 ? output.substring(0, 2000) + "..." : output);
            } else {
                log.debug("FFmpeg 输出: {}", output.length() > 2000 ? output.substring(0, 2000) + "..." : output);
            }
        }
        return process.exitValue();
    }
 
    private static String readStream(java.io.InputStream inputStream) throws IOException {
        StringBuilder sb = new StringBuilder();
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) {
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line).append('\n');
            }
        }
        return sb.toString().trim();
    }
}