
做视频处理的时候“把一个大视频切成若干小段处理完再拼回去”这个需求真的非常常见。我最早遇到是在一个在线教育项目里用户上传的课程视频动辄好几个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]concatn2:v1:a1 \ -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接收的是一个ListString每个元素就是一个独立的参数完全不经过 shell 解析。路径里有空格、中文、甚至括号都没关系你传什么它就是什么。这是开发和线上稳定性上非常关键的一点代码写出来是这样ListString 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(ListString 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 ...那杀进程就只杀 shellFFmpeg 可能变成孤儿进程继续跑。所以再次强调直接用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 有自己的一套转义规则单引号要写成\这种形式。比如its.mp4list.txt 里要写成file it\s.mp4。实际开发中我不建议你去处理这种极端情况而是在分片入口就把文件名里的单引号替换掉从源头规避。第三编码必须统一。Java 那边写 list.txt 时明确指定为 UTF-8Files.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 formatduration -show_entries streamcodec_type,codec_name output.mp4确认时长符合预期、音视频流完整、编码信息正常再往下走业务逻辑。这一步在自动化流水线里非常关键能提前拦截掉大部分损坏文件。4.4 可直接复制的 Java 工具类下面是一个简化但能直接用的工具类包含分片、生成 list.txt、合并三步public class FfmpegVideoMerger { private static final String FFMPEG /usr/local/bin/ffmpeg; public ListString split(String inputPath, String outputDir, long segmentSeconds) throws IOException, InterruptedException { Files.createDirectories(Paths.get(outputDir)); String outputPattern Paths.get(outputDir, part_%03d.mp4).toString(); ListString 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 (StreamPath 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(ListString 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 { ListString 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 streamcodec_name,width,height,avg_frame_rate -of defaultnoprint_wrappers1 input.mp4把输入文件的编码信息存进日志里等合并出问题时不用重新猜直接拿日志里的原始信息对比分片文件能省不少力气。视频处理这个东西数据和日志永远比记忆可靠。