实现海康/宇视NVR硬盘录像机/IPC摄像机监控历史回放录像rstp流视频的倍数下载

该工具类实现基于多线程的RTSP回放视频下载,支持倍速播放(1/2/4/8/16/32倍),通过时间切片并行下载,自动合并分片文件。兼容海康、宇视等主流NVR设备,利用FFmpeg进行音视频处理,支持指定起止时间与线程数,具备断点续传、异常重试及临时缓存清理机制,确保高效稳定下载高质量回放视频。

java 复制代码
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;

import java.io.*;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.*;

import static cn.iocoder.yudao.module.wvp.utils.RtspSignalClient.*;


@Slf4j
public class RtspScaleReplayDownloader {

    private static final boolean NEED_AUDIO = true;
    public static final boolean DEBUG = false;

    private static final String FFMPEG_PATH = "ffmpeg";
    private static final String OUTPUT_DIR = "./rtsp_download";
    private static final long FFMPEG_WAIT_FOR_TIME = 1;
    private static final int DELETE_FILE_TRY_TIME = 3;
    private static final long DELETE_FILE_RETRY_DELAY = 200;
    private static final DateTimeFormatter INPUT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    private static final DateTimeFormatter RTSP_TIME_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'");

    /**
     * 支持的 Scale 倍速(2 的幂次方)
     *
     * <p>RTSP 协议本身允许任意浮点数,但主流 NVR 只支持 2 的幂次方。</p>
     * <ul>
     *   <li><b>宇视</b>:支持 1/2/4/8/16/32(实测 32 报 500,实际上限 16)</li>
     *   <li><b>海康</b>:仅支持 1/2/4(实测可支持16)</li>
     * </ul>
     */
    private static final double[] SUPPORTED_SCALES = {1.0, 2.0, 4.0, 8.0, 16.0, 32.0};

    static {
        File file = new File(OUTPUT_DIR);
        showInfoLog("开始清理生成回放视频缓存文件夹: %s", file.getAbsolutePath());
        if (file.exists()) {
            delFolder(file.getAbsolutePath());
        }
    }

