实现海康/宇视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 + "毫秒";
}
}
正文到此结束
- 本文标签: Java Spring Boot RSTP回放流倍数下载
- 本文链接: https://code.jiangjiesheng.cn/article/382
- 版权声明: 本文由小江同学原创发布,转载请先联系本站长,谢谢。