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 buildInputArgs(String ffmpeg, File source, String inputPath) { List 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 buildRemuxCommand(String ffmpeg, File source, String inputPath, String outputPath) { List 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 buildEncodeVideoNoAudioCommand(String ffmpeg, File source, String inputPath, String outputPath) { List 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 buildEncodeVideoWithAudioCommand(String ffmpeg, File source, String inputPath, String outputPath) { List 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 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 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 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(); } }