做视频处理的时候,“把一个大视频切成若干小段,处理完再拼回去”这个需求真的非常常见。我最早遇到是在一个在线教育项目里,用户上传的课程视频动辄好几个G,服务器和对象存储都做了单文件大小限制,必须先分片再上传;后来另一个视频审核项目,又需要把长视频拆成多段分发给不同节点并行抽帧处理,最后再把结果合并回去。当时第一反应就是用 FFmpeg,但真正上手做 Java 这边的调度封装时才发现,“分片容易、合并坑多”这句话一点不夸张。
这篇就从一个能直接落地的方案说起:Java 调用 FFmpeg 做视频分片,自动生成 list.txt,再通过 concat demuxer 把分片无损合并成完整视频。适合正在做视频上传、转码、审核这类功能的 Java 后端开发者,也适合想搞清楚 FFmpeg 三种 concat 方式有什么区别的读者。全文不涉及编译 FFmpeg 源码,直接用官方二进制就够了,关键是怎么把进程调用、参数构造、异常兜底这些细节处理好。
1. 视频分片合并到底解决什么问题
1.1 一个“分不了片”就卡住整个链路的需求
很多人第一次接触分片合并,是因为上传限制。OSS、网盘、CDN 回源层都有单文件大小上限,比如 5GB 或者 2GB。视频这种动辄几十 GB 的文件,直接传肯定过不去。分片的意义就是把一个大文件变成多个小片段,上传完成后再在后端合并还原。
但这只是最基础的需求。实际项目里还有几个更常见的场景:
- 并行处理:视频审核、抽帧、AI 分析这类任务,单机处理一个长视频非常慢,拆成多个片段分发到不同机器并行处理,效率能提升好几倍,处理完再合并。
- 断点续传:大文件传输中断后重传成本太高,分片之后可以精确记录哪些片传完了,剩下只补传缺失部分。
- 分段转码:有些转码任务耗时很长,把视频拆成若干段分别转码,再合并输出,整体耗时能压下来一截。
- 素材拼接:短视频批量生产时,会有大量片段需要按顺序拼成一个完整成片,本质上也属于分片合并的范畴。
1.2 哪些项目会用到这套组合
从我在实际项目里看到的,用到“Java + FFmpeg + list.txt”这个组合的,基本集中在两类系统里。
一类是服务端视频处理平台。比如在线教育、知识付费、视频号运营后台,用户上传原始视频后,服务端要转码、切片、抽封面、打水印。这类系统后端大多用 Java 写,而视频处理本身又绕不开 FFmpeg,所以 Java 负责流程编排和任务调度,FFmpeg 负责真正的媒体处理,两边通过进程调用衔接。
另一类是自动化剪辑/批量出片工具。比如电商做商品视频批量生成,或者资讯类 App 做短视频自动拼接,Java 负责模板配置、素材管理、调度执行,最终产物就是 FFmpeg 合并出来的视频文件。这种场景下,分片和合并不是用户主动触发的,而是流水线里的一环,必须做到无人值守也能跑完,异常要能自动重试或告警。
1.3 为什么偏偏是 Java + FFmpeg
选这套组合,核心原因是各干各擅长的事。
FFmpeg 在视频处理领域几乎是事实标准,命令行为 API,不需要引入沉重的 SDK,官方提供各平台二进制,下载解压就能用。分片、合并、转码、抽帧都是几条命令的事。Java 这边,ProcessBuilder 调用外部进程很成熟,可以精确控制参数、捕获输出、设置超时,配合 Spring Boot 这类框架做任务队列和管理后台非常顺手。两者通过“命令行参数 + 退出码 + stderr 日志”通信,边界清晰,出问题也容易排查。
提示:这里说的 FFmpeg 是作为独立进程被 Java 拉起来执行的,不是 JNI 也不是 JavaCV。之所以不推荐 JavaCV,是因为 JavaCV 把 FFmpeg 封装成库后版本绑定比较死,升级 FFmpeg 要跟着重新编译,排查问题时还多一层封装噪音。直接用进程调用,FFmpeg 可以随时替换版本,业务代码几乎不用改动。
2. 三种拼接方式怎么选:list.txt 方案凭什么胜出
2.1 FFmpeg 并不是只有“合并”一个命令
很多人以为 FFmpeg 合并就是把几个视频“拼”在一起,其实 FFmpeg 提供了三种完全不同的拼接方式,分别适用于不同场景。如果选错了,轻则速度慢,重则直接报错或者输出文件损坏。
- concat filter(过滤器方案):把多个输入文件解码后,在滤镜图里拼接,再统一编码输出。命令大概长这样:
ffmpeg -i part_0.mp4 -i part_1.mp4 -filter_complex \ "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1" \ -c:v libx264 -c:a aac output.mp4这种方案一定会重新编码,所以速度最慢,CPU 占用也高,而且重编码会带来画质损失。它的唯一优势是兼容性极强:即使各个分片的编码参数不一致,也能通过重编码强行统一后拼接。
- concat protocol(协议方案):直接把多个文件按字节流拼接,命令长这样:
ffmpeg -i "concat:part_0.ts|part_1.ts" -c copy output.ts这种方案不重新编码,速度极快,但只适合 TS 流这类本身就支持无缝拼接的封装格式。对 MP4 这种带 moov box 索引结构的格式来说,简单的字节拼接会直接破坏索引信息,出来的文件大概率打不开。
- concat demuxer(解封装方案):先用一个文本文件描述要拼接的片段列表,然后逐个读取文件,在解封装层重新写入输出,配合
-c copy可以不重新编码。命令长这样:
ffmpeg -f concat -safe 0 -i list.txt -c copy output.mp4这就是我们最终采用的方案,也就是写 list.txt 的合并方式。
三种方案对比下来,差异非常明显:
| 方案 | 是否重编码 | 速度 | 输出格式兼容性 | 典型适用场景 |
|---|---|---|---|---|
| concat filter | 是 | 慢 | 很强,任何可解码格式 | 分片编码参数不一致、必须重编码 |
| concat protocol | 否 | 极快 | 弱,主要面向 TS 流 | 直播录制切片、TS 片段拼接 |
| concat demuxer | 否 | 快 | 强,支持 MP4 等常见封装 | 同源分片无损合并,日常首选 |
2.2 concat demuxer 的代价与边界
concat demuxer 能又快又无损,是因为它把 list.txt 里每个文件当作同一个媒体流的不同数据段,直接在封装层把它们串起来。它不做解码,不重新编码,只是重新封装。代价就是它有一个硬性前提:所有分片的编码参数必须完全一致,包括视频编码器、分辨率、帧率、像素格式、音频编码器、采样率、声道数等等。
如果分片来自同一个视频文件、由同一条 FFmpeg 分片命令产生,这个前提自然满足。这也是为什么我们选择先分片再合并,因为分片和合并是配对的。但如果分片来自不同来源,比如一个是 iPhone 拍的,一个是 Android 拍的,编码参数大概率不同,concat demuxer 强行合并就会出现花屏、音画不同步甚至直接失败。这时候只能回到 concat filter 方案,先重编码统一参数。
2.3 这次方案成立的两个前提
明确一下,这套方案能跑通,靠的是两个前提:
第一,分片必须同源同参。最简单的做法是让分片也用 FFmpeg 做,保证所有分片的编码信息完全一致。如果你手动切分没把握,最好的办法就是直接跑下面这条分片命令,让 FFmpeg 的 segment muxer 替你切。
第二,合并阶段必须用-c copy。这样是走 remux 路线,不做编解码。要是把这个参数去掉,FFmpeg 会默认重新编码,速度慢不说,画质还掉,等于把分片时省下的时间全赔进去了。
3. Java 进程调用 FFmpeg:容易被忽视的两个致命细节
3.1 ProcessBuilder 为什么比 Runtime.exec 稳
Java 调外部命令,最常见的就是Runtime.getRuntime().exec()和ProcessBuilder。老项目里Runtime.exec用得不少,但它有一个很坑的行为:接收一个完整命令字符串时,它会按空格做简单的 token 拆分。这在绝大多数命令里没问题,可一旦路径里带空格,比如C:\My Videos\part 001.mp4,就会被拆成多个参数,FFmpeg 直接报文件不存在。
ProcessBuilder接收的是一个List<String>,每个元素就是一个独立的参数,完全不经过 shell 解析。路径里有空格、中文、甚至括号都没关系,你传什么它就是什么。这是开发和线上稳定性上非常关键的一点,代码写出来是这样:
List<String> command = List.of( "/usr/local/bin/ffmpeg", "-i", inputPath, "-c", "copy", "-f", "segment", "-segment_time", "60", "-reset_timestamps", "1", outputPattern ); ProcessBuilder pb = new ProcessBuilder(command);因为不用 shell,也就不存在引号嵌套、反斜杠转义这些头疼问题。这是我强烈建议所有生产代码一律用ProcessBuilder的原因。
3.2 不读输出流,进程会“假死”
这是新人最容易踩的坑,而且一旦踩了,表现特别诡异:Java 程序卡在process.waitFor()上永不返回,CPU 却不高,看起来像死锁。
原因很简单:FFmpeg 会把大量日志和进度信息写到 stderr,如果 Java 这边不读取 stderr/stdout,操作系统管道缓冲区很快就会被写满,FFmpeg 写不进去就只能阻塞等待,整个进程就“挂”住了。你还在等它退出,它在等你读数据,两边互相等,完美死锁。
正确做法是在process.waitFor()之前,启动两个线程分别读取 stdout 和 stderr。我把这段封装成一个通用方法,项目里所有 FFmpeg 调用都走它:
private ProcessResult run(List<String> command) throws IOException, InterruptedException { ProcessBuilder pb = new ProcessBuilder(command); pb.redirectErrorStream(false); Process process = pb.start(); ByteArrayOutputStream stdoutBuffer = new ByteArrayOutputStream(); ByteArrayOutputStream stderrBuffer = new ByteArrayOutputStream(); Thread stdoutThread = new Thread(() -> drain(process.getInputStream(), stdoutBuffer)); Thread stderrThread = new Thread(() -> drain(process.getErrorStream(), stderrBuffer)); stdoutThread.start(); stderrThread.start(); boolean finished = process.waitFor(30, TimeUnit.MINUTES); if (!finished) { process.destroyForcibly(); throw new RuntimeException("FFmpeg 执行超时,已强制终止"); } stdoutThread.join(); stderrThread.join(); return new ProcessResult( process.exitValue(), stdoutBuffer.toString(StandardCharsets.UTF_8), stderrBuffer.toString(StandardCharsets.UTF_8) ); } private void drain(InputStream input, ByteArrayOutputStream output) { try (InputStream in = input) { byte[] buffer = new byte[4096]; int len; while ((len = in.read(buffer)) != -1) { output.write(buffer, 0, len); } } catch (IOException e) { // 进程被强制终止时,流关闭可能抛异常,这里吞掉即可 } }3.3 超时兜底与结果判定
视频处理任务可能跑很久,必须给进程设置一个合理的超时时间,否则一旦输入文件有问题,FFmpeg 可能长时间卡住占用 CPU 和内存。process.waitFor(timeout, TimeUnit)超时后返回 false,这时要调用process.destroyForcibly()强制杀掉进程。
不过这里有个细节:destroyForcibly()只是把 FFmpeg 主进程杀了,如果它还有子进程,理论上可能出现残留。实际验证下来,FFmpeg 执行转码任务时很少派生子进程,所以这个问题基本可以忽略。但如果你用 shell 包装了命令,比如通过/bin/sh -c "ffmpeg ...",那杀进程就只杀 shell,FFmpeg 可能变成孤儿进程继续跑。所以再次强调,直接用ProcessBuilder传完整参数列表,不要套 shell。
结果判定也很简单:退出码 0 表示成功,非 0 表示失败。FFmpeg 的错误细节都写在 stderr 里,失败时把 stderr 记录到日志里,人工排查基本够用。
4. 完整实现:分片、生成 list.txt、自动合并一把梭
4.1 分片命令的参数逐个拆解
分片这一步,核心命令只有一条:
ffmpeg -i input.mp4 -c copy -f segment \ -segment_time 60 -reset_timestamps 1 \ -segment_format mp4 "part_%03d.mp4"逐个参数说清楚为什么这么写:
-c copy:流复制,不做重新编码。分片只做封装层的分割,速度极快,画质零损失。-f segment:使用 segment muxer,也就是按时间切片的封装器。这是 FFmpeg 官方推荐的分片方式。-segment_time 60:每个分片的目标时长,单位秒。实际切出来的长度会受关键帧位置影响,后面单独讲。-reset_timestamps 1:让每个分片的起始时间戳都从 0 开始。这个参数特别重要,后面音画不同步那节还会提到。-segment_format mp4:指定分片的封装格式,这里显式写成 mp4,避免个别情况下 FFmpeg 猜错格式。"part_%03d.mp4":输出文件名的数字序号格式化,%03d会生成part_001.mp4、part_002.mp4这种三位补零的文件名。
三条补零的意义非同小可:如果文件名是part_1.mp4、part_10.mp4、part_2.mp4,按字符串字典序排序就会排成part_1、part_10、part_2,顺序全乱。补零到三位以上,字典序和自然顺序就一致了。
4.2 list.txt 的编码与路径坑
分片完成后,自动生成 list.txt。这个文件的格式非常“严格”,每行指定一个文件,最标准的写法是:
file 'part_001.mp4' file 'part_002.mp4' file 'part_003.mp4'注意几点:
第一,文件路径必须用单引号包起来,路径里有空格也能正常解析。
第二,文件名里如果本身包含单引号,FFmpeg 的 concat demuxer 有自己的一套转义规则:单引号要写成'\''这种形式。比如it's.mp4,list.txt 里要写成file 'it'\''s.mp4'。实际开发中我不建议你去处理这种极端情况,而是在分片入口就把文件名里的单引号替换掉,从源头规避。
第三,编码必须统一。Java 那边写 list.txt 时,明确指定为 UTF-8:
Files.writeString(Paths.get(listPath), content, StandardCharsets.UTF_8);如果用了系统默认编码,在 Windows 上很容易出现 GBK 乱码,FFmpeg 解析路径失败,合并阶段直接报No such file or directory。
第四,绝对路径和相对路径的问题。-safe 0参数会允许 FFmpeg 处理绝对路径以及包含特殊字符的路径,所以我们线上通常直接写绝对路径,省去切换工作目录的麻烦。但要注意 Windows 风格的路径,比如D:\videos\part_001.mp4,建议统一转成正斜杠D:/videos/part_001.mp4,兼容性更好。
4.3 合并命令与最终验证
list.txt 生成后,合并命令非常简单:
ffmpeg -f concat -safe 0 -i list.txt -c copy -movflags +faststart output.mp4-f concat:指定 concat demuxer。-safe 0:允许不安全路径。如果不加这个参数,FFmpeg 默认拒绝绝对路径和特殊字符路径,很多奇怪的报错都源于此。-c copy:流复制,不重编码。-movflags +faststart:把 MP4 的 moov box 挪到文件头部,这样输出文件在网页端可以边下载边播放,体验更好。
合并完成不等于万事大吉,最好再用ffprobe验证一下输出文件:
ffprobe -v error -show_entries format=duration -show_entries stream=codec_type,codec_name output.mp4确认时长符合预期、音视频流完整、编码信息正常,再往下走业务逻辑。这一步在自动化流水线里非常关键,能提前拦截掉大部分损坏文件。
4.4 可直接复制的 Java 工具类
下面是一个简化但能直接用的工具类,包含分片、生成 list.txt、合并三步:
public class FfmpegVideoMerger { private static final String FFMPEG = "/usr/local/bin/ffmpeg"; public List<String> split(String inputPath, String outputDir, long segmentSeconds) throws IOException, InterruptedException { Files.createDirectories(Paths.get(outputDir)); String outputPattern = Paths.get(outputDir, "part_%03d.mp4").toString(); List<String> cmd = List.of( FFMPEG, "-i", inputPath, "-c", "copy", "-f", "segment", "-segment_time", String.valueOf(segmentSeconds), "-reset_timestamps", "1", "-segment_format", "mp4", outputPattern ); ProcessResult result = run(cmd); if (result.exitCode() != 0) { throw new IllegalStateException("分片失败: " + result.stderr()); } try (Stream<Path> paths = Files.list(Paths.get(outputDir))) { return paths .filter(p -> p.getFileName().toString().matches("part_\\d{3}\\.mp4")) .sorted() .map(p -> p.toString()) .collect(Collectors.toList()); } } public String writeListFile(List<String> partPaths) throws IOException { StringBuilder sb = new StringBuilder(); for (String path : partPaths) { sb.append("file '").append(path.replace("\\", "/").replace("'", "'\\''")).append("'\n"); } Path list = Files.createTempFile("concat_", ".txt"); Files.writeString(list, sb.toString(), StandardCharsets.UTF_8); return list.toString(); } public void merge(String listFilePath, String outputPath) throws IOException, InterruptedException { List<String> cmd = List.of( FFMPEG, "-f", "concat", "-safe", "0", "-i", listFilePath, "-c", "copy", "-movflags", "+faststart", outputPath ); ProcessResult result = run(cmd); if (result.exitCode() != 0) { throw new IllegalStateException("合并失败: " + result.stderr()); } } // run 方法复用上一节的实现 }这个类的问题是谁用谁知道,真放到生产里,还需要加上删除临时文件的清理逻辑、分片超时配置、异常重试策略等。但核心思路已经完整了。
5. 实战踩坑记录:从乱码到音画不同步的排查链路
5.1 分片时长不受控:关键帧决定切割点
分片命令里明明写了-segment_time 60,但切出来的每个分片时长参差不齐,有的 58 秒,有的 63 秒,这是正常的。原因是-c copy模式下,FFmpeg 不能在任意帧位置直接切文件,只能在关键帧(I 帧)处切割。如果源视频的关键帧间隔是 5 秒,FFmpeg 会找离目标切割点最近的关键帧下手,时长自然有浮动。
大部分视频这点偏差无所谓。但有一种极端情况要注意:部分录屏软件、摄像头保存的视频,关键帧间隔特别长(比如 10 秒甚至更多),分片时长可能和预期差非常多。如果业务上对分片时长有硬性要求,有两个办法:
- 分片前先转码,用
-force_key_frames "expr:gte(t,n_forced*60)"强制每 60 秒一个关键帧,再分片。 - 用
-segment_time_delta 5允许切割点向最近关键帧偏移一段距离,缓解临界处反复横跳的问题。
实际业务里,如果只是做并行处理和合并,时长轻微浮动完全不用管,合并回去没有任何影响。
5.2 合并后音画不同步:时间戳的锅
音频和视频不同步是最让人头疼的问题,我第一次遇到时排查了半天才发现是时间戳的问题。症状是:合并后的视频前面正常,越往后声音滞后越明显,或者画面跳帧。
两种常见成因:
第一种,分片时没有-reset_timestamps 1。分片保留的是源视频的时间戳,第二个分片的起始时间戳不是 0,而是从 60 秒延续下去。concat demuxer 合并时虽然会尝试修正,但遇到某些封装情况会产生微小的音频/视频时间戳偏差,尤其分片多了之后偏差累积,最后就变成明显的音画不同步。解法就是分片命令里加-reset_timestamps 1,让每片从 0 开始。
第二种,源视频本身时间戳就有问题。比如某些视频的音频流和视频流起始时间戳不一致,合并后问题放大。这种情况可以在合并命令里加-fflags +genpts,让 FFmpeg 重新生成 PTS,很多小问题都能被修复:
ffmpeg -f concat -safe 0 -i list.txt -c copy -fflags +genpts -movflags +faststart output.mp4如果加了genpts还是不同步,那多半是分片文件本身有问题,老老实实走 concat filter 重编码兜底。
5.3 中文路径和特殊字符:list.txt 的转义规则
中文路径在 Windows 上特别容易出问题。一个典型报错:
[concat @ 0x...] Impossible to open 'D:\视频\part_001.mp4'这类问题通常是编码和路径格式两件事叠加导致的。第一,list.txt 必须用 UTF-8 写入;第二,路径里的反斜杠最好转成正斜杠,也就是我在工具类里写的那行replace("\\", "/");第三,确认合并命令里带了-safe 0,否则 FFmpeg 会拒绝含特殊字符的路径。
关于路径里的特殊字符,这里要给一个非常务实的建议:尽量不要在业务路径里使用空格和单引号。虽然技术上可以转义处理,但这些字符在日志打印、命令行拼接、跨平台传输时都会制造额外麻烦。我自己在项目里统一建了一个临时文件目录,生成的临时文件全用 ASCII 命名,彻底绕开这堆问题。
5.4 一套走出迷宫的自检链路
踩过几轮坑之后,我摸索出一套排查链路,每次合并失败时按这个顺序走,定位问题基本不超过十分钟:
- 手动跑一遍分片命令,确认分片能正常生成。连分片都失败,问题大概率在输入文件本身(损坏、编码异常、路径不存在)。
- 人工检查 list.txt 内容。重点看:编码是不是 UTF-8、路径是否存在、有没有空格或单引号、文件名顺序是否正确。
- 挑两个分片单独合并测试。如果有问题的分片能被排查出来,说明其他分片没问题,问题集中在那一个分片的编码参数上。
- 用 ffprobe 对比分片编码信息。看分片的编码器、分辨率、帧率、采样率是否完全一致,不一致就说明分片来源有问题。
- 查看 FFmpeg stderr 日志。大多数错误信息已经写得很直白,比如
No such file or directory、Invalid data、Non-monotonous DTS,直接按关键词定位。 - 最后兜底:切到 concat filter 重编码方案。如果时间紧、对画质要求不高,先让业务跑通,再回头优化。
这套链路在我这边的生产环境里用得很顺,基本能覆盖 90% 的合并异常。
最后再分享一个实用小技巧:跑分片命令之前,可以先跑一条
ffprobe -v error -show_entries stream=codec_name,width,height,avg_frame_rate -of default=noprint_wrappers=1 input.mp4把输入文件的编码信息存进日志里,等合并出问题时不用重新猜,直接拿日志里的原始信息对比分片文件,能省不少力气。视频处理这个东西,数据和日志永远比记忆可靠。