    /**
     * 主码流非常清晰,但是实测海康白天的视频大小比子码流大7倍左右
     */
    public static File downloadReplayByMultiThreadWithScale(RtspSignalClient.RtspDeviceConfig rtspDeviceConfig, String rtspUri, String startTimeStr, String endTimeStr, int threadCount, double pushStreamScale, String mp4FileName) throws Exception {
        //校验
        validate(rtspDeviceConfig, threadCount, pushStreamScale);

        long start = System.currentTimeMillis();
        LocalDateTime startTime = LocalDateTime.parse(startTimeStr, INPUT_FORMAT);
        LocalDateTime endTime = LocalDateTime.parse(endTimeStr, INPUT_FORMAT);

        //【海康的每个信令调用都要带上starttime、endtime (且是北京时间时区) 否则报超时、如果时间范围内没有数据,也会报超时】
        if (VideoBrandTypeEnum.HIKVISION.equals(rtspDeviceConfig.getBrand())) {
            rtspUri += "?starttime=" + startTime.format(RTSP_TIME_FORMAT)
                    + "&endtime=" + endTime.format(RTSP_TIME_FORMAT);
        }

        // 动态计算总时长(秒,保留毫秒精度用于后续裁剪)
        double totalSeconds = Duration.between(startTime, endTime).toMillis() / 1000.0;

        //完全可以作为处理文件的临时文件夹,(这里需要建个子文件夹。考虑同时多个下载任务)
        String timestamp = LocalDateTime.now()
                .format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss_"));

        String tempDir = DEBUG ? OUTPUT_DIR : OUTPUT_DIR + File.separator + timestamp + UUID.randomUUID().toString().toLowerCase();

        File dir = new File(tempDir);
        List<Future<File>> futures = new ArrayList<>();
        // 2. 多线程下载
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        CompletionService<File> completionService = new ExecutorCompletionService<>(executor);
        try {
            if (!dir.exists()) {
                dir.mkdirs();
            }
            // 1. 毫秒级均匀切片
            List<TimeSlice> slices = splitTime(startTime, endTime, threadCount);
            showInfoLog("倍数下载配置:线程数:%s,推流倍数:%s", threadCount, pushStreamScale);
            showInfoLog("===== 时间切片方案(毫秒级均匀)=====");
            for (int i = 0; i < slices.size(); i++) {
                TimeSlice s = slices.get(i);
                showInfoLog("组%d: 北京 %s ~ %s | UTC Range: %s-%s | 目标时长 %.3f 秒",
                        i + 1, s.start, s.end, toUtc(s.start), toUtc(s.end), s.targetSeconds);
            }
            showInfoLog("总时长:%.3f 秒\n", totalSeconds);

            for (int i = 0; i < slices.size(); i++) {
                final int index = i + 1;
                final TimeSlice slice = slices.get(i);
                File outputFile = new File(dir, "part_" + index + ".mp4");

                String finalRtspUri = rtspUri;
                Future<File> future = completionService.submit(() -> {
                    downloadAndTrimSlice(rtspDeviceConfig, finalRtspUri, slice, outputFile, index, pushStreamScale);
                    return outputFile;
                });
                futures.add(future);
            }

            // 3. 等待所有分片完成
            List<File> parts = new ArrayList<>();
            for (int i = 0; i < slices.size(); i++) {
                // 谁先完成谁先返回
                Future<File> f = completionService.take();
                // 任意一个失败会立刻抛异常
                parts.add(f.get());
            }

            // 4. 合并(用动态总时长兜底截断)
            // 按文件名里的 index 排序,确保合并顺序正确
            // 文件名格式:part_1.mp4, part_2.mp4, ...
            parts.sort(Comparator.comparingInt(f -> {
                String name = f.getName();               // part_1.mp4
                String num = name.replaceAll("\\D+", ""); // 1
                return Integer.parseInt(num);
            }));
            File finalFile = new File(dir, mp4FileName);
            mergeMp4(parts, finalFile, totalSeconds);

            String cleanPath = finalFile.toPath().normalize().toAbsolutePath().toString();
            showInfoLog("✅ 完成:%s,%.3f秒耗时:%s",
                    cleanPath, totalSeconds, getCostTimeString(start));

            //清理临时文件,保留最终结果文件
            cleanTempFiles(dir);

            return finalFile;
        } catch (Exception e) {
            showErrorLog("下载出现异常:%s", e.getMessage());
            for (Future<File> f : futures) {
                if (!f.isDone()) {
                    f.cancel(true);
                } 
            }
            //全部删除
            delFolder(dir.getAbsolutePath());

            //调用层处理业务逻辑
            throw e;
        } finally {
            // 先尝试正常关闭,等 1 秒,如果还有任务就跑 shutdownNow
            executor.shutdown();
            try {
                if (!executor.awaitTermination(1, TimeUnit.SECONDS)) {
                    executor.shutdownNow();
                }
            } catch (InterruptedException e) {
                executor.shutdownNow();
                Thread.currentThread().interrupt();
            }
        }

    }

    public static boolean isFfmpegInstalled(String toolName) {
        ShellExecUtil.CommandResult ret = ShellExecUtil.isWindows()?
                ShellExecUtil.executeSafe(3, "cmd", "/c", "where "+toolName):
                ShellExecUtil.executeSafe(3, "sh", "-c", "command -v "+toolName);

        // 退出码 + 输出 同时判断
        if (ret.isSuccess() && StringUtils.isNotBlank(ret.getOutput())) {
            log.info( "{} 已就绪,路径: {}", toolName , ret.getOutput().trim());
            return true;
        }
        return false;
    }

    /**
     * 毫秒级均匀切片
     * 把 [start, end] 分成 count 份,每份时长 = totalMillis / count 毫秒
     * 最后一份兜底覆盖所有剩余毫秒,确保首尾相接无遗漏
     */
    private static List<TimeSlice> splitTime(LocalDateTime start, LocalDateTime end, int count) {
        List<TimeSlice> list = new ArrayList<>();
        long totalMillis = Duration.between(start, end).toMillis();
        long perSliceMillis = totalMillis / count;

        for (int i = 0; i < count; i++) {
            long startMillis = perSliceMillis * i;
            // 最后一份把余数都兜住
            long endMillis = (i == count - 1) ? totalMillis : perSliceMillis * (i + 1);

            LocalDateTime sliceStart = start.plusNanos(startMillis * 1_000_000L);
            LocalDateTime sliceEnd = start.plusNanos(endMillis * 1_000_000L);

            double targetSeconds = (endMillis - startMillis) / 1000.0;

            list.add(new TimeSlice(sliceStart, sliceEnd, targetSeconds));
        }
        return list;
    }

