
简介这份资源面向在 SpringBoot 项目中需要处理视频元数据的 Java 开发者聚焦于获取视频第一帧、时长与宽高比等关键信息适用于视频分享平台、视频预览、上传审核等场景。包内共 131 个文件以 103 个 xml 配置、8 个 java 源码、7 个 class 字节码为主另含 yml、properties 配置、mp4 测试视频、jpg 首帧示例及 jar 依赖压缩包约 25.75MB工程结构完整可直接导入运行。内容围绕 FFmpeg 集成展开涵盖命令行调用与 ffmpeg-java 两种方式演示了通过解析 Duration 字段获取时长、用 -vframes 1 抽取首帧并转为 BufferedImage、依据宽高计算宽高比判断横竖屏以及 Controller 接收上传文件的整合写法同时涉及异步处理、文件大小限制与 MIME 类型校验等优化与安全思路。已有 3013 人学习下载适合希望快速落地视频信息提取功能的中级开发者参考。1. 视频首帧与时长提取Spring Boot 里绕不开的元数据硬骨头做内容管理、短视频审核或者在线教育后台的朋友大概率都遇到过这个需求用户上传一个视频后台得立刻拿到它的封面图和播放时长。封面图用来做列表展示时长用来做进度条和计费。听起来简单但真到 Spring Boot 里落地你会发现 JDK 自带的库根本啃不动 MP4 的 moov box而引入 FFmpeg 又得考虑服务器上有没有装、Windows 开发环境怎么跑。我见过太多项目在这块翻车——要么封面是黑屏要么时长永远是 0要么并发一上来直接把内存打爆。这篇笔记就围绕「Spring Boot 获取视频第一帧和时长」这个具体场景把选型、代码、参数和踩过的坑一次讲透。适合正在做文件上传模块的后端也适合想给视频列表加预览图的前端对接人。2. 选型对比JavaCV、FFmpeg CLI 还是 JCodec2.1 三种主流方案的能力边界在 Spring Boot 里抽视频帧常见做法无非三条路。第一条是调 FFmpeg 命令行用ProcessBuilder起子进程优点是功能最全、格式通吃缺点是依赖外部环境Docker 镜像里不加 ffmpeg 就直接报Cannot run program ffmpeg。第二条是 JavaCV它把 FFmpeg 的动态库打包进了 jar跨平台省心但 jar 包体积直接飙到 500MB 以上对镜像大小敏感的项目得掂量。第三条是 JCodec纯 Java 实现零外部依赖但只支持 H.264 的 MP4 和 MOV遇到 H.265 或者 MKV 就歇菜。我一般会先问一句你的视频格式可控吗如果是内部系统上传前就限定了 MP4/H.264那 JCodec 最轻量一个jcodec加jcodec-javase依赖就搞定。如果是面向 C 端用户什么手机拍的都有那别犹豫直接上 JavaCV 或者 FFmpeg CLI。下面这张表是我自己在选型时列的对比参数基于常见版本具体以你项目实际为准。方案依赖体积格式支持首帧提取时长获取并发表现FFmpeg CLI需单独安装约 70MB几乎全格式支持支持进程隔离较稳JavaCVjar 约 500MB几乎全格式支持支持内存占用高JCodecjar 约 2MBMP4/H.264支持支持纯内存快但格式窄2.2 为什么我最终选了 FFmpeg CLI 方案JavaCV 的坑在于它虽然把库打进来了但不同平台要加载不同的 native 库在 Alpine 镜像里经常报UnsatisfiedLinkError排查起来很玄学。JCodec 的坑在于它取时长是靠遍历帧算出来的一个 10 分钟的视频要读几千帧CPU 直接拉满。FFmpeg CLI 虽然要装环境但胜在稳定、快、资源可控。我的做法是在 Dockerfile 里加一行RUN apk add ffmpeg或者在 Ubuntu 基础镜像里apt-get install -y ffmpeg然后 Java 侧只负责拼命令和读输出。这样即使 FFmpeg 崩了也只是子进程挂掉不会把 JVM 带崩。提示如果你用openjdk:8-jre-alpine做基础镜像装完 ffmpeg 后镜像大概增加 60-80MB比塞 JavaCV 划算得多。3. 动手实现ProcessBuilder 调 FFmpeg 抽帧与读时长3.1 环境准备与依赖确认先在服务器上确认 ffmpeg 和 ffprobe 都能用。ffprobe 是 FFmpeg 套件里专门读元数据的工具输出 JSON 格式解析起来比正则稳得多。执行ffmpeg -version和ffprobe -version只要有输出就行。Spring Boot 项目本身不需要加任何视频处理依赖只需要spring-boot-starter-web处理上传即可。如果你用 Maven确认一下pom.xml里没有多余的javacv依赖避免 jar 冲突。# 检查 ffmpeg 和 ffprobe 是否可用 ffmpeg -version ffprobe -version # 如果没装Ubuntu/Debian 下这样装 apt-get update apt-get install -y ffmpeg这两条命令是后续所有操作的前提。ffmpeg -version会输出编译配置ffprobe -version输出类似。如果提示 command not found后面的 Java 代码一定跑不通先解决环境问题。3.2 用 ffprobe 读取视频时长时长信息藏在容器的 metadata 里ffprobe 可以直接读出来不用解码整个视频。下面这段 Java 代码把 ffprobe 的输出解析成秒数。注意-v quiet是压掉日志-print_format json让输出结构化-show_format只取容器层信息速度极快。import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.BufferedReader; import java.io.InputStreamReader; public class VideoMetaUtil { private static final ObjectMapper MAPPER new ObjectMapper(); /** * 读取视频时长单位秒 * param filePath 视频文件的绝对路径 * return 时长秒数解析失败返回 0 */ public static double getDuration(String filePath) { try { // 拼 ffprobe 命令只读 format 层不解码 ProcessBuilder pb new ProcessBuilder( ffprobe, -v, quiet, -print_format, json, -show_format, filePath ); pb.redirectErrorStream(true); Process process pb.start(); StringBuilder output new StringBuilder(); try (BufferedReader reader new BufferedReader( new InputStreamReader(process.getInputStream()))) { String line; while ((line reader.readLine()) ! null) { output.append(line); } } process.waitFor(); JsonNode root MAPPER.readTree(output.toString()); // duration 字段在 format 节点下单位是秒可能是字符串 JsonNode durationNode root.path(format).path(duration); if (durationNode.isMissingNode()) { return 0; } return Double.parseDouble(durationNode.asText()); } catch (Exception e) { // 实际项目里建议打日志这里简化 return 0; } } }逻辑说明ProcessBuilder把 ffprobe 当外部命令跑redirectErrorStream(true)把 stderr 合并到 stdout避免缓冲区满了导致进程卡死。-show_format只输出容器信息不碰视频流所以哪怕文件很大也是毫秒级返回。参数上-v quiet可以换成-v error方便排查但生产环境建议 quiet。duration字段有时是字符串有时是数字用asText()统一转字符串再 parse 最稳。3.3 抽取第一帧并保存为图片抽帧用 ffmpeg 的-ss定位到第 0 秒-vframes 1只取一帧输出 jpg 或 png。这里有个细节-ss放在-i前面是快速定位放在后面是精确解码。对于第一帧放前面就行速度差好几倍。import java.io.File; public class VideoFrameUtil { /** * 抽取视频第一帧保存为 jpg * param videoPath 视频绝对路径 * param outputPath 输出图片绝对路径 * return 是否成功 */ public static boolean extractFirstFrame(String videoPath, String outputPath) { try { ProcessBuilder pb new ProcessBuilder( ffmpeg, -ss, 0, // 定位到第 0 秒 -i, videoPath, // 输入文件 -vframes, 1, // 只取一帧 -q:v, 2, // 图片质量2 是高质量 -y, // 覆盖已存在文件 outputPath ); pb.redirectErrorStream(true); Process process pb.start(); // 必须消费输出流否则 ffmpeg 可能阻塞 try (var reader new java.io.BufferedReader( new java.io.InputStreamReader(process.getInputStream()))) { while (reader.readLine() ! null) { // 丢弃日志 } } return process.waitFor() 0 new File(outputPath).exists(); } catch (Exception e) { return false; } } }逻辑说明-ss 0放在-i前ffmpeg 会直接跳到文件开头附近不用解码前面的帧。-vframes 1限制只输出一帧不加的话会一直抽到视频结束。-q:v 2控制 jpg 质量范围 2-31数字越小质量越高。-y是覆盖输出避免第二次调用时卡在交互提示。最关键的是消费getInputStream()ffmpeg 会往 stdout 写日志不读的话缓冲区满了进程就挂住这是血泪经验。3.4 在 Spring Boot 上传接口里串起来把上面两个工具类塞进上传接口接收MultipartFile先存临时文件再调工具方法最后把图片路径和时长返回给前端。import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.util.HashMap; import java.util.Map; import java.util.UUID; RestController RequestMapping(/api/video) public class VideoUploadController { PostMapping(/upload) public MapString, Object upload(RequestParam(file) MultipartFile file) throws Exception { MapString, Object result new HashMap(); // 1. 存临时文件 String tmpDir System.getProperty(java.io.tmpdir); String videoName UUID.randomUUID() .mp4; File videoFile new File(tmpDir, videoName); file.transferTo(videoFile); // 2. 抽第一帧 String coverName UUID.randomUUID() .jpg; File coverFile new File(tmpDir, coverName); boolean frameOk VideoFrameUtil.extractFirstFrame( videoFile.getAbsolutePath(), coverFile.getAbsolutePath()); // 3. 读时长 double duration VideoMetaUtil.getDuration(videoFile.getAbsolutePath()); result.put(coverOk, frameOk); result.put(coverPath, coverFile.getAbsolutePath()); result.put(duration, duration); // 4. 清理临时视频实际项目里可能转存 OSS videoFile.delete(); return result; } }逻辑说明MultipartFile.transferTo把上传流落到磁盘因为 ffmpeg 需要文件路径而不是流。临时目录用java.io.tmpdir兼容不同系统。封面和视频都用了 UUID 防重名。最后删掉临时视频避免磁盘堆积。参数上RequestParam(file)要和前端表单字段名一致否则报 400。4. 避坑排查抽帧黑屏、时长为零、并发卡死4.1 现象封面图全黑但视频能正常播放原因-ss 0定位到的是关键帧之前的位置有些视频开头几帧是黑场或者编码器延迟抽出来就是黑的。解决把-ss往后挪一点比如-ss 0.5或者-ss 1牺牲一点精度换可用封面。我一般会先试 0如果黑屏就改 1 秒。4.2 现象duration 返回 0 或者 NaN原因ffprobe 的format.duration字段在某些流媒体格式里不存在或者视频没有 moov box。解决先判断durationNode.isMissingNode()如果缺失就退而求其次读streams[0].duration再不行就返回 0 让前端显示--:--。不要强行 parse会抛异常。4.3 现象并发上传十几个视频后接口全部超时原因每个请求都起一个 ffmpeg 进程CPU 被占满而且ProcessBuilder默认没有超时控制一个卡住的进程会拖垮线程池。解决给process.waitFor()加超时比如process.waitFor(30, TimeUnit.SECONDS)超时后process.destroyForcibly()。另外用信号量限制同时运行的 ffmpeg 数量比如Semaphore(4)。4.4 现象Windows 开发环境正常Linux 服务器报 Cannot run program ffmpeg原因服务器没装 ffmpeg或者 PATH 里没有。解决Dockerfile 里显式安装或者用绝对路径/usr/bin/ffmpeg。如果用的是精简版基础镜像apt-get install后记得which ffmpeg确认路径。4.5 现象抽出来的图片是 0 字节原因ffmpeg 进程还没写完文件Java 就返回了。process.waitFor()只保证进程结束但文件系统缓冲可能还没刷。解决在waitFor之后加一个短暂的文件存在性轮询或者直接判断process.exitValue() 0再读文件。更稳的做法是输出到临时目录再原子移动。5. 进阶技巧异步抽帧、封面压缩与缓存策略5.1 把抽帧从上传主链路里拆出去上传接口里同步跑 ffmpeg用户得等好几秒才拿到响应体验很差。我现在的习惯是上传接口只存文件、写一条statusprocessing的记录然后丢一个消息到线程池或者 MQ异步去抽帧和读时长完成后更新记录。前端轮询或者用 WebSocket 拿结果。这样上传接口的 RT 能压到 200ms 以内。import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; Service public class VideoProcessService { Async(videoExecutor) // 自定义线程池核心数别超过 CPU 核数 public void processAsync(Long videoId, String videoPath) { // 抽帧 String coverPath videoPath .jpg; VideoFrameUtil.extractFirstFrame(videoPath, coverPath); // 读时长 double duration VideoMetaUtil.getDuration(videoPath); // 更新数据库记录 // updateVideoMeta(videoId, coverPath, duration); } }逻辑说明Async需要配合EnableAsync和自定义线程池。线程池大小建议设为 CPU 核数因为 ffmpeg 是 CPU 密集型。videoExecutor这个 Bean 要自己定义队列别用无界的用ArrayBlockingQueue加拒绝策略防止任务堆积把内存撑爆。5.2 封面图压缩与格式选择ffmpeg 抽出来的 jpg 可能有好几 MB列表页加载慢。可以在抽帧命令里直接加缩放参数比如-vf scale320:-1宽度固定 320高度按比例。格式上jpg 比 png 小很多但 png 支持透明。视频封面一般不需要透明所以 jpg 是首选。质量参数-q:v控制在 2 到 5 之间肉眼几乎看不出差别体积能小一半。参数作用推荐值-vf scale320:-1宽度 320高度自适应列表页用-q:vjpg 质量越小越好2-5-ss定位时间0 或 1-vframes输出帧数15.3 缓存策略别对同一个视频反复抽帧同一个视频可能被多次请求封面如果每次都跑 ffmpeg纯属浪费。我的做法是封面文件按视频 ID 命名存在固定目录请求时先查文件是否存在存在就直接返回 URL。时长也一样存数据库里不要每次读。另外如果视频被删除记得把封面文件一起清理否则磁盘里全是孤儿文件。从那以后我每次写上传逻辑都强制走一遍「存文件 → 异步处理 → 写缓存 → 清理临时文件」的流程再也没出现过封面黑屏或者时长丢失的线上事故。希望帮到你。本文还有配套的精品资源点击获取