package com.jsfj.core.agora.util;
/**
@author dong.cheng
@description
@Date 2025年10月27日 16:32
/
import com.jsfj.core.agora.conf.AgoraProperties;
import com.jsfj.core.agora.strategy.IBackStrategy;
import com.jsfj.core.common.util.$;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.;
import java.nio.file.;
import java.text.SimpleDateFormat;
import java.util.;
import java.util.concurrent.*;
public class VideoMergeUtil {
private static final Logger log = LoggerFactory.getLogger(VideoMergeUtil.class);
private static final ConcurrentMap<String, Boolean> mergingRooms = new ConcurrentHashMap<>();
private static ThreadPoolExecutor mergeThreadPoolExecutor;
static { int corePoolSize = Math.max(2, Runtime.getRuntime().availableProcessors() / 2); mergeThreadPoolExecutor = new ThreadPoolExecutor( corePoolSize, corePoolSize * 2, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(100), new ThreadPoolExecutor.CallerRunsPolicy() ); } // 媒体文件对容器 public static class MediaPair { public final File videoFile; public final File audioFile; public MediaPair(File videoFile, File audioFile) { this.videoFile = videoFile; this.audioFile = audioFile; } } /** * 合并房间音视频主入口 */ public static void mergeVideo(String roomPath, String roomId, AgoraProperties properties, IBackStrategy iBackStrategy) { File roomDir = new File(roomPath); Runnable runnable = () -> { // 并发控制:防止同一房间重复合并 if (mergingRooms.putIfAbsent(roomId, true) != null) { log.warn("【视频合并】房间{}已有合并任务进行中,跳过本次", roomId); return; } try { List<File> mergedFiles = processMediaFiles(roomDir, roomPath); moveAndCallback(mergedFiles, roomId, properties, iBackStrategy); } catch (Exception e) { handleMergeError(e, roomId, iBackStrategy); } finally { mergingRooms.remove(roomId); cleanTempFiles(roomPath); } }; mergeThreadPoolExecutor.execute(runnable); } /** * 核心媒体处理逻辑 */ private static List<File> processMediaFiles(File roomDir, String roomPath) throws Exception { // 1. 检查FFmpeg可用性 if (!checkFfmpegAvailable()) { throw new IllegalStateException("FFmpeg not found in system PATH"); } // 2. 智能配对音视频文件 Map<String, List<MediaPair>> mediaFiles = advancedPairing(roomDir); if (mediaFiles.isEmpty()) { log.warn("【音视频配对】未找到可配对文件"); return Collections.emptyList(); } // 3. 处理每个配对 List<File> mergedFiles = new ArrayList<>(); for (Map.Entry<String, List<MediaPair>> entry : mediaFiles.entrySet()) { String uid = entry.getKey(); for (MediaPair pair : entry.getValue()) { File outputFile = createOutputFile(uid, pair.videoFile, roomPath); if (outputFile.exists()) { log.info("【文件存在】跳过已合成文件: {}", outputFile.getName()); mergedFiles.add(outputFile); continue; } executeTranscode(pair, outputFile); mergedFiles.add(outputFile); log.info("【合成成功】{} => {}", pair.videoFile.getName(), outputFile.getName()); } } return mergedFiles; } /** * 智能音视频配对算法 (核心改进) */ private static Map<String, List<MediaPair>> advancedPairing(File dir) { // UID -> 类型 -> 文件列表 Map<String, Map<String, List<File>>> uidTypeMap = new HashMap<>(); // 扫描所有媒体文件 for (File file : Optional.ofNullable(dir.listFiles()).orElse(new File[0])) { String name = file.getName(); if (!isMediaFile(name)) continue; // 解析文件名:1001_20251027080629555.aac String[] parts = parseFileName(name); if (parts == null || parts.length < 2) continue; String uid = parts[0]; String type = parts[1]; // "video"或"audio" uidTypeMap .computeIfAbsent(uid, k -> new HashMap<>()) .computeIfAbsent(type, k -> new ArrayList<>()) .add(file); } // 配对处理 Map<String, List<MediaPair>> result = new HashMap<>(); for (Map.Entry<String, Map<String, List<File>>> uidEntry : uidTypeMap.entrySet()) { String uid = uidEntry.getKey(); Map<String, List<File>> typeMap = uidEntry.getValue(); List<File> videos = typeMap.getOrDefault("video", Collections.emptyList()); List<File> audios = typeMap.getOrDefault("audio", Collections.emptyList()); // 双指针高效配对算法 List<MediaPair> pairs = pairMediaFiles(videos, audios); if (!pairs.isEmpty()) { result.put(uid, pairs); } } return result; } /** * 双指针法配对音视频文件 (O(n+m)时间复杂度) */ private static List<MediaPair> pairMediaFiles(List<File> videos, List<File> audios) { List<MediaPair> pairs = new ArrayList<>(); if (videos.isEmpty() || audios.isEmpty()) return pairs; // 按时间戳排序(升序) Comparator<File> timestampComparator = Comparator.comparingLong( f -> extractTimestamp(f.getName()) ); videos.sort(timestampComparator); audios.sort(timestampComparator); int vIdx = 0, aIdx = 0; while (vIdx < videos.size() && aIdx < audios.size()) { File video = videos.get(vIdx); File audio = audios.get(aIdx); long videoTs = extractTimestamp(video.getName()); long audioTs = extractTimestamp(audio.getName()); long diff = Math.abs(videoTs - audioTs); // 时间差在3秒内视为有效配对 if (diff <= 3000) { pairs.add(new MediaPair(video, audio)); vIdx++; aIdx++; } // 视频时间早于音频(追赶视频) else if (videoTs < audioTs) { vIdx++; } // 音频时间早于视频(追赶音频) else { aIdx++; } } return pairs; } /** * 执行转码操作(完整参数配置) */ private static void executeTranscode(MediaPair pair, File outputFile) throws Exception { List<String> command = new ArrayList<>(); command.add("ffmpeg"); command.add("-y"); // 覆盖输出文件 command.add("-hwaccel"); command.add("auto"); // 自动硬件加速 // 视频输入 command.add("-i"); command.add(pair.videoFile.getAbsolutePath()); // 音频输入 command.add("-i"); command.add(pair.audioFile.getAbsolutePath()); // 视频编码参数(浏览器兼容关键) Collections.addAll(command, "-c:v", "libx264", "-profile:v", "main", // 兼容移动设备 "-level", "4.0", // 主流设备支持 "-pix_fmt", "yuv420p", // 通用像素格式 "-preset", "fast", // 速度与质量平衡 "-crf", "23", // 视觉质量参数 "-g", "60", // GOP大小 "-x264-params", "scenecut=0" ); // 音频编码参数 Collections.addAll(command, "-c:a", "aac", "-b:a", "128k", // 音频比特率 "-ac", "2", // 双声道 "-ar", "44100" // 采样率 ); // 容器参数 Collections.addAll(command, "-movflags", "+faststart", // 流媒体优化 "-max_muxing_queue_size", "1024", // 避免同步问题 "-shortest", // 按最短流结束 outputFile.getAbsolutePath() ); // 执行命令(带超时控制) ProcessBuilder pb = new ProcessBuilder(command); pb.redirectErrorStream(true); Process process = pb.start(); // 异步消费输出流(避免阻塞) new Thread(() -> { try (BufferedReader reader = new BufferedReader( new InputStreamReader(process.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { log.debug("[FFmpeg] {}", line); } } catch (IOException e) { log.warn("FFmpeg输出流读取异常", e); } }).start(); // 设置超时(3分钟) if (!process.waitFor(3, TimeUnit.MINUTES)) { process.destroyForcibly(); throw new TimeoutException("FFmpeg处理超时"); } if (process.exitValue() != 0) { throw new IOException("FFmpeg错误码: " + process.exitValue()); } } /** * 文件整理与回调通知 */ private static void moveAndCallback(List<File> mergedFiles, String roomId, AgoraProperties properties, IBackStrategy iBackStrategy) { if (mergedFiles.isEmpty()) { log.warn("【文件移动】未发现合成文件"); return; } // 按日期组织目录 String dateDir = new SimpleDateFormat("yyyy/MM/dd").format(new Date()); String targetDirPath = properties.getVideoPath() + dateDir + roomId; File targetDir = new File(targetDirPath); if (!targetDir.exists() && !targetDir.mkdirs()) { log.error("【目录创建】失败: {}", targetDir); return; } List<String> successFiles = new ArrayList<>(); for (File file : mergedFiles) { Path target = Paths.get(targetDir.getAbsolutePath(), file.getName()); try { Files.move(file.toPath(), target, StandardCopyOption.REPLACE_EXISTING); successFiles.add(target.toString()); String uid = file.getName().split("_")[0]; String url = dateDir + roomId + "/" + file.getName(); iBackStrategy.videoMergeOverBack(roomId, uid, url); } catch (IOException e) { log.error("【文件移动】失败: {} → {}", file, target, e); iBackStrategy.videoRoomError(roomId, "文件移动失败:"+file.getPath()); } } if (!successFiles.isEmpty()) { } else { iBackStrategy.videoRoomError(roomId, "无有效文件移动成功"); } } /** * 错误处理统一入口 */ private static void handleMergeError(Exception e, String roomId, IBackStrategy iBackStrategy) { log.error("【合并异常】房间{}: {} {}", roomId, e.getClass().getSimpleName(), e.getMessage()); iBackStrategy.videoRoomError(roomId, $.isBlank(e.getMessage()) ? "视频合成异常" : e.getMessage()); } /** * 清理临时文件(保留最终合成文件) */ private static void cleanTempFiles(String roomPath) { File dir = new File(roomPath); for (File file : Optional.ofNullable(dir.listFiles()).orElse(new File[0])) { String name = file.getName(); // 删除原始音视频文件(保留合并结果) if (name.endsWith(".aac") || (name.endsWith(".mp4") && !name.contains("_av_"))) { if (!file.delete()) { log.warn("【清理失败】临时文件: {}", file); } } } } // ===== 辅助工具方法 ===== private static boolean isMediaFile(String name) { return name.endsWith(".mp4") || name.endsWith(".aac"); } private static String[] parseFileName(String name) { // 1001_20251027080629555.aac → [1001, audio] String uid = name.substring(0, 4); // 假设UID固定4位 String type = name.contains(".mp4") ? "video" : "audio"; return new String[]{uid, type}; } private static long extractTimestamp(String name) { // 1001_20251027080629555.aac → 20251027080629555 String[] parts = name.split("_"); if (parts.length < 2) return 0L; String tsStr = parts[1].split("\\.")[0]; try { return Long.parseLong(tsStr); } catch (NumberFormatException e) { log.warn("时间戳解析失败: {}", name, e); return 0L; } } private static File createOutputFile(String uid, File videoFile, String roomPath) { // 1001_20251027080628985.mp4 → 1001_av_20251027080628985.mp4 long ts = extractTimestamp(videoFile.getName()); return new File(roomPath, String.format("%s_av_%d.mp4", uid, ts)); } private static boolean checkFfmpegAvailable() { try { Process p = Runtime.getRuntime().exec("ffmpeg -version"); return p.waitFor(2, TimeUnit.SECONDS) && p.exitValue() == 0; } catch (Exception e) { return false; } }
} 优化以上代码输出完整代码