    /**
     * 下载单个切片,并在下载完成后裁剪到精确时长
     */
    private static void downloadAndTrimSlice(RtspSignalClient.RtspDeviceConfig rtspDeviceConfig, String rtspUri, TimeSlice slice, File outputFile, int index, double pushStreamScale) throws Exception {
        //新增:开启音频时走音频路径,走完直接返回;关闭时走纯视频路径
        if (NEED_AUDIO) {
            downloadAndTrimSliceWithAudio(rtspDeviceConfig, rtspUri, slice, outputFile, index, pushStreamScale);
            return;
        }
        downloadPureVideo(rtspDeviceConfig, rtspUri, slice, outputFile, index, pushStreamScale);
    }

    /**
     * 纯视频下载(原 downloadAndTrimSlice 的方法体,一字不动搬到这里)
     * 单独抽出来是为了让音频路径回退时能直接调用,避免无限递归
     */
    private static void downloadPureVideo(RtspSignalClient.RtspDeviceConfig rtspDeviceConfig, String rtspUri, TimeSlice slice, File outputFile, int index, double pushStreamScale) throws Exception {
        String rangeStart = null;
        String rangeEnd = null;
        if (VideoBrandTypeEnum.UNIVIEW.equals(rtspDeviceConfig.getBrand())) {
            rangeStart = toUtc(slice.start);
            rangeEnd = toUtc(slice.end);

            showInfoLog("[线程%d] 宇视切片: %s ~ %s (目标 %.3f 秒)",
                    index, rangeStart, rangeEnd, slice.targetSeconds);
        }

        // 【重要】关键改动:每个切片用自己时间范围的 URL
        if (VideoBrandTypeEnum.HIKVISION.equals(rtspDeviceConfig.getBrand())) {
            // 用切片自己的时间覆盖 URL 的 starttime/endtime
            rangeStart = slice.start.format(RTSP_TIME_FORMAT);
            rangeEnd = slice.end.format(RTSP_TIME_FORMAT);
            rtspUri = rtspUri.replaceAll("starttime=[^&]*", "starttime=" + rangeStart)
                    .replaceAll("endtime=[^&]*", "endtime=" + rangeEnd);

            showInfoLog("[线程%d] 海康切片(纯视频): %s ~ %s (目标 %.3f 秒)",
                    index, rangeStart, rangeEnd, slice.targetSeconds);
        }

        RtspSignalClient client = new RtspSignalClient(rtspDeviceConfig.getHost(), rtspDeviceConfig.getPort(), rtspDeviceConfig.getUsername(), rtspDeviceConfig.getPassword(), rtspUri);

        //海康专属优化 ------ 客户端时长预算(+3 秒冗余),到点主动掐断
        // 宇视场景:IS_HIKVISION=false,不调用 setMaxReceiveSeconds,行为与之前一字不差
        if (VideoBrandTypeEnum.HIKVISION.equals(rtspDeviceConfig.getBrand())) {
            double maxSeconds = slice.targetSeconds + 3.0;
            client.setMaxReceiveSeconds(maxSeconds);
            showInfoLog("[线程%d] 海康模式(纯视频):设置时长预算 %.3f 秒", index, maxSeconds);
        }

        client.connectAndProbe();

        String codec = client.getVideoCodec();
        String codecParam = "H265".equals(codec) ? "hevc" : "h264";
        showInfoLog("[线程" + index + "] 编码格式(纯视频): " + codec);

        // ============ 步骤 1:下载原始数据到临时文件 ============
        File rawFile = new File(outputFile.getParent(), "raw_" + index + ".mp4");
        Process ffmpeg = null;
        try {
            ProcessBuilder pb = new ProcessBuilder(
                    FFMPEG_PATH,
                    "-fflags", "+genpts+discardcorrupt",
                    "-analyzeduration", "10000000",
                    "-probesize", "10000000",
                    "-f", codecParam,
                    "-i", "pipe:0",
                    "-c", "copy",
                    "-movflags", "+faststart",
                    "-y",
                    rawFile.getAbsolutePath()
            );
            pb.redirectErrorStream(true);
            ffmpeg = pb.start();

            // 提取公共方法:启动日志读取线程
            startFfmpegLogThread(ffmpeg, "FFmpeg-" + index);

            try (OutputStream ffmpegStdin = ffmpeg.getOutputStream()) {
                client.play(rangeStart, rangeEnd, ffmpegStdin, pushStreamScale);
            } catch (Exception e) {
                showErrorLog("[线程" + index + "] 拉流异常: " + e.getMessage());
            }

            // 提取公共方法:等待进程结束(带超时 + 强制终止)
            waitForProcess(ffmpeg, "FFmpeg 下载");

            long rawSize = rawFile.length() / 1024;
            showInfoLog("[线程%d] downloadPureVideo下载完成: raw_%d.mp4 (%d KB, 收到 %d 包)",
                    index, index, rawSize, client.getReceivedPackets());

            // ============ 步骤 2:裁剪到精确时长 ============
            // 目标时长多给 0.05 秒冗余,避免因 GOP 边界导致末尾丢帧
            double trimSeconds = slice.targetSeconds + 0.05;
            trimSlice(rawFile, outputFile, trimSeconds, index);

            showInfoLog("[线程%d] downloadPureVideo完成: %s (%d KB)",
                    index, outputFile.getName(), outputFile.length() / 1024);
        } catch (Exception e) {
            showInfoLog("[线程%d] downloadPureVideo异常: %s (%s)",
                    index, outputFile.getName(), e.toString());
            //关键 让全局异常生效统一关闭,重新抛出异常,让 Future 标记为失败
            throw e;
        } finally {
            if (ffmpeg != null && ffmpeg.isAlive()) {
                closeFfmpeg(ffmpeg);
            }
            // 删除原始文件(重试 3 次,每次间隔 200ms,Windows 下句柄释放有延迟)
            if (!deleteFileWithRetry(rawFile, DELETE_FILE_TRY_TIME, DELETE_FILE_RETRY_DELAY)) {
                showErrorLog("[线程" + index + "] 删除临时文件失败: " + rawFile);
            }
        }

    }

