Spring Boot + FFmpeg 实现「大视频文件分片上传 → 服务端合并 → FFmpeg 校验/转码」,这是视频平台(点播、网课、安防回放)的标准做法。
一、整体设计思路(先看这个)
✅ 核心流程
前端 ├─ 1. 申请上传(获取 uploadId) ├─ 2. 分片上传(chunkIndex) ├─ 3. 通知合并 │ 后端 Spring Boot ├─ 临时目录:/upload/tmp/{uploadId}/ ├─ 保存分片:chunk_0001.tmp ├─ 合并分片:FileOutputStream ├─ FFmpeg 校验/转码(可选) └─ 最终视频:/video/{videoId}.mp4⚠️关键点
✅ 分片 ≠ FFmpeg 切片(segment)
✅ 合并是二进制拼接
✅ FFmpeg 只在合并完成后介入(校验 / 转码 / 截图)
二、目录结构约定
/upload ├─ tmp │ └─ 1690000000000_abc │ ├─ chunk_0000.tmp │ ├─ chunk_0001.tmp │ └─ ... └─ video └─ 1690000000000_abc.mp4三、DTO / 常量定义
public class UploadConstants { public static final String TMP_DIR = "./upload/tmp/"; public static final String VIDEO_DIR = "./upload/video/"; public static final String CHUNK_PREFIX = "chunk_"; public static final String CHUNK_SUFFIX = ".tmp"; }四、分片上传 Controller
@RestController @RequestMapping("/api/video/upload") @RequiredArgsConstructor @Slf4j public class VideoChunkUploadController { @PostMapping("/init") public Map<String, String> initUpload() { String uploadId = System.currentTimeMillis() + "_" + UUID.randomUUID(); File tmpDir = new File(UploadConstants.TMP_DIR + uploadId); tmpDir.mkdirs(); return Map.of("uploadId", uploadId); } @PostMapping("/chunk") public String uploadChunk( @RequestParam String uploadId, @RequestParam Integer chunkIndex, @RequestParam MultipartFile file ) throws Exception { File tmpDir = new File(UploadConstants.TMP_DIR + uploadId); if (!tmpDir.exists()) { throw new RuntimeException("uploadId 不存在"); } String chunkName = String.format("%s%04d%s", UploadConstants.CHUNK_PREFIX, chunkIndex, UploadConstants.CHUNK_SUFFIX); file.transferTo(new File(tmpDir, chunkName)); return "chunk uploaded"; } }五、合并分片(核心)
✅ 合并逻辑(二进制拼接)
@Service @RequiredArgsConstructor @Slf4j public class VideoMergeService { public String mergeChunks(String uploadId, int totalChunks) throws Exception { File tmpDir = new File(UploadConstants.TMP_DIR + uploadId); if (!tmpDir.exists()) { throw new RuntimeException("分片目录不存在"); } new File(UploadConstants.VIDEO_DIR).mkdirs(); String finalFileName = uploadId + ".mp4"; File outputFile = new File(UploadConstants.VIDEO_DIR, finalFileName); try (FileOutputStream fos = new FileOutputStream(outputFile)) { for (int i = 0; i < totalChunks; i++) { File chunk = new File(tmpDir, String.format("%s%04d%s", UploadConstants.CHUNK_PREFIX, i, UploadConstants.CHUNK_SUFFIX)); if (!chunk.exists()) { throw new RuntimeException("缺失分片:" + i); } Files.copy(chunk.toPath(), fos); } } // 清理临时分片 deleteDirectory(tmpDir); return outputFile.getAbsolutePath(); } private void deleteDirectory(File dir) { File[] files = dir.listFiles(); if (files != null) { for (File f : files) f.delete(); } dir.delete(); } }六、FFmpeg 校验 + 转码(强烈推荐)
合并后一定要用 FFmpeg 校验,否则可能出现:
视频无法播放
时长异常
moov box 在尾部(HTTP 播放失败)
✅ FFmpeg 校验视频完整性
public boolean validateVideo(String videoPath) throws Exception { List<String> cmd = new ArrayList<>(); cmd.add("ffmpeg"); cmd.add("-v"); cmd.add("error"); cmd.add("-i"); cmd.add(videoPath); cmd.add("-f"); cmd.add("null"); cmd.add("-"); ProcessBuilder pb = new ProcessBuilder(cmd); pb.redirectErrorStream(true); Process process = pb.start(); try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()))) { String line; while ((line = reader.readLine()) != null) { log.error("ffmpeg error: {}", line); } } return process.waitFor() == 0; }✅ 转码为标准 MP4(moov 前置)
public void transcodeToMp4(String input, String output) throws Exception { List<String> cmd = new ArrayList<>(); cmd.add("ffmpeg"); cmd.add("-i"); cmd.add(input); cmd.add("-c:v"); cmd.add("libx264"); cmd.add("-c:a"); cmd.add("aac"); cmd.add("-movflags"); cmd.add("faststart"); // ✅ moov 前置 cmd.add("-y"); cmd.add(output); new ProcessBuilder(cmd).inheritIO().start().waitFor(); }七、合并接口
@PostMapping("/merge") public Map<String, String> merge( @RequestParam String uploadId, @RequestParam int totalChunks ) throws Exception { String rawVideoPath = videoMergeService.mergeChunks(uploadId, totalChunks); if (!videoMergeService.validateVideo(rawVideoPath)) { throw new RuntimeException("视频文件损坏"); } String finalPath = rawVideoPath.replace(".mp4", "_final.mp4"); videoMergeService.transcodeToMp4(rawVideoPath, finalPath); return Map.of( "rawVideo", rawVideoPath, "finalVideo", finalPath ); }八、前端分片示例(JS 伪代码)
const chunkSize = 5 * 1024 * 1024; // 5MB let totalChunks = Math.ceil(file.size / chunkSize); for (let i = 0; i < totalChunks; i++) { const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize); const form = new FormData(); form.append("uploadId", uploadId); form.append("chunkIndex", i); form.append("file", chunk); await fetch("/api/video/upload/chunk", { method: "POST", body: form }); } await fetch("/api/video/upload/merge?uploadId=" + uploadId + "&totalChunks=" + totalChunks);九、断点续传(加分项)
功能 | 实现方式 |
|---|---|
已上传分片 | 前端上传前 |
秒传 | MD5 校验文件是否存在 |
并发控制 | Promise.all 限流 |
大文件 | 单分片 ≥ 5MB |
十、生产级优化建议
✅必做
FFmpeg
-movflags faststart合并后删除临时分片
文件大小 + MD5 校验
异步转码(
@Async/ MQ)
✅进阶
分布式:MinIO + Redis 记录分片
秒传:Redis 存 fileMd5 → videoPath
进度:WebSocket / 轮询
Docker:
ffmpeg:6.0镜像