【Windows流媒体实战2】SpringBoot2.x 整合FFmpeg 完整实操教程

SpringBoot2.x 整合FFmpeg 完整实操教程

一、前言

在第一篇基础环境搭建好后,本篇的目标是实现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 完整实操教程(【本文】完整工具类源码、双场景实测)

相关推荐
程序员老陆1 天前
FFmpeg6的滤镜函数解析
ffmpeg·音视频·avfilter
看浪的路人1 天前
第10讲:Python 调用 FFmpeg 批量处理——打造你的视频处理工具箱
python·ffmpeg·音视频
程序员老陆2 天前
FFmpeg 时间戳全解:从 av_rescale_q 到时间基黑洞
ffmpeg·音视频·时间基
周航宇JoeZhou2 天前
JP3-2-1-MyItem项目简介
java·ai·springboot·环境搭建·项目·管理·springai
水獭比特2 天前
自动成片没报错,为什么音画还会漂?
ffmpeg
剧号2 天前
互联网大厂Java面试全解析:Java SE 11, Spring Boot及微服务实战问答
java·微服务·面试·kafka·kubernetes·springboot·分布式系统
Yeauty2 天前
2026 年在 Rust 里处理音视频,该走哪条路?
rust·ffmpeg·音视频·视频
勿忘初心12213 天前
【Windows流媒体实战1】FFmpeg+Nginx-RTMP Windows详细搭建教程
ffmpeg·nginx-rtmp·windows流媒体搭建·rtmp直播
X-⃢_⃢-X4 天前
四、OpenFeign远程调用
java·微服务·springboot·springcloud