    /**
     * 用 FFmpeg 裁剪 MP4 到指定时长(不重编码)
     * 注意:-c copy + -t 只能截断到最近的关键帧,会有 ~0.1 秒误差
     */
    private static void trimSlice(File input, File output, double targetSeconds, int index) throws Exception {
        ProcessBuilder pb = new ProcessBuilder(
                FFMPEG_PATH,
                "-i", input.getAbsolutePath(),
                "-t", String.format("%.3f", targetSeconds),
                "-c", "copy",
                "-avoid_negative_ts", "make_zero",
                "-movflags", "+faststart",
                "-y",
                output.getAbsolutePath()
        );
        pb.redirectErrorStream(true);
        Process p = pb.start();

        startFfmpegLogThread(p, "FFmpeg-trim-" + index);

        // 提取公共方法:等待进程结束(带超时 + 强制终止)
        waitForProcess(p, "[trim-" + index + "] 裁剪");
    }

    /**
     * 北京时间 -> RTSP Range 的 UTC 格式(秒级)
     * 注意:RTSP clock 格式只支持到秒,毫秒会被向下取整
     */
    private static String toUtc(LocalDateTime beijingTime) {
        LocalDateTime utc = beijingTime
                .atZone(ZoneId.of("Asia/Shanghai"))
                .withZoneSameInstant(ZoneOffset.UTC)
                .toLocalDateTime();
        // 向下取整到秒(丢弃毫秒/纳秒部分)
        utc = utc.withNano(0);
        return utc.format(RTSP_TIME_FORMAT);
    }

    /**
     * 合并所有分片为最终的 MP4
     *
     * @param parts        分片文件列表
     * @param output       最终输出文件
     * @param totalSeconds 动态总时长(用于兜底截断)
     */
    private static void mergeMp4(List<File> parts, File output, double totalSeconds) throws Exception {
        File listFile = new File(OUTPUT_DIR, "concat_list.txt");
        try (PrintWriter pw = new PrintWriter(listFile)) {
            for (File f : parts) {
                pw.println("file '" + f.getAbsolutePath().replace("\\", "/") + "'");
            }
        }

        ProcessBuilder pb = new ProcessBuilder(
                FFMPEG_PATH,
                "-fflags", "+genpts",
                "-f", "concat",
                "-safe", "0",
                "-i", listFile.getAbsolutePath(),
                "-t", String.format("%.3f", totalSeconds),   //动态总时长兜底
                "-c", "copy",
                "-y",
                output.getAbsolutePath()
        );
        pb.redirectErrorStream(true);
        Process p = pb.start();

        try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
            String line;
            while ((line = br.readLine()) != null) {
                if (line.contains("Output") || line.contains("Error") || line.contains("frame=")) {
                    showInfoLog("[合并] " + line);
                }
            }
        }

