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