原创

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

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

import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;

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 + "毫秒";
}

}

正文到此结束
本文目录