        // 提取公共方法:等待进程结束(带超时 + 强制终止)
        waitForProcess(p, "MP4 合并");
    }

    /**
     * 时间切片对象(含目标时长,用于后续裁剪)
     */
    static class TimeSlice {
        LocalDateTime start;
        LocalDateTime end;
        double targetSeconds;

        TimeSlice(LocalDateTime s, LocalDateTime e, double target) {
            this.start = s;
            this.end = e;
            this.targetSeconds = target;
        }
    }

    //新增:带音频下载路径(NEED_AUDIO=true 时才会进入)
    // 显式回退:检测到 NVR 无音频时,直接调用原 downloadAndTrimSlice 走纯视频路径
    //新增:带音频下载路径(NEED_AUDIO=true 时才会进入)
    // 显式回退:检测到 NVR 无音频时,直接调用 downloadPureVideo 走纯视频路径
    private static void downloadAndTrimSliceWithAudio(RtspSignalClient.RtspDeviceConfig rtspDeviceConfig, String rtspUri, TimeSlice slice, File outputFile, int index, double pushStreamScale) throws Exception {
        String rangeStart = toUtc(slice.start);
        String rangeEnd = toUtc(slice.end);

        // 关键修复:海康场景,用切片自己的时间覆盖 URL 的 starttime/endtime
        // 与 downloadPureVideo 保持一致,否则 4 个分片共用全局 URL,海康会推到全局 endtime
        if (VideoBrandTypeEnum.HIKVISION.equals(rtspDeviceConfig.getBrand())) {
            rangeStart = slice.start.format(RTSP_TIME_FORMAT);
            rangeEnd = slice.end.format(RTSP_TIME_FORMAT);
            rtspUri = rtspUri.replaceAll("starttime=[^&]*", "starttime=" + rangeStart)
                    .replaceAll("endtime=[^&]*", "endtime=" + rangeEnd);
            showInfoLog("[线程%d] 海康切片(音频): %s ~ %s (目标 %.3f 秒)",
                    index, rangeStart, rangeEnd, slice.targetSeconds);
        }

        showInfoLog("[线程%d] 开始(音频): %s ~ %s (目标 %.3f 秒)",
                index, rangeStart, rangeEnd, slice.targetSeconds);

        RtspSignalClient client = new RtspSignalClient(rtspDeviceConfig.getHost(), rtspDeviceConfig.getPort(), rtspDeviceConfig.getUsername(), rtspDeviceConfig.getPassword(), rtspUri, true);

        //海康专属优化 ------ 音频路径也用时长预算
        // 宇视场景:IS_HIKVISION=false,不调用 setMaxReceiveSeconds,行为与之前一字不差
        if (VideoBrandTypeEnum.HIKVISION.equals(rtspDeviceConfig.getBrand())) {
            double maxSeconds = slice.targetSeconds + 3.0;
            client.setMaxReceiveSeconds(maxSeconds);
            showInfoLog("[线程%d] 海康模式(音频):设置时长预算 %.3f 秒", index, maxSeconds);
        }

        client.connectAndProbe();

        //显式回退:NVR 不推音频时,直接走纯视频路径(绕开 NEED_AUDIO 判断,避免递归)
        if (!client.isAudioAvailable()) {
            showInfoLog("[线程%d] 无音频,回退到纯视频路径", index);
            client.stop();
            downloadPureVideo(rtspDeviceConfig, rtspUri, slice, outputFile, index, pushStreamScale);
            return;
        }

        String codec = client.getVideoCodec();
        String codecParam = "H265".equals(codec) ? "hevc" : "h264";
        showInfoLog("[线程" + index + "] 编码格式: " + codec + ",音频可用: " + client.isAudioAvailable());

        File rawFile = new File(outputFile.getParent(), "raw_" + index + ".mp4");
        File videoTmp = new File(outputFile.getParent(), "video_" + index + ".tmp");
        File audioTmp = new File(outputFile.getParent(), "audio_" + index + ".pcm");

        // 阶段 1:拉流,视频/音频分别落临时文件
        try (OutputStream videoOut = new FileOutputStream(videoTmp);
             OutputStream audioOut = new FileOutputStream(audioTmp)) {
            client.play(rangeStart, rangeEnd, videoOut, audioOut, pushStreamScale);
        }
        showInfoLog("[线程%d] 拉流完成,开始封装 MP4", index);

        try {

            // 阶段 2:FFmpeg 合并视频+音频
            String audioFormat = "PCMU".equals(client.getAudioCodec()) ? "mulaw" : "alaw";
            ProcessBuilder pb = new ProcessBuilder(
                    FFMPEG_PATH,
                    "-fflags", "+genpts+discardcorrupt",
                    "-analyzeduration", "10000000",
                    "-probesize", "10000000",
                    "-f", codecParam,
                    "-i", videoTmp.getAbsolutePath(),
                    "-f", audioFormat,
                    "-ar", "8000",
                    "-ac", "1",
                    "-t", String.format("%.3f", slice.targetSeconds + 3.0),
                    "-i", audioTmp.getAbsolutePath(),
                    "-map", "0:v",
                    "-map", "1:a",
                    "-c:v", "copy",
                    "-c:a", "aac",
                    // 关键修复:音频 PTS 整体 +0.128,对齐视频的 0.128
                    // asetpts 是音频版的 setpts,TB 是时间基
                    "-af", "asetpts=PTS+0.128/TB",
                    "-t", String.format("%.3f", slice.targetSeconds + 3.0),
                    "-movflags", "+faststart",
                    "-y",
                    rawFile.getAbsolutePath()
            );
            pb.redirectErrorStream(true);
            Process ffmpeg = pb.start();

            // 提取公共方法:启动日志读取线程
            startFfmpegLogThread(ffmpeg, "FFmpeg-audio-" + index);

            // 提取公共方法:等待进程结束(带超时 + 强制终止)
            waitForProcess(ffmpeg, "FFmpeg 音频封装");

            // 阶段 3:裁剪到精确时长
            double trimSeconds = slice.targetSeconds + 0.05;
            trimSlice(rawFile, outputFile, trimSeconds, index);

            showInfoLog("[线程%d] 完成: %s (%d KB, 音频包 %d)",
                    index, outputFile.getName(), outputFile.length() / 1024, client.getAudioReceivedPackets());
        } catch (Exception e) {
            showInfoLog("[线程%d] 异常: %s (%d KB, 音频包 %d)",
                    index, outputFile.getName(), outputFile.length() / 1024, client.getAudioReceivedPackets());
            throw e;
        } finally {
            // 删除原始文件
            if (!deleteFileWithRetry(videoTmp, DELETE_FILE_TRY_TIME, DELETE_FILE_RETRY_DELAY)) {
                showErrorLog("[线程" + index + "] 删除临时文件失败: " + videoTmp);
            }
            if (!deleteFileWithRetry(audioTmp, DELETE_FILE_TRY_TIME, DELETE_FILE_RETRY_DELAY)) {
                showErrorLog("[线程" + index + "] 删除临时文件失败: " + audioTmp);
            }
            if (!deleteFileWithRetry(rawFile, DELETE_FILE_TRY_TIME, DELETE_FILE_RETRY_DELAY)) {
                showErrorLog("[线程" + index + "] 删除临时文件失败: " + rawFile);
            }
        }
    }

    /**
     * 删除指定文件夹下的所有文件,不包括当前自身第一级的文件夹
     *
     * @param path 路径
     * @return 完成标识
     */
    private static boolean delAllFile(String path) {
        if (path == null || path.isBlank()) {
            return true;
        }
        boolean flag = false;
        File file = new File(path);

        if (!file.exists()) {
            return flag;
        }

        if (!file.isDirectory()) {
            return flag;
        }
        // 有多级文件夹,但是文件夹都没有文件的情况,先执行下删除,这个只会删除掉最后一级的文件夹
        // 如果文件别占用似乎不会提示,可以通过  deleteFileByWalkFileTree(folderPath); 临时检测一下
        file.delete();

        String[] tempList = file.list();
        File temp = null;
        if (tempList != null) {
            for (String p : tempList) {
                if (path.endsWith(File.separator)) {
                    temp = new File(path + p);
                } else {
                    temp = new File(path + File.separator + p);
                }
                if (temp.isFile()) {
                    temp.delete();
                }
                if (temp.isDirectory()) {
                    //先删除文件夹里面的文件
                    delAllFile(path + "/" + p);
                    //再删除空文件夹
                    delFolder(path + "/" + p);
                    flag = true;
                }
            }
        }
        return flag;
    }

    /**
     * 清理临时文件,保留最终结果文件
     * 清理中间产生的临时文件(raw_*.mp4, trimmed_*.mp4, concat_list.txt)
     */
    private static void cleanTempFiles(File dir) {
        File[] files = dir.listFiles();
        if (files == null) return;
        for (File f : files) {
            String name = f.getName();
            if (name.startsWith("raw_") || name.startsWith("part_")
                    || name.equals("concat_list.txt")) {
                if (f.delete()) {
                    RtspSignalClient.showDebugLog("[清理文件] " + name);
                } else {
                    showErrorLog("[清理文件失败] " + name);
                }
            }
        }
    }

    /**
     * 删除文件夹 ,包括当前自身第一级的文件假
     *
     * @param folderPath 文件夹
     */
    private static void delFolder(String folderPath) {
        // deleteFileByWalkFileTree(folderPath);
        try {
            if (folderPath == null || folderPath.isBlank()) {
                return;
            }
            // 删除完里面所有内容
            delAllFile(folderPath);
            File myFilePath = new File(folderPath);
            // 删除空文件夹
            myFilePath.delete();
        } catch (Exception e) {
            showErrorLog("delFolder 删除文件夹出现异常,路径:{}, 异常:%s", folderPath, e);
        }
    }


    /**
     * 删除文件(带重试)
     *
     * <p>Windows 下 FFmpeg 进程退出后,文件句柄释放有延迟,需要重试几次。</p>
     *
     * @param file       要删除的文件
     * @param maxRetry   最大重试次数
     * @param retryDelay 重试间隔(毫秒)
     * @return 是否删除成功
     */
    private static boolean deleteFileWithRetry(File file, int maxRetry, long retryDelay) {
        if (file == null || !file.exists()) {
            return true;
        }
        for (int i = 0; i < maxRetry; i++) {
            if (file.delete()) {
                return true;
            }
            try {
                Thread.sleep(retryDelay);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return false;
            }
        }
        return false;
    }

    /**
     * 关闭Ffmpeg进程
     * @param ffmpeg
     */
    private static void closeFfmpeg(Process ffmpeg) {
        if (ffmpeg == null || !ffmpeg.isAlive()) {
            return;
        }
        ffmpeg.destroyForcibly();
        // 等它真正退出,避免文件句柄泄漏
        long deadline = System.currentTimeMillis() + 5000;
        while (ffmpeg.isAlive() && System.currentTimeMillis() < deadline) {
            try {
                ffmpeg.waitFor(100, TimeUnit.MILLISECONDS);
            } catch (InterruptedException e) {
                // 不清除中断标志,继续等
                Thread.currentThread().interrupt();
            }
        }
    }

    /**
     * 等待进程结束(带超时 + 强制终止 + 退出码检查)
     * @param process 要等待的进程
     * @param timeout 超时时间
     * @param unit    时间单位
     * @param desc    描述(用于异常信息,如 "FFmpeg 下载"、"[trim-1] 裁剪")
     */
    private static void waitForProcess(Process process, long timeout, TimeUnit unit, String desc) {
        // 给 waitFor 加超时,避免 FFmpeg 卡住时 Java 线程一直等
        boolean exited;
        try {
            exited = process.waitFor(timeout, unit);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            closeFfmpeg(process);
            throw ServiceExceptionUtil.businessException(desc + " 等待被中断");
        }
        if (!exited) {
            closeFfmpeg(process);
            throw ServiceExceptionUtil.businessException(desc + " 超时(" + timeout + " " + unit + "),已强制终止");
        }
        if (process.exitValue() != 0) {
            throw ServiceExceptionUtil.businessException(desc + " 退出码: " + process.exitValue());
        }
    }

    private static void waitForProcess(Process process, String desc) {
        waitForProcess(process, FFMPEG_WAIT_FOR_TIME, TimeUnit.MINUTES, desc);
    }

    /**
     * 启动 FFmpeg 日志读取线程(守护线程)
     *
     * <p>本次重构新增:原代码在 2 处(downloadPureVideo、downloadAndTrimSliceWithAudio)
     * 存在几乎相同的日志读取线程启动逻辑,现统一提取到此方法。</p>
     *
     * @param ffmpeg FFmpeg 进程
     * @param tag    日志标签前缀(如 "FFmpeg-1"、"FFmpeg-audio-1")
     */
    private static void startFfmpegLogThread(Process ffmpeg, String tag) {
        Thread logThread = new Thread(() -> {
            try (BufferedReader br = new BufferedReader(new InputStreamReader(ffmpeg.getInputStream()))) {
                String line;
                while ((line = br.readLine()) != null) {
                    // 支持外部中断:多线程场景下,一个分片失败,其他分片应立即停止
                    if (Thread.currentThread().isInterrupted()) {
                        showInfoLog("[" + tag + "] 收到中断信号,停止读取日志");
                        break;
                    }
                    if (line.contains("frame=") || line.contains("Error") || line.contains("error")) {
                        //RtspSignalClient.showDebugLog("[" + tag + "] " + line);
                    }
                }
            } catch (Exception ignored) {
            }
        });
        logThread.setDaemon(true);
        logThread.start();
    }

    /**
     * 校验 pushStreamScale,不合法抛异常
     *
     * @param rtspDeviceConfig
     * @param pushStreamScale
     * @throws IllegalArgumentException 不合法时抛出
     */
    private static void validate(RtspDeviceConfig rtspDeviceConfig, int threadCount, double pushStreamScale) {

        //判断ffmpeg :
        boolean ffmpegInstalled = isFfmpegInstalled("ffmpeg");
        if (!ffmpegInstalled) {
            throw ServiceExceptionUtil.businessException("未安装ffmpeg");
        }

        if (threadCount > 1) {
            showInfoLog("不推荐多线程分片下载,合并相邻视频会有跳跃");
        }
        if (!isValidScale(pushStreamScale)) {
            throw ServiceExceptionUtil.businessException(
                    "非法的 pushStreamScale: " + pushStreamScale + ",仅支持: 1/2/4/8/16/32");
        }
        if (VideoBrandTypeEnum.UNIVIEW.equals(rtspDeviceConfig.getBrand())) {
            if (pushStreamScale > 32) {
                throw ServiceExceptionUtil.businessException("宇视的推流倍数不推荐超过32倍(最多实际支持16倍)");
            }
        }
        if (VideoBrandTypeEnum.HIKVISION.equals(rtspDeviceConfig.getBrand())) {
            if (pushStreamScale > 16) {
                throw ServiceExceptionUtil.businessException("海康的推流倍数不推荐超过16倍");
            }
        }

    }

    /**
     * 校验 pushStreamScale 是否合法
     *
     * @param scale 倍速值
     * @return true=合法
     */
    private static boolean isValidScale(double scale) {
        for (double s : SUPPORTED_SCALES) {
            if (Math.abs(s - scale) < 0.001) {   // 浮点数比较用 epsilon
                return true;
            }
        }
        return false;
    }

    /**
     * 计算任务消费的时间
     */
    public static String getCostTimeString(long startTime) {
        long endTime = System.currentTimeMillis();
        long ms = endTime - startTime;

        long hour = ms / 1000 / 60 / 60;
        long min = ms / 1000 / 60 % 60;
        long sec = ms / 1000 % 60;
        long mi = ms % 1000;
        return hour + "时 " + min + "分 " + sec + "秒 " + mi + "毫秒";
    }

}

