SpringBoot2.x 整合FFmpeg 完整实操教程
-
- 一、前言
- 二、项目技术栈与环境说明
- 三、Maven依赖
- 四、集成步骤
-
- [4.1 路径配置初始化](#4.1 路径配置初始化)
- [4.2 核心工具类开发](#4.2 核心工具类开发)
- [4.3 容器生命周期管控配置](#4.3 容器生命周期管控配置)
- [4.4 本地双模式测试验证](#4.4 本地双模式测试验证)
- 五、工具类源码
- 六、双模式本地测试验证
-
- [6.1 VOD点播模式(开发调试首选)](#6.1 VOD点播模式(开发调试首选))
- [6.2 LIVE直播模式(生产场景验证)](#6.2 LIVE直播模式(生产场景验证))
- 七、集成所遇问题
- 八、本篇小结
一、前言
在第一篇基础环境搭建好后,本篇的目标是实现SpringBoot与FFmpeg、RTMP流媒体服务的业务集成。目前网上很多都的东一块西一块的,代码比较零散,导致开发者复制代码后就报错了,无法直接复制使用测试。
本篇基于SpringBoot2.x + JDK8 + Windows本地开发环境,提供了一整套集成流程,包含Maven依赖导入、项目配置初始化、完整工具类的开发、源码关键位置加注释、本地双模式测试、高频报错等的解决方案,全程可直接复刻集成测试,一次性完成落地应用。
二、项目技术栈与环境说明
- 基础框架:SpringBoot 2.7.18(稳定版本,兼容JDK8)
- 运行环境:JDK8、Windows 10
- 视频处理工具:ffmpeg-8.1.2-full_build.7z 完整版(上篇搭建环境)
- 流媒体服务:nginx-rtmp-win32-1.2.1.zip Windows稳定版
- 工具依赖:Hutool 5.8.38(简化文件、线程、字符串冗余代码)
- 日志框架:Slf4j(统一日志输出、异常监控、问题追溯)
三、Maven依赖
xml
<!-- SpringBoot核心父依赖 -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<dependencies>
<!-- SpringBoot Web核心依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Hutool万能工具类 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.38</version>
</dependency>
<!-- SpringBoot测试依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
四、集成步骤
4.1 路径配置初始化
统一配置FFmpeg执行文件绝对路径、视频存储根路径,摒弃硬编码混乱写法,生产环境可迁移至yml配置文件统一管理,适配多环境部署,避免路径不存在、文件找不到等报错。
4.2 核心工具类开发
封装生产级RTMP视频录制工具类,整合直播/点播双流录制、进程并发管控、安全参数校验、异常容错处理、临时文件清理、线程资源回收全链路逻辑,静态方法可直接业务调用,对业务代码零侵入。
4.3 容器生命周期管控配置
通过@PostConstruct实现项目启动时配置自动初始化,解决静态方法调用空指针问题;通过@PreDestroy实现服务关闭时线程池优雅回收,彻底规避线程泄漏、资源残留、僵尸进程堆积问题。
4.4 本地双模式测试验证
提供VOD点播、LIVE直播两套测试方案,本地快速验证功能可用性,确保代码逻辑正常、无报错、可生成标准可播放MP4文件,保障集成效果。
java
public static void main(String[] args) {
// // 模式1:LIVE直播地址
// String testRtmp = "rtmp://127.0.0.1:1935/live/test001";
// 模式2:VOD点播地址(模拟海康回放)
String testRtmp = "rtmp://127.0.0.1:1935/vod/test.mp4";
String streamId = "202607251534";
String suffix = "mp4";
String uniqueStreamId = streamId + "_" + FULL_TIME_FMT.format(LocalDateTime.now());
long start = System.currentTimeMillis();
String result = convertRtmpToMp4(testRtmp, uniqueStreamId, suffix);
LOGGER.info("转换耗时: {}秒; 转换结果访问路径: {}", (System.currentTimeMillis() - start) / 1000, result);
}
五、工具类源码
以下源码经过多轮迭代优化,修复全部已知Bug,采用两段式录制架构(RTMP流--->临时FLV--->标准MP4),自带性能限流、安全防护、异常兜底机制,可直接复制集成测试。
java
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.thread.ThreadUtil;
import cn.hutool.core.util.ReUtil;
import cn.hutool.core.util.StrUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* RTMP视频录制工具类
* 适配环境:SpringBoot2.x + JDK8 + Windows
* 业务场景:海康/大华设备RTMP回放点播、RTMP实时直播录制归档
* 核心架构:两段式录制(RTMP流→临时FLV→标准可拖拽MP4)
* 核心能力:并发限流、安全校验、超时容错、进程兜底、文件自动清理
*
* @author Z
* @since 2026
*/
@Component
public class RtmpVideoRecorderUtils {
private static final Logger LOGGER = LoggerFactory.getLogger(RtmpVideoRecorderUtils.class);
// ====================== 安全校验正则(防攻击核心)======================
/** streamId合法正则:仅字母数字下划线,防御目录遍历攻击 */
private static final String STREAM_ID_REGEX = "^[a-zA-Z0-9_]+$";
/** RTMP地址Host提取正则,用于SSRF内网防护校验 */
private static final Pattern RTMP_HOST_PATTERN = Pattern.compile("rtmp://([^/:]+)(:\\d+)?(/.*)?");
// ====================== 业务常量配置 ======================
/** 视频后缀白名单,仅支持标准MP4 */
private static final String ALLOW_SUFFIX = "mp4";
/** FFmpeg可执行文件路径(本地/生产自行适配) */
private static final String FFMPEG_BIN_PATH =
"E:\\disk_software\\IT\\video\\ffmpeg\\ffmpeg-8.1.2-full_build\\bin\\ffmpeg.exe";
/** 视频文件存储根目录 */
private static final String VIDEO_SAVE_BASE_PATH =
"E:\\disk_software\\IT\\video\\ffmpeg\\ffmpeg-8.1.2-full_build\\uploads\\payback\\";
// ====================== 性能限流配置 ======================
/** FFmpeg最大并行进程数(SSD固态硬盘:6~8;机械硬盘:建议4以内) */
private static final int MAX_FFMPEG_PROCESS = 6;
/** RTMP拉流失败最大重试次数 */
private static final int RTMP_RETRY_COUNT = 2;
/** FFmpeg子进程全局最大执行时长(秒),超时强制kill */
private static final long FFMPEG_TIMEOUT_SECONDS = 630L;
/** 获取信号量许可最大等待时长,避免定时任务主线程无限阻塞 */
private static final long SEMAPHORE_WAIT_SECONDS = 20L;
/** 全局最大运行任务数,防止任务堆积 */
private static final int MAX_RUNNING_TASK = 150;
/** 重试基础休眠时间(指数退避重试) */
private static final long RETRY_BASE_SLEEP_MS = 1500;
/** 最大录制时长,规避无限拉流风险 */
private static final String MAX_RECORD_SECOND = "600";
// ====================== 时间格式化工具 ======================
private static final DateTimeFormatter YMD_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
private static final DateTimeFormatter FULL_TIME_FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
// ====================== 并发管控核心对象 ======================
/** 公平信号量:管控FFmpeg并发,保护磁盘IO */
private static final Semaphore FFMPEG_SEMAPHORE = new Semaphore(MAX_FFMPEG_PROCESS, true);
/** 全局任务计数器,拦截任务堆积 */
private static final AtomicInteger RUNNING_TASK_COUNT = new AtomicInteger(0);
// ====================== 静态缓存与线程池 ======================
// 静态全局变量定义
private static String STATIC_FFMPEG_BIN_PATH;
private static String STATIC_VIDEO_BASE_PATH;
private static File STATIC_FFMPEG_EXE_FILE;
/** 日期目录缓存:消除并发创建目录锁竞争,提升大批量任务吞吐 */
private static final ConcurrentHashMap<String, File> DAY_DIR_CACHE =
new ConcurrentHashMap<>(16, 0.75f, 4);
/** 独立流消费线程池:隔离FFmpeg输出流,避免进程缓冲区阻塞卡死 */
private static final ExecutorService F_FMPEG_STREAM_EXECUTOR;
static {
// 初始化守护线程池,JVM退出自动回收
AtomicInteger idx = new AtomicInteger(0);
ThreadFactory daemonFactory = r -> {
Thread t = new Thread(r, "ffmpeg-stream-consume-" + idx.getAndIncrement());
t.setDaemon(true);
return t;
};
F_FMPEG_STREAM_EXECUTOR = Executors.newFixedThreadPool(MAX_FFMPEG_PROCESS * 2, daemonFactory);
}
// ====================== 容器初始化与销毁 ======================
/**
* 项目启动自动初始化配置
* 解决静态方法调用、配置延迟加载问题
*/
private static volatile boolean CONFIG_INITIALIZED = false;
private static final Object CONFIG_LOCK = new Object();
private File ffmpegExeFile;
/**
* Spring容器启动回调,初始化全局配置
* 执行时机:Bean创建完成后自动执行
*/
@PostConstruct
public void initConfig() {
synchronized (CONFIG_LOCK) {
if (CONFIG_INITIALIZED) {
return;
}
// 初始化FFmpeg文件对象,校验文件合法性
String ffmpegBinPath = FFMPEG_BIN_PATH;
if (StrUtil.isNotBlank(ffmpegBinPath)) {
this.ffmpegExeFile = new File(ffmpegBinPath);
}
// 同步静态变量,支持静态方法调用
STATIC_FFMPEG_BIN_PATH = ffmpegBinPath;
STATIC_VIDEO_BASE_PATH = VIDEO_SAVE_BASE_PATH;
STATIC_FFMPEG_EXE_FILE = this.ffmpegExeFile;
CONFIG_INITIALIZED = true;
LOGGER.info("【Spring初始化】FFmpeg加载完成,最大并发进程:{}", MAX_FFMPEG_PROCESS);
}
}
/**
* 服务优雅停机,回收线程池资源
* 杜绝线程泄漏、句柄残留问题
*/
@PreDestroy
public void destroy() {
LOGGER.info("开始关闭FFmpeg流消费线程池");
F_FMPEG_STREAM_EXECUTOR.shutdown();
try {
if (!F_FMPEG_STREAM_EXECUTOR.awaitTermination(3, TimeUnit.SECONDS)) {
// 等待超时,强制关闭所有线程
F_FMPEG_STREAM_EXECUTOR.shutdownNow();
}
} catch (InterruptedException e) {
F_FMPEG_STREAM_EXECUTOR.shutdownNow();
Thread.currentThread().interrupt();
}
LOGGER.info("FFmpeg线程池优雅关闭完成");
}
// ====================== 对外静态调用入口【业务直接使用】 ======================
/**
* RTMP直播/点播流转标准MP4
* @param rtmpUrl RTMP流地址(直播/点播通用)
* @param streamId 唯一业务ID
* @param suffix 文件后缀(仅mp4)
* @return 可访问文件路径,失败返回null
*/
public static String convertRtmpToMp4(String rtmpUrl, String streamId, String suffix) {
// 1. 全局任务上限拦截,防止大批量任务堆积
if (RUNNING_TASK_COUNT.get() >= MAX_RUNNING_TASK) {
LOGGER.error("转换任务达到全局上限{},跳过 streamId:{}", MAX_RUNNING_TASK, streamId);
return null;
}
// 2. 参数安全校验
if (!ReUtil.isMatch(STREAM_ID_REGEX, streamId) || !ALLOW_SUFFIX.equalsIgnoreCase(suffix)) {
LOGGER.error("参数非法,streamId:{}", streamId);
return null;
}
if (StrUtil.isBlank(rtmpUrl) || StrUtil.isBlank(streamId)) {
LOGGER.warn("参数缺失,跳过任务:{}", streamId);
return null;
}
// 3. 延迟初始化配置
if (!CONFIG_INITIALIZED) {
synchronized (CONFIG_LOCK) {
if (!CONFIG_INITIALIZED) {
STATIC_FFMPEG_BIN_PATH = FFMPEG_BIN_PATH;
STATIC_VIDEO_BASE_PATH = VIDEO_SAVE_BASE_PATH;
if (StrUtil.isNotBlank(STATIC_FFMPEG_BIN_PATH)) {
STATIC_FFMPEG_EXE_FILE = new File(STATIC_FFMPEG_BIN_PATH);
}
CONFIG_INITIALIZED = true;
LOGGER.info("【静态延迟初始化】FFmpeg配置加载完成");
}
}
}
// 环境校验
if (!checkStaticFfmpegEnv()) {
return null;
}
boolean acquire = false;
RUNNING_TASK_COUNT.incrementAndGet();
try {
// 限时获取并发许可,避免无限阻塞
acquire = FFMPEG_SEMAPHORE.tryAcquire(SEMAPHORE_WAIT_SECONDS, TimeUnit.SECONDS);
if (!acquire) {
LOGGER.error("FFmpeg并发已满,等待{}秒未获取许可,跳过 streamId:{}", SEMAPHORE_WAIT_SECONDS, streamId);
return null;
}
// 5. 指数退避重试机制,提升录制成功率
String result = null;
for (int i = 0; i <= RTMP_RETRY_COUNT; i++) {
result = doStaticConvert(rtmpUrl, streamId, suffix);
if (StrUtil.isNotBlank(result)) {
break;
}
if (i >= RTMP_RETRY_COUNT) {
LOGGER.error("重试{}次全部失败 streamId:{}", RTMP_RETRY_COUNT, streamId);
break;
}
long sleepMs = RETRY_BASE_SLEEP_MS * (i + 1);
LOGGER.warn("第{}次重试 streamId:{},休眠{}ms", i + 1, streamId, sleepMs);
ThreadUtil.sleep(sleepMs);
}
return result;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
LOGGER.error("转换线程中断 streamId:{}", streamId, e);
return null;
} finally {
// 资源兜底释放
if (acquire) {
FFMPEG_SEMAPHORE.release();
}
RUNNING_TASK_COUNT.decrementAndGet();
}
}
/**
* RTMP地址脱敏,隐藏鉴权参数,防止日志泄露密钥
*/
private static String maskRtmpUrl(String rtmpUrl) {
if (StrUtil.isBlank(rtmpUrl)) {
return rtmpUrl;
}
int queryIndex = rtmpUrl.indexOf('?');
if (queryIndex > 0) {
return rtmpUrl.substring(0, queryIndex) + "?******";
}
return rtmpUrl;
}
/**
* 从RTMP链接中提取Host
* @param rtmpUrl rtmp地址
* @return host,解析失败返回null
*/
private static String extractRtmpHost(String rtmpUrl) {
if (StrUtil.isBlank(rtmpUrl)) {
return null;
}
Matcher matcher = RTMP_HOST_PATTERN.matcher(rtmpUrl);
if (matcher.find()) {
return matcher.group(1);
}
return null;
}
/**
* 判断是否为禁止访问的内网/本地地址,防御SSRF
* @param host 提取出来的host(ip/域名)
* @return true=禁止访问
*/
private static boolean isBlockHost(String host) {
if (StrUtil.isBlank(host)) {
return false;
}
// 本地回环域名、回环地址
if ("localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host) || "::1".equals(host)) {
return true;
}
// 正则校验是否为标准IPv4格式
if (!host.matches("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$")) {
// 非IPv4(域名),不进行DNS解析,避免DNS重绑定攻击、网络阻塞
return false;
}
String[] ipSeg = host.split("\\.");
int seg1 = Integer.parseInt(ipSeg[0]);
int seg2 = Integer.parseInt(ipSeg[1]);
// 10.0.0.0/8
if (seg1 == 10) {
return true;
}
// 172.16.0.0/12 ~ 172.31.255.255
if (seg1 == 172 && seg2 >= 16 && seg2 <= 31) {
return true;
}
// 192.168.0.0/16
if (seg1 == 192 && seg2 == 168) {
return true;
}
return false;
}
// ====================== 内部私有工具方法 ======================
/**
* 校验FFmpeg运行环境是否就绪
* @return true 环境正常;false 不可用
*/
private static boolean checkStaticFfmpegEnv() {
if (StrUtil.isBlank(STATIC_FFMPEG_BIN_PATH) || STATIC_FFMPEG_EXE_FILE == null) {
LOGGER.error("FFmpeg路径未配置");
return false;
}
if (!STATIC_FFMPEG_EXE_FILE.exists()) {
LOGGER.error("FFmpeg文件不存在:{}", STATIC_FFMPEG_BIN_PATH);
return false;
}
if (!STATIC_FFMPEG_EXE_FILE.canExecute()) {
LOGGER.error("FFmpeg无执行权限:{}", STATIC_FFMPEG_BIN_PATH);
return false;
}
return true;
}
/**
* 单次转换核心逻辑【两段式:RTMP录制FLV ---> FLV转标准MP4】
* 无损流拷贝,支持进度拖拽,修复时间戳异常
* @param rtmpUrl RTMP地址
* @param streamId 事件ID
* @param suffix 文件后缀
* @return 访问路径 / null
*/
private static String doStaticConvert(String rtmpUrl, String streamId, String suffix) {
String ymdStr = LocalDate.now().format(YMD_FMT);
String timeStr = LocalDateTime.now().format(FULL_TIME_FMT);
String tempFlvName = streamId + "_" + timeStr + ".flv";
String finalMp4Name = streamId + "_" + timeStr + "." + suffix;
// 目录缓存,减少重复创建目录竞争
File dayDirFile = DAY_DIR_CACHE.computeIfAbsent(ymdStr, k -> {
String dirPath = FileUtil.normalize(STATIC_VIDEO_BASE_PATH + File.separator + k);
File dir = new File(dirPath);
FileUtil.mkdir(dir);
return dir;
});
String dayDir = dayDirFile.getAbsolutePath();
String tempFlvPath = FileUtil.normalize(dayDir + File.separator + tempFlvName);
String fullSavePath = FileUtil.normalize(dayDir + File.separator + finalMp4Name);
File tempFlvFile = new File(tempFlvPath);
File outputVideoFile = new File(fullSavePath);
// 前置清理旧文件,避免文件占用冲突
if (tempFlvFile.exists()) {
FileUtil.del(tempFlvFile);
}
if (outputVideoFile.exists()) {
FileUtil.del(outputVideoFile);
}
Process recordProcess = null;
boolean flvRecordSuccess = false;
try {
// 获取规范路径,适配Windows路径特殊场景
String ffmpegExePath;
try {
ffmpegExePath = STATIC_FFMPEG_EXE_FILE.getCanonicalPath();
} catch (IOException e) {
ffmpegExePath = STATIC_FFMPEG_EXE_FILE.getAbsolutePath();
}
// ========== 第一步:RTMP流录制为临时FLV文件 ==========
List<String> recordCmdList = buildRecordFlvCmd(ffmpegExePath, rtmpUrl, tempFlvPath);
ProcessBuilder pbRecord = new ProcessBuilder(recordCmdList);
pbRecord.directory(STATIC_FFMPEG_EXE_FILE.getParentFile());
LOGGER.info("【录制FLV】FFmpeg命令:{}", recordCmdList);
LOGGER.info("开始录制 streamId:{}, rtmpSrc:{}", streamId, maskRtmpUrl(rtmpUrl));
recordProcess = pbRecord.start();
// 异步消费输出流,防止缓冲区阻塞进程卡死
InputStream inputStream = recordProcess.getInputStream();
F_FMPEG_STREAM_EXECUTOR.submit(() -> asyncConsumeStream(inputStream, streamId));
// 等待录制进程执行完成,设置全局超时(超时管控,杜绝进程永久挂起)
boolean finished = recordProcess.waitFor(FFMPEG_TIMEOUT_SECONDS, TimeUnit.SECONDS);
if (!finished) {
LOGGER.error("录制FLV超时{}秒 streamId:{}", FFMPEG_TIMEOUT_SECONDS, streamId);
return null;
}
int exitCode = recordProcess.exitValue();
if (exitCode != 0) {
LOGGER.error("录制FLV异常退出码{} streamId:{}", exitCode, streamId);
return null;
}
// 校验flv文件有效性(极端网络断流场景生成几KB 损坏 flv,过滤残缺碎片)
long flvLen = tempFlvFile.length();
if (!tempFlvFile.exists() || flvLen <= 1024) {
LOGGER.error("FLV输出文件为空 streamId:{}", streamId);
return null;
}
flvRecordSuccess = true;
LOGGER.info("FLV录制完成,大小:{}KB streamId:{}", flvLen / 1024, streamId);
// ========== 第二步:FLV转标准MP4(带faststart支持进度拖拽) ==========
List<String> convertCmd = buildFlvToMp4Cmd(ffmpegExePath, tempFlvPath, fullSavePath);
ProcessBuilder pbConvert = new ProcessBuilder(convertCmd);
pbConvert.directory(STATIC_FFMPEG_EXE_FILE.getParentFile());
Process convertProcess = null;
try {
convertProcess = pbConvert.start();
// FLV转MP4是纯本地文件流拷贝,最多预留60秒足够
if (!convertProcess.waitFor(60, TimeUnit.SECONDS)) {
convertProcess.destroyForcibly();
LOGGER.error("FLV转MP4进程超时卡死,强制终止 streamId:{}", streamId);
return null;
}
// 校验最终mp4
long mp4Len = outputVideoFile.length();
if (!outputVideoFile.exists() || mp4Len <= 0) {
LOGGER.error("FLV转MP4失败,保留flv文件作为兜底 streamId:{}", streamId);
return null;
}
// 转码成功,删除临时flv文件
FileUtil.del(tempFlvFile);
// 返回前端Nginx可访问相对路径
String accessUrl = File.separator + "eventFile" + File.separator + "payback"
+ File.separator + ymdStr + File.separator + finalMp4Name;
long fileKb = mp4Len / 1024;
LOGGER.info("最终转封装成功 streamId:{},大小:{}KB,url:{}", streamId, fileKb, accessUrl);
return accessUrl;
} finally {
// 无论正常结束/异常返回,强制销毁转封装ffmpeg,杜绝僵尸进程
if (convertProcess != null && convertProcess.isAlive()) {
convertProcess.destroyForcibly();
}
}
} catch (Exception e) {
LOGGER.error("单次转换异常 streamId:{}", streamId, e);
return null;
} finally {
// 强制销毁录制子进程
if (recordProcess != null && recordProcess.isAlive()) {
recordProcess.destroyForcibly();
LOGGER.warn("强制销毁录制FFmpeg进程 streamId:{}", streamId);
}
// 录制失败,清理残缺flv
if (!flvRecordSuccess && tempFlvFile.exists()) {
ThreadUtil.sleep(500);
boolean deleteOk = FileUtil.del(tempFlvFile);
if (deleteOk) {
LOGGER.warn("录制失败,清理残缺flv streamId:{}", streamId);
} else {
LOGGER.error("录制失败,flv文件无法删除(占用) path:{}", tempFlvFile.getAbsolutePath());
}
}
}
}
/**
* 命令1:构建RTMP转FLV录制命令
* 适配直播/点播双场景,添加超时、容错、修复参数
*/
private static List<String> buildRecordFlvCmd(String ffmpegExePath, String rtmpUrl, String tempFlvPath) {
List<String> cmdList = new ArrayList<>();
cmdList.add(ffmpegExePath);
// 禁止监听标准输入,解决Java启动ffmpeg进程卡死经典问题
cmdList.add("-nostdin");
// 读写空闲超时:连续40秒无数据包,ffmpeg主动退出(微秒)
cmdList.add("-rw_timeout");
cmdList.add("40000000");
// RTMP缓冲区
cmdList.add("-rtmp_buffer");
cmdList.add("1000000");
// 输入源
cmdList.add("-i");
cmdList.add(rtmpUrl);
// 最大录制时长(网络流必须放在-i之后)
cmdList.add("-t");
cmdList.add(MAX_RECORD_SECOND);
// 流拷贝,不转码
cmdList.add("-c");
cmdList.add("copy");
cmdList.add("-y");
cmdList.add("-fflags");
cmdList.add("+igndts");
cmdList.add("-avoid_negative_ts");
cmdList.add("make_zero");
// TCP连接握手超时
cmdList.add("-timeout");
cmdList.add("30000000");
cmdList.add("-loglevel");
cmdList.add("error");
// 输出flv
cmdList.add(tempFlvPath);
return cmdList;
}
/**
* 命令2:构建FLV转MP4命令
* 核心faststart参数,实现视频进度拖拽功能
*/
private static List<String> buildFlvToMp4Cmd(String ffmpegExePath, String flvPath, String mp4Path) {
List<String> cmdList = new ArrayList<>();
cmdList.add(ffmpegExePath);
cmdList.add("-i");
cmdList.add(flvPath);
cmdList.add("-c");
cmdList.add("copy");
// moov移到文件头部,支持播放器拖动进度条
cmdList.add("-movflags");
cmdList.add("faststart");
cmdList.add("-y");
cmdList.add("-loglevel");
cmdList.add("error");
cmdList.add(mp4Path);
return cmdList;
}
// ====================== 安全与工具私有方法 ======================
/**
* 异步消费FFmpeg进程输出流,防止缓冲区阻塞
* <p>优化:移除Scanner,使用原始字节流,彻底消除hasNextLine永久阻塞风险</p>
* @param inputStream FFmpeg进程输入流
* @param streamId 任务标识,用于日志定位
*/
private static void asyncConsumeStream(InputStream inputStream, String streamId) {
byte[] buffer = new byte[4096];
try (InputStream is = inputStream) {
// 持续清空缓冲区,防止ffmpeg进程缓冲区满阻塞
while (is.read(buffer) != -1) {
// 丢弃输出内容,无需打印
}
} catch (Exception e) {
LOGGER.error("FFmpeg输出流消费异常 streamId:{}", streamId, e);
}
}
// ====================== 本地main测试入口 ======================
/**
* 本地调试专用,两种测试模式
* 1. LIVE直播模式:需手动推流,匀速录制
* 2. VOD点播模式:无需推流,秒级调试(推荐)
*/
public static void main(String[] args) {
// // 模式1:LIVE直播地址
// String testRtmp = "rtmp://127.0.0.1:1935/live/test001";
// 模式2:VOD点播地址(模拟海康回放),(推荐调试)
String testRtmp = "rtmp://127.0.0.1:1935/vod/test.mp4";
String streamId = "202607251534";
String suffix = "mp4";
String uniqueStreamId = streamId + "_" + FULL_TIME_FMT.format(LocalDateTime.now());
long start = System.currentTimeMillis();
String result = convertRtmpToMp4(testRtmp, uniqueStreamId, suffix);
LOGGER.info("转换耗时: {}秒; 转换结果访问路径: {}", (System.currentTimeMillis() - start) / 1000, result);
}
}
六、双模式本地测试验证
本篇提供两套测试方案,分别适配日常快速调试、生产场景模拟,无需复杂配置,运行工具类main测试方法即可验证功能的有效性。
6.1 VOD点播模式(开发调试首选)
无需手动推流,Nginx-RTMP直接读取本地完整视频文件,全速拉流录制,20MB视频可秒级完成转码生成,调试效率极高。最终输出标准MP4文件,支持任意进度拖拽、无黑屏、无音视频错位、无文件残缺,完美适配日常开发调试场景。
测试代码:
java
public static void main(String[] args) {
// 模式2:VOD点播地址(模拟海康回放)
String testRtmp = "rtmp://127.0.0.1:1935/vod/test.mp4";
String streamId = "202607251534";
String suffix = "mp4";
String uniqueStreamId = streamId + "_" + FULL_TIME_FMT.format(LocalDateTime.now());
long start = System.currentTimeMillis();
String result = convertRtmpToMp4(testRtmp, uniqueStreamId, suffix);
LOGGER.info("转换耗时: {}秒; 转换结果访问路径: {}", (System.currentTimeMillis() - start) / 1000, result);
}
运行日志:


6.2 LIVE直播模式(生产场景验证)
通过FFmpeg推流命令模拟摄像头、监控设备实时直播推流,程序持续抓取RTMP流数据并匀速录制,推流断开后自动触发超时机制,优雅结束录制任务。完美复现真实生产直播场景,生成视频完整、音画同步,适配实时监控、直播归档等核心业务。
测试代码:
java
public static void main(String[] args) {
// 模式1:LIVE直播地址
String testRtmp = "rtmp://127.0.0.1:1935/live/test001";
String streamId = "202607251534";
String suffix = "mp4";
String uniqueStreamId = streamId + "_" + FULL_TIME_FMT.format(LocalDateTime.now());
long start = System.currentTimeMillis();
String result = convertRtmpToMp4(testRtmp, uniqueStreamId, suffix);
LOGGER.info("转换耗时: {}秒; 转换结果访问路径: {}", (System.currentTimeMillis() - start) / 1000, result);
}
运行日志:

两次转换后生成的mp4视频文件:


七、集成所遇问题
结合实际集成测试经验,总结SpringBoot集成FFmpeg全过程中遇到的问题,并定位问题原因、提供可直接落地的解决方案:
- 问题1: FFmpeg文件不存在报错
问题原因:路径配置错误、路径含中文/空格特殊字符、exe文件缺失损坏;
解决方案:统一使用纯英文无特殊字符绝对路径,核对FFmpeg bin目录下核心程序完整性,适配本机环境修改配置路径。 - 问题2: FFmpeg进程卡死、无日志输出
问题原因:未异步消费进程输出流,缓冲区溢出导致进程阻塞挂起。
解决方案:独立守护线程池异步清空进程输出流,从底层彻底规避缓冲区阻塞问题。 - 问题3: 静态调用空指针异常
问题原因:Spring容器未初始化完成,业务提前调用静态方法。
解决方案:采用双重检查锁延迟初始化配置,保障容器加载优先级,彻底解决静态调用空指针问题。 - 问题4: 视频文件占用、删除失败
问题原因:FFmpeg子进程未彻底销毁,系统文件句柄残留。
解决方案:通过finally代码块兜底强制销毁进程,失败自动清理残缺临时文件,释放文件资源。 - 问题5: 并发任务卡顿、批量录制吞吐低
问题原因:无并发限流管控,多进程打满磁盘IO、并发创建目录产生锁竞争。
解决方案:信号量管控并发进程数量,目录缓存消除锁竞争,大幅提升批量任务处理吞吐。
八、本篇小结
本文作为流媒体实战专栏系列 第二篇集成整合内容,承接上篇FFmpeg+Nginx-RTMP环境搭建内容,完成了从基础环境部署到SpringBoot工程业务落地的完整闭环,解决了SpringBoot整合FFmpeg代码零散、报错频发的问题。
本文工具类采用两段式录制方式,解决了普通转码视频无法拖拽、音视频时间戳异常的问题;信号量限流、任务计数器、线程池隔离机制,有效支撑大批量并发录制场景;参数正则校验、超时管控、指数退避重试、资源兜底回收等多重防护。
本文工具类经过多次测试优化,零基础开发者也能直接复制集成测试,快速实现RPMP直播、点播流转标准可播放MP4文件的业务能力。
本文属于【Windows流媒体实战专栏】系列第二篇
第一篇:FFmpeg+Nginx-RTMP Windows超详细搭建教程(统一版本、解决环境报错、零基础部署)
第二篇:SpringBoot2.x 整合FFmpeg 完整实操教程(【本文】完整工具类源码、双场景实测)