原文地址: https://code.jiangjiesheng.cn/article/382

胜行天下网 jiangjiesheng.cn 版权所有

相关推荐
林墨聊AIGC2 小时前
AI分镜模板:一键生成高效短剧视觉流程
人工智能·ai作画·aigc·音视频·ai写作
程序员JerrySUN2 小时前
Jetson Edge AI 实战05:Secure Boot 到底在保护什么?PKC、SBK、Fuse 与可信启动链一次讲清【视频讲解】
arm开发·人工智能·安全·计算机视觉·目标跟踪·音视频
voipmaker2 小时前
选煤厂智能化:UWB 人员定位系统 WebAPI 对接融合通信平台实战
物联网·实时互动·json·音视频·实时音视频·web·视频
木头左4 小时前
音频转文字与说话人识别部署与使用指南
音视频
DogDaoDao4 小时前
当视频理解撞上大语言模型:一篇 37 页综述的全景拆解
人工智能·语言模型·音视频·知识图谱·视频
阿明副业观察5 小时前
AI短剧分镜秘籍:从零到电影级视觉冲击
大数据·人工智能·aigc·音视频·ai写作
语音之家5 小时前
NeurIPS 2026|AnyEdit:免人声分离,嘈杂音频也能局部编辑
人工智能·音视频·消融实验·声源分离
小白学大数据5 小时前
逆向分析短视频平台 a_bogus 参数:从 JavaScript 混淆到 Python 复现
javascript·python·音视频
常州晟凯电子科技6 小时前
8路CVBS/AHD模拟视频编码器评测
音视频·视频编解码