1. 为什么要在Java里折腾FFmpeg视频处理这件事,放在五年前我大概率会直接甩给前端或者专门的转码服务去做。但这两年明显感觉到,业务侧对"在Java服务里直接处理视频"的需求越来越硬——用户上传一个手机拍的MOV,后台得转成MP4才能保证全平台播放;运营给了一段背景音乐和一段无声视频,要求合成一条带BGM的推广片;内容审核环节需要从视频里抽帧截图做封面;甚至有些内部工具还要求能直接预览播放。这些需求如果每个都去对接第三方云服务,成本和延迟都扛不住,尤其是私有化部署的场景,根本不允许把视频往外传。FFmpeg就是干这个的。它是一套开源的音视频处理工具集,命令行能力极强,几乎覆盖了格式转换、编解码、滤镜、合并、抽帧、推流等所有你能想到的操作。Java本身没有原生的视频处理能力,但Java可以通过ProcessBuilder或者Runtime.exec去调用FFmpeg的可执行文件,把命令行的能力"借"过来用。这条路子听起来有点土,但实测下来是最稳、最可控、依赖最少的方案。这篇文章面向的是有一定Java基础、需要在服务端做视频处理的开发者。我会把格式转换、音视频合并、播放、截图这四个核心场景全部拆开讲,包括FFmpeg的安装配置、Java调用封装、参数选择逻辑、踩过的坑和排查方法。看完你应该能直接把这套东西搬进自己的项目里跑起来。2. 环境准备与FFmpeg基础认知2.1 FFmpeg的安装与验证FFmpeg不是Java库,它是一个独立的可执行程序。所以第一步是在运行Java服务的机器上把它装好。Linux下最省事的方式是用包管理器,比如apt install ffmpeg或者yum install ffmpeg,但要注意发行版自带的版本可能偏老,某些新编码器不支持。我一般推荐去官网下载静态编译包,解压后把ffmpeg、ffprobe、ffplay三个二进制文件放到/usr/local/bin下,然后chmod +x赋予执行权限。Windows下就是下载压缩包,解压到某个目录,比如D:\tools\ffmpeg,然后把D:\tools\ffmpeg\bin加到系统环境变量PATH里。装完之后在终端敲ffmpeg -version,能打印出版本号和编译配置就说明成功了。注意:ffprobe是FFmpeg套件里专门用来探测媒体信息的工具,后面获取视频时长、分辨率、编码格式都要靠它,千万别只装ffmpeg一个文件。2.2 Java调用FFmpeg的两种方式Java调用外部程序主要有两条路。一条是Runtime.getRuntime().exec(),另一条是ProcessBuilder。我强烈建议用ProcessBuilder,原因有三个:第一,它可以把标准错误流和标准输出流合并,避免缓冲区满了导致进程卡死;第二,它支持设置工作目录和环境变量;第三,它的参数传递是列表形式,不用自己拼字符串,能规避路径里有空格导致的解析错误。ProcessBuilder pb = new ProcessBuilder(); pb.command("ffmpeg", "-i", inputPath, outputPath); pb.redirectErrorStream(true); Process process = pb.start();这里有个关键点:redirectErrorStream(true)必须开。FFmpeg的日志默认走stderr,如果你不合并也不读取,当输出内容超过操作系统管道缓冲区大小时,FFmpeg进程会阻塞在写日志上,Java这边waitFor()就永远等不到结束,表现为"程序卡死"。这个坑我踩过不止一次。2.3 封装一个通用的命令执行工具每个功能都写一遍ProcessBuilder太啰嗦,我一般会封装一个工具方法,负责执行命令、读取输出、等待结束、返回退出码。public static int execute(List<String> command, long timeoutSeconds) throws Exception { ProcessBuilder pb = new ProcessBuilder(command); pb.redirectErrorStream(true); Process process = pb.start(); StringBuilder output = new StringBuilder(); try (BufferedReader reader = new BufferedReader( new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { output.append(line).append("\n"); } } boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS); if (!finished) { process.destroyForcibly(); throw new RuntimeException("FFmpeg执行超时"); } return process.exitValue(); }退出码为0表示成功,非0就是失败,失败时把output打出来基本能定位到原因。超时机制一定要加,视频处理遇到损坏文件时FFmpeg可能长时间无响应,没有超时保护会把线程池拖垮。3. 视频格式转换的核心实现3.1 格式转换到底在转什么很多人以为格式转换就是改个后缀名,其实远不止。一个视频文件包含容器格式和编码格式两个层面。容器是"盒子",比如MP4、MKV、AVI,决定了文件怎么组织音视频轨道、字幕、元数据;编码是"盒子里的东西怎么压缩的",比如H.264、H.265、VP9。所谓格式转换,可能是换容器不换编码,也可能是容器和编码一起换。如果只是换容器,比如MKV转MP4,而内部编码本来就是H.264+AAC,那可以直接用流复制,速度极快,几乎不损失画质:ffmpeg -i input.mkv -c copy output.mp4-c copy的意思就是所有流原样复制,不做重新编码。这个操作几秒钟就能完成几个G的文件。但如果源视频是H.265编码,目标设备只支持H.264,那就必须重新编码:ffmpeg -i input.mp4 -c:v libx264 -c:a aac -preset medium -crf 23 output.mp4这里的参数就有讲究了。-c:v libx264指定视频编码器,-c:a aac指定音频编码器。-preset控制编码速度和压缩率的平衡,可选ultrafast到veryslow,越慢压缩率越高但耗时越长。-crf是恒定质量因子,范围0到51,数值越小画质越好文件越大,23是默认值,18基本视觉无损,28就明显糊了。3.2 Java中的格式转换封装把上面的逻辑封装成Java方法,核心是判断是否需要重新编码。我的做法是先用ffprobe探测源文件的编码格式,如果和目标容器兼容就用-c copy,否则走重新编码。public static void convert(String input, String output, String targetFormat) throws Exception { String videoCodec = probeCodec(input, "v"); String audioCodec = probeCodec(input, "a"); List<String> cmd = new ArrayList<>(); cmd.add("ffmpeg"); cmd.add("-i"); cmd.add(input); if (isCompatible(videoCodec, targetFormat) && isCompatible(audioCodec, targetFormat)) { cmd.add("-c"); cmd.add("copy"); } else { cmd.add("-c:v"); cmd.add("libx264"); cmd.add("-c:a"); cmd.add("aac"); cmd.add("-preset"); cmd.add("medium"); cmd.add("-crf"); cmd.add("23"); } cmd.add("-y"); cmd.add(output); int code = execute(cmd, 600); if (code != 0) { throw new RuntimeException("格式转换失败"); } }-y参数表示输出文件已存在时直接覆盖,不加的话FFmpeg会交互式询问,在服务端环境下会直接卡住。3.3 转换参数的取舍经验关于-preset,我实测下来medium是性价比最高的档位。ultrafast虽然快,但生成的文件体积可能是medium的两倍还多,对于存储敏感的场景不划算。slow以上提升有限但耗时翻倍,除非是离线批处理任务,否则没必要。-crf的选择要看业务。用户头像、短视频封面这类,23足够;如果是需要二次剪辑的素材,建议18;纯归档存储可以放到28。还有一个参数-movflags +faststart,对于MP4输出强烈建议加上,它会把元数据移到文件头部,让视频支持边下边播,否则必须整个文件下载完才能播放。实操心得:转换大文件时,FFmpeg默认会输出大量进度日志。如果Java端不读取,进程会卡死。我的做法是在读取线程里只保留最后几行,或者用-loglevel error把日志级别调高,只输出错误信息。4. 音视频合并的完整方案4.1 合并场景的两种类型音视频合并其实分两种情况。第一种是给无声视频配上一段音频,比如给监控录像加背景音乐;第二种是把多个视频片段拼接成一条长视频。这两种在FFmpeg里的处理方式完全不同,很多人会搞混。给视频配音频,本质是替换或添加音频轨道:ffmpeg -i video.mp4 -i audio.mp3 -c:v copy -c:a aac -shortest output.mp4-shortest表示以较短的流为准结束输出,避免音频比视频长导致最后一段黑屏。-c:v copy保留原视频流不重新编码,只对音频编码,速度快。多视频拼接则要用concat协议。先创建一个文本文件列出所有片段:file 'part1.mp4' file 'part2.mp4' file 'part3.mp4'然后执行:ffmpeg -f concat -safe 0 -i list.txt -c copy output.mp4-safe 0允许使用绝对路径。这里有个硬性要求:所有片段的编码格式、分辨率、帧率必须完全一致,否则拼接出来的视频会出现音画不同步或者花屏。如果格式不统一,得先统一转码再拼接。4.2 Java实现音视频合并先看配音频的实现:public static void mergeAudioVideo(String video, String audio, String output) throws Exception { List<String> cmd = Arrays.asList( "ffmpeg", "-i", video, "-i", audio, "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest", "-y", output ); int code = execute(cmd, 600); if (code != 0) { throw new RuntimeException("音视频合并失败"); } }-b:a 192k指定音频码率,192kbps对于大多数场景够用了,追求音质可以上320k。多视频拼接的Java实现稍微复杂一点,因为要先写临时列表文件:public static void concatVideos(List<String> inputs, String output) throws Exception { Path listFile = Files.createTempFile("concat", ".txt"); StringBuilder sb = new StringBuilder(); for (String input : inputs) { sb.append("file '").append(input.replace("'", "'\\''")).append("'\n"); } Files.write(listFile, sb.toString().getBytes(StandardCharsets.UTF_8)); List<String> cmd = Arrays.asList( "ffmpeg", "-f", "concat", "-safe", "0", "-i", listFile.toString(), "-c", "copy", "-y", output ); int code = execute(cmd, 1200); Files.deleteIfExists(listFile); if (code != 0) { throw new RuntimeException("视频拼接失败"); } }路径里的单引号要转义,否则列表文件解析会出错。临时文件用完记得删,不然跑久了磁盘会被塞满。4.3 合并时的常见坑第一个坑是音视频时长不一致。如果音频比视频长,不加-shortest的话输出视频会以音频长度为准,视频结束后是黑屏。如果视频比音频长,音频结束后是静音。业务上一般要求以视频为准,所以-shortest基本是必加的。第二个坑是拼接时分辨率不一致。比如手机横屏拍的1920x1080和竖屏拍的1080x1920混在一起,直接concat会报错或者输出异常。解决办法是先用scale滤镜统一分辨率:ffmpeg -i input.mp4 -vf "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2" output.mp4这个命令会把视频等比缩放到1280x720的画布内,不足的部分用黑边填充,保证所有片段尺寸一致。注意:force_original_aspect_ratio=decrease保证不拉伸变形,pad负责补黑边。如果直接scale=1280:720会强行拉伸,人物会变形。5. 视频截图与封面抽取5.1 截图的时机选择视频截图最核心的参数是"截哪一帧"。最简单的做法是截第一帧:ffmpeg -i input.mp4 -ss 00:00:01 -vframes 1 output.jpg-ss指定时间点,-vframes 1表示只输出一帧。这里有个性能细节:-ss放在-i前面是快速定位,FFmpeg会直接跳到关键帧附近,速度快但可能不够精确;放在-i后面是精确解码,会从文件头解码到指定时间,慢但帧准确。对于封面抽取,快速定位就够了。如果要做视频缩略图墙,可以按固定间隔抽帧:ffmpeg -i input.mp4 -vf "fps=1/10" thumb_%03d.jpgfps=1/10表示每10秒抽一帧,%03d是序号占位符,生成thumb_001.jpg、thumb_002.jpg这样。5.2 Java实现截图功能public static void captureFrame(String video, String output, String timePoint) throws Exception { List<String> cmd = Arrays.asList( "ffmpeg", "-ss", timePoint, "-i", video, "-vframes", "1", "-q:v", "2", "-y", output ); int code = execute(cmd, 120); if (code != 0) { throw new RuntimeException("视频截图失败"); } }-q:v 2控制JPEG质量,范围1到31,数值越小质量越高,2基本是高质量。如果要生成PNG,把输出后缀改成.png即可,PNG是无损的但文件更大。5.3 截图功能的进阶玩法有时候业务需要"智能封面",也就是避开片头黑屏和片尾字幕,从中间截取。我的做法是先用ffprobe拿到视频总时长,然后取30%位置作为截图点:public static double getDuration(String video) throws Exception { List<String> cmd = Arrays.asList( "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", video ); ProcessBuilder pb = new ProcessBuilder(cmd); pb.redirectErrorStream(true); Process process = pb.start(); try (BufferedReader reader = new BufferedReader( new InputStreamReader(process.getInputStream()))) { String line = reader.readLine(); return Double.parseDouble(line.trim()); } }拿到时长后乘以0.3就是截图时间点。这个比例是我试出来的经验值,太靠前容易截到黑屏,太靠后容易截到片尾。实操心得:截图输出路径的目录必须提前创建好,FFmpeg不会自动建目录。如果目录不存在,它会直接报错退出。我一般会在调用前用Files.createDirectories确保目录存在。6. 视频播放能力的实现思路6.1 服务端"播放"的本质严格来说,Java服务端并不负责"播放"视频,播放是客户端的事。但服务端需要做的是让视频能够被播放,这涉及两个层面:一是转码成浏览器或播放器支持的格式,二是支持HTTP Range请求实现拖动进度条。浏览器原生支持最好的是MP4(H.264+AAC)和WebM。如果用户上传的是AVI、RMVB这些老格式,必须先转成MP4。转码时加上-movflags +faststart,让元数据前置,这样浏览器不用等整个文件下载完就能开始播放。6.2 支持拖动进度的关键视频能播放但拖不动进度条,这是很多人遇到的问题。根源在于服务端没有正确处理HTTP的Range请求。播放器拖动时会发送Range: bytes=1000-2000这样的请求头,服务端必须返回206状态码和对应的字节片段。如果用Spring Boot,可以用ResourceHttpRequestHandler或者自己写Controller处理:@GetMapping("/video/{id}") public ResponseEntity<Resource> play(@PathVariable String id, @RequestHeader(value = "Range", required = false) String range) throws Exception { File file = getVideoFile(id); long length = file.length(); if (range == null) { return ResponseEntity.ok() .contentType(MediaType.parseMediaType("video/mp4")) .contentLength(length) .body(new FileSystemResource(file)); } String[] ranges = range.replace("bytes=", "").split("-"); long start = Long.parseLong(ranges[0]); long end = ranges.length > 1 ? Long.parseLong(ranges[1]) : length - 1; long contentLength = end - start + 1; InputStream is = new RandomAccessFile(file, "r").getChannel() .map(FileChannel.MapMode.READ_ONLY, start, contentLength) .newInputStream(); return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) .contentType(MediaType.parseMediaType("video/mp4")) .header("Accept-Ranges", "bytes") .header("Content-Range", "bytes " + start + "-" + end + "/" + length) .contentLength(contentLength) .body(new InputStreamResource(is)); }Accept-Ranges: bytes这个响应头必须返回,否则播放器不知道服务端支持分段请求,进度条就是灰的。6.3 播放相关的转码策略如果视频需要加密或者加水印,就不能直接返回原文件,得实时转码。实时转码的性能开销很大,一个1080P的视频转码能吃掉好几个CPU核心。我的建议是异步预转码,用户上传后立即触发转码任务,转好了再提供播放地址,而不是在播放请求里同步转码。如果确实需要实时处理,可以用FFmpeg的管道输出,把转码结果直接写到HTTP响应流里:ffmpeg -i input.mp4 -f mp4 -movflags frag_keyframe+empty_moov -pipe:1frag_keyframe+empty_moov生成的是碎片化MP4,适合流式传输。Java端读取进程的输出流,边读边写到HttpServletResponse的OutputStream。注意:实时转码场景下,客户端断开连接后必须主动销毁FFmpeg进程,否则会留下僵尸进程持续消耗CPU。可以在finally块里调用process.destroyForcibly()。7. 常见问题与排查技巧实录7.1 问题速查表问题现象可能原因排查方法解决方案进程卡死不返回未读取输出流导致缓冲区满检查是否调用redirectErrorStream并读取合并流并持续读取转换后无声音音频编码不兼容用ffprobe查看音频编码指定-c:a aac重新编码拼接后音画不同步片段帧率不一致检查各片段帧率统一转码后再拼接截图是黑屏截到了片头黑帧换时间点重试取视频30%位置截图播放拖不动进度未支持Range请求抓包看是否返回206实现分段响应中文路径报错编码问题查看FFmpeg错误输出统一用UTF-8并避免中文路径输出文件0字节磁盘满或权限不足检查磁盘和目录权限清理空间或改权限7.2 几个容易被忽略的细节第一个是路径问题。FFmpeg对中文路径和空格路径的处理在不同操作系统上表现不一致。我的做法是统一把输入输出文件放到临时目录,用UUID命名,处理完再移动到目标位置。这样既避免了编码问题,也防止了文件名冲突。第二个是并发控制。FFmpeg是CPU密集型任务,一个转码任务可能占满多个核心。如果Java服务同时处理多个转码请求,机器会直接卡死。我一般会用信号量限制并发数,比如Semaphore(2),同时最多跑两个转码任务,超出的排队等待。private static final Semaphore TRANSCODE_SEMAPHORE = new Semaphore(2); public static void transcodeWithLimit(String input, String output) throws Exception { TRANSCODE_SEMAPHORE.acquire(); try { convert(input, output, "mp4"); } finally { TRANSCODE_SEMAPHORE.release(); } }第三个是临时文件清理。转码过程中会产生大量临时文件,如果异常退出没有清理,磁盘很快会被占满。建议用try-finally确保临时文件被删除,或者定期跑清理任务。7.3 性能优化的几个方向如果转码量很大,可以考虑用硬件加速。NVIDIA显卡可以用h264_nvenc编码器,Intel核显可以用h264_qsv,速度比纯CPU快好几倍。但硬件加速的画质通常略逊于libx264,需要根据业务权衡。ffmpeg -i input.mp4 -c:v h264_nvenc -preset p4 -cq 23 output.mp4-cq是NVENC的质量参数,类似CRF。-preset p4是NVENC的预设,p1最快p7最慢。另一个方向是分片并行。长视频可以切成多个片段并行转码,最后拼接。但这样会引入片段边界的问题,实现复杂度高,除非是超长视频否则不建议。实操心得:转码任务的日志一定要落盘。FFmpeg的报错信息很详细,但如果不保存,出问题时只能看到"退出码1",根本没法排查。我一般会把每个任务的完整输出写到日志文件,按任务ID命名,保留7天。8. 一套可复用的封装设计把上面所有功能整合起来,我一般会设计一个FfmpegService类,对外暴露几个高层方法:convert、merge、capture、probe。内部统一走命令执行工具,统一处理超时、日志、并发限制。配置项比如FFmpeg路径、超时时间、并发数放到配置文件里,不同环境可以调整。FFmpeg路径建议做成可配置的,因为开发机可能是Windows,服务器是Linux,硬编码路径换环境就挂。用@Value("${ffmpeg.path:ffmpeg}")这种形式,默认值ffmpeg表示从PATH里找,特殊环境再覆盖。还有一点,FFmpeg的版本差异会导致某些参数不兼容。比如老版本不支持某些新编码器,新版本废弃了某些旧参数。建议在服务启动时执行一次ffmpeg -version,把版本号打到日志里,出问题时能快速定位是不是版本问题。这套东西我在几个项目里都用过,从日处理几十个视频的小工具到日处理上万条的批处理服务都跑得动。核心就是三点:命令执行要防卡死、并发要限流、临时文件要清理。把这三点做好,剩下的就是按业务需求调参数了。
1. 为什么要在Java里折腾FFmpeg视频处理这件事,放在五年前我大概率会直接甩给前端或者专门的转码服务去做。但这两年明显感觉到,业务侧对"在Java服务里直接处理视频"的需求越来越硬——用户上传一个手机拍的MOV,后台得转成MP4才能保证全平台播放;运营给了一段背景音乐和一段无声视频,要求合成一条带BGM的推广片;内容审核环节需要从视频里抽帧截图做封面;甚至有些内部工具还要求能直接预览播放。这些需求如果每个都去对接第三方云服务,成本和延迟都扛不住,尤其是私有化部署的场景,根本不允许把视频往外传。FFmpeg就是干这个的。它是一套开源的音视频处理工具集,命令行能力极强,几乎覆盖了格式转换、编解码、滤镜、合并、抽帧、推流等所有你能想到的操作。Java本身没有原生的视频处理能力,但Java可以通过ProcessBuilder或者Runtime.exec去调用FFmpeg的可执行文件,把命令行的能力"借"过来用。这条路子听起来有点土,但实测下来是最稳、最可控、依赖最少的方案。这篇文章面向的是有一定Java基础、需要在服务端做视频处理的开发者。我会把格式转换、音视频合并、播放、截图这四个核心场景全部拆开讲,包括FFmpeg的安装配置、Java调用封装、参数选择逻辑、踩过的坑和排查方法。看完你应该能直接把这套东西搬进自己的项目里跑起来。2. 环境准备与FFmpeg基础认知2.1 FFmpeg的安装与验证FFmpeg不是Java库,它是一个独立的可执行程序。所以第一步是在运行Java服务的机器上把它装好。Linux下最省事的方式是用包管理器,比如apt install ffmpeg或者yum install ffmpeg,但要注意发行版自带的版本可能偏老,某些新编码器不支持。我一般推荐去官网下载静态编译包,解压后把ffmpeg、ffprobe、ffplay三个二进制文件放到/usr/local/bin下,然后chmod +x赋予执行权限。Windows下就是下载压缩包,解压到某个目录,比如D:\tools\ffmpeg,然后把D:\tools\ffmpeg\bin加到系统环境变量PATH里。装完之后在终端敲ffmpeg -version,能打印出版本号和编译配置就说明成功了。注意:ffprobe是FFmpeg套件里专门用来探测媒体信息的工具,后面获取视频时长、分辨率、编码格式都要靠它,千万别只装ffmpeg一个文件。2.2 Java调用FFmpeg的两种方式Java调用外部程序主要有两条路。一条是Runtime.getRuntime().exec(),另一条是ProcessBuilder。我强烈建议用ProcessBuilder,原因有三个:第一,它可以把标准错误流和标准输出流合并,避免缓冲区满了导致进程卡死;第二,它支持设置工作目录和环境变量;第三,它的参数传递是列表形式,不用自己拼字符串,能规避路径里有空格导致的解析错误。ProcessBuilder pb = new ProcessBuilder(); pb.command("ffmpeg", "-i", inputPath, outputPath); pb.redirectErrorStream(true); Process process = pb.start();这里有个关键点:redirectErrorStream(true)必须开。FFmpeg的日志默认走stderr,如果你不合并也不读取,当输出内容超过操作系统管道缓冲区大小时,FFmpeg进程会阻塞在写日志上,Java这边waitFor()就永远等不到结束,表现为"程序卡死"。这个坑我踩过不止一次。2.3 封装一个通用的命令执行工具每个功能都写一遍ProcessBuilder太啰嗦,我一般会封装一个工具方法,负责执行命令、读取输出、等待结束、返回退出码。public static int execute(List<String> command, long timeoutSeconds) throws Exception { ProcessBuilder pb = new ProcessBuilder(command); pb.redirectErrorStream(true); Process process = pb.start(); StringBuilder output = new StringBuilder(); try (BufferedReader reader = new BufferedReader( new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = reader.readLine()) != null) { output.append(line).append("\n"); } } boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS); if (!finished) { process.destroyForcibly(); throw new RuntimeException("FFmpeg执行超时"); } return process.exitValue(); }退出码为0表示成功,非0就是失败,失败时把output打出来基本能定位到原因。超时机制一定要加,视频处理遇到损坏文件时FFmpeg可能长时间无响应,没有超时保护会把线程池拖垮。3. 视频格式转换的核心实现3.1 格式转换到底在转什么很多人以为格式转换就是改个后缀名,其实远不止。一个视频文件包含容器格式和编码格式两个层面。容器是"盒子",比如MP4、MKV、AVI,决定了文件怎么组织音视频轨道、字幕、元数据;编码是"盒子里的东西怎么压缩的",比如H.264、H.265、VP9。所谓格式转换,可能是换容器不换编码,也可能是容器和编码一起换。如果只是换容器,比如MKV转MP4,而内部编码本来就是H.264+AAC,那可以直接用流复制,速度极快,几乎不损失画质:ffmpeg -i input.mkv -c copy output.mp4-c copy的意思就是所有流原样复制,不做重新编码。这个操作几秒钟就能完成几个G的文件。但如果源视频是H.265编码,目标设备只支持H.264,那就必须重新编码:ffmpeg -i input.mp4 -c:v libx264 -c:a aac -preset medium -crf 23 output.mp4这里的参数就有讲究了。-c:v libx264指定视频编码器,-c:a aac指定音频编码器。-preset控制编码速度和压缩率的平衡,可选ultrafast到veryslow,越慢压缩率越高但耗时越长。-crf是恒定质量因子,范围0到51,数值越小画质越好文件越大,23是默认值,18基本视觉无损,28就明显糊了。3.2 Java中的格式转换封装把上面的逻辑封装成Java方法,核心是判断是否需要重新编码。我的做法是先用ffprobe探测源文件的编码格式,如果和目标容器兼容就用-c copy,否则走重新编码。public static void convert(String input, String output, String targetFormat) throws Exception { String videoCodec = probeCodec(input, "v"); String audioCodec = probeCodec(input, "a"); List<String> cmd = new ArrayList<>(); cmd.add("ffmpeg"); cmd.add("-i"); cmd.add(input); if (isCompatible(videoCodec, targetFormat) && isCompatible(audioCodec, targetFormat)) { cmd.add("-c"); cmd.add("copy"); } else { cmd.add("-c:v"); cmd.add("libx264"); cmd.add("-c:a"); cmd.add("aac"); cmd.add("-preset"); cmd.add("medium"); cmd.add("-crf"); cmd.add("23"); } cmd.add("-y"); cmd.add(output); int code = execute(cmd, 600); if (code != 0) { throw new RuntimeException("格式转换失败"); } }-y参数表示输出文件已存在时直接覆盖,不加的话FFmpeg会交互式询问,在服务端环境下会直接卡住。3.3 转换参数的取舍经验关于-preset,我实测下来medium是性价比最高的档位。ultrafast虽然快,但生成的文件体积可能是medium的两倍还多,对于存储敏感的场景不划算。slow以上提升有限但耗时翻倍,除非是离线批处理任务,否则没必要。-crf的选择要看业务。用户头像、短视频封面这类,23足够;如果是需要二次剪辑的素材,建议18;纯归档存储可以放到28。还有一个参数-movflags +faststart,对于MP4输出强烈建议加上,它会把元数据移到文件头部,让视频支持边下边播,否则必须整个文件下载完才能播放。实操心得:转换大文件时,FFmpeg默认会输出大量进度日志。如果Java端不读取,进程会卡死。我的做法是在读取线程里只保留最后几行,或者用-loglevel error把日志级别调高,只输出错误信息。4. 音视频合并的完整方案4.1 合并场景的两种类型音视频合并其实分两种情况。第一种是给无声视频配上一段音频,比如给监控录像加背景音乐;第二种是把多个视频片段拼接成一条长视频。这两种在FFmpeg里的处理方式完全不同,很多人会搞混。给视频配音频,本质是替换或添加音频轨道:ffmpeg -i video.mp4 -i audio.mp3 -c:v copy -c:a aac -shortest output.mp4-shortest表示以较短的流为准结束输出,避免音频比视频长导致最后一段黑屏。-c:v copy保留原视频流不重新编码,只对音频编码,速度快。多视频拼接则要用concat协议。先创建一个文本文件列出所有片段:file 'part1.mp4' file 'part2.mp4' file 'part3.mp4'然后执行:ffmpeg -f concat -safe 0 -i list.txt -c copy output.mp4-safe 0允许使用绝对路径。这里有个硬性要求:所有片段的编码格式、分辨率、帧率必须完全一致,否则拼接出来的视频会出现音画不同步或者花屏。如果格式不统一,得先统一转码再拼接。4.2 Java实现音视频合并先看配音频的实现:public static void mergeAudioVideo(String video, String audio, String output) throws Exception { List<String> cmd = Arrays.asList( "ffmpeg", "-i", video, "-i", audio, "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest", "-y", output ); int code = execute(cmd, 600); if (code != 0) { throw new RuntimeException("音视频合并失败"); } }-b:a 192k指定音频码率,192kbps对于大多数场景够用了,追求音质可以上320k。多视频拼接的Java实现稍微复杂一点,因为要先写临时列表文件:public static void concatVideos(List<String> inputs, String output) throws Exception { Path listFile = Files.createTempFile("concat", ".txt"); StringBuilder sb = new StringBuilder(); for (String input : inputs) { sb.append("file '").append(input.replace("'", "'\\''")).append("'\n"); } Files.write(listFile, sb.toString().getBytes(StandardCharsets.UTF_8)); List<String> cmd = Arrays.asList( "ffmpeg", "-f", "concat", "-safe", "0", "-i", listFile.toString(), "-c", "copy", "-y", output ); int code = execute(cmd, 1200); Files.deleteIfExists(listFile); if (code != 0) { throw new RuntimeException("视频拼接失败"); } }路径里的单引号要转义,否则列表文件解析会出错。临时文件用完记得删,不然跑久了磁盘会被塞满。4.3 合并时的常见坑第一个坑是音视频时长不一致。如果音频比视频长,不加-shortest的话输出视频会以音频长度为准,视频结束后是黑屏。如果视频比音频长,音频结束后是静音。业务上一般要求以视频为准,所以-shortest基本是必加的。第二个坑是拼接时分辨率不一致。比如手机横屏拍的1920x1080和竖屏拍的1080x1920混在一起,直接concat会报错或者输出异常。解决办法是先用scale滤镜统一分辨率:ffmpeg -i input.mp4 -vf "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2" output.mp4这个命令会把视频等比缩放到1280x720的画布内,不足的部分用黑边填充,保证所有片段尺寸一致。注意:force_original_aspect_ratio=decrease保证不拉伸变形,pad负责补黑边。如果直接scale=1280:720会强行拉伸,人物会变形。5. 视频截图与封面抽取5.1 截图的时机选择视频截图最核心的参数是"截哪一帧"。最简单的做法是截第一帧:ffmpeg -i input.mp4 -ss 00:00:01 -vframes 1 output.jpg-ss指定时间点,-vframes 1表示只输出一帧。这里有个性能细节:-ss放在-i前面是快速定位,FFmpeg会直接跳到关键帧附近,速度快但可能不够精确;放在-i后面是精确解码,会从文件头解码到指定时间,慢但帧准确。对于封面抽取,快速定位就够了。如果要做视频缩略图墙,可以按固定间隔抽帧:ffmpeg -i input.mp4 -vf "fps=1/10" thumb_%03d.jpgfps=1/10表示每10秒抽一帧,%03d是序号占位符,生成thumb_001.jpg、thumb_002.jpg这样。5.2 Java实现截图功能public static void captureFrame(String video, String output, String timePoint) throws Exception { List<String> cmd = Arrays.asList( "ffmpeg", "-ss", timePoint, "-i", video, "-vframes", "1", "-q:v", "2", "-y", output ); int code = execute(cmd, 120); if (code != 0) { throw new RuntimeException("视频截图失败"); } }-q:v 2控制JPEG质量,范围1到31,数值越小质量越高,2基本是高质量。如果要生成PNG,把输出后缀改成.png即可,PNG是无损的但文件更大。5.3 截图功能的进阶玩法有时候业务需要"智能封面",也就是避开片头黑屏和片尾字幕,从中间截取。我的做法是先用ffprobe拿到视频总时长,然后取30%位置作为截图点:public static double getDuration(String video) throws Exception { List<String> cmd = Arrays.asList( "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", video ); ProcessBuilder pb = new ProcessBuilder(cmd); pb.redirectErrorStream(true); Process process = pb.start(); try (BufferedReader reader = new BufferedReader( new InputStreamReader(process.getInputStream()))) { String line = reader.readLine(); return Double.parseDouble(line.trim()); } }拿到时长后乘以0.3就是截图时间点。这个比例是我试出来的经验值,太靠前容易截到黑屏,太靠后容易截到片尾。实操心得:截图输出路径的目录必须提前创建好,FFmpeg不会自动建目录。如果目录不存在,它会直接报错退出。我一般会在调用前用Files.createDirectories确保目录存在。6. 视频播放能力的实现思路6.1 服务端"播放"的本质严格来说,Java服务端并不负责"播放"视频,播放是客户端的事。但服务端需要做的是让视频能够被播放,这涉及两个层面:一是转码成浏览器或播放器支持的格式,二是支持HTTP Range请求实现拖动进度条。浏览器原生支持最好的是MP4(H.264+AAC)和WebM。如果用户上传的是AVI、RMVB这些老格式,必须先转成MP4。转码时加上-movflags +faststart,让元数据前置,这样浏览器不用等整个文件下载完就能开始播放。6.2 支持拖动进度的关键视频能播放但拖不动进度条,这是很多人遇到的问题。根源在于服务端没有正确处理HTTP的Range请求。播放器拖动时会发送Range: bytes=1000-2000这样的请求头,服务端必须返回206状态码和对应的字节片段。如果用Spring Boot,可以用ResourceHttpRequestHandler或者自己写Controller处理:@GetMapping("/video/{id}") public ResponseEntity<Resource> play(@PathVariable String id, @RequestHeader(value = "Range", required = false) String range) throws Exception { File file = getVideoFile(id); long length = file.length(); if (range == null) { return ResponseEntity.ok() .contentType(MediaType.parseMediaType("video/mp4")) .contentLength(length) .body(new FileSystemResource(file)); } String[] ranges = range.replace("bytes=", "").split("-"); long start = Long.parseLong(ranges[0]); long end = ranges.length > 1 ? Long.parseLong(ranges[1]) : length - 1; long contentLength = end - start + 1; InputStream is = new RandomAccessFile(file, "r").getChannel() .map(FileChannel.MapMode.READ_ONLY, start, contentLength) .newInputStream(); return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT) .contentType(MediaType.parseMediaType("video/mp4")) .header("Accept-Ranges", "bytes") .header("Content-Range", "bytes " + start + "-" + end + "/" + length) .contentLength(contentLength) .body(new InputStreamResource(is)); }Accept-Ranges: bytes这个响应头必须返回,否则播放器不知道服务端支持分段请求,进度条就是灰的。6.3 播放相关的转码策略如果视频需要加密或者加水印,就不能直接返回原文件,得实时转码。实时转码的性能开销很大,一个1080P的视频转码能吃掉好几个CPU核心。我的建议是异步预转码,用户上传后立即触发转码任务,转好了再提供播放地址,而不是在播放请求里同步转码。如果确实需要实时处理,可以用FFmpeg的管道输出,把转码结果直接写到HTTP响应流里:ffmpeg -i input.mp4 -f mp4 -movflags frag_keyframe+empty_moov -pipe:1frag_keyframe+empty_moov生成的是碎片化MP4,适合流式传输。Java端读取进程的输出流,边读边写到HttpServletResponse的OutputStream。注意:实时转码场景下,客户端断开连接后必须主动销毁FFmpeg进程,否则会留下僵尸进程持续消耗CPU。可以在finally块里调用process.destroyForcibly()。7. 常见问题与排查技巧实录7.1 问题速查表问题现象可能原因排查方法解决方案进程卡死不返回未读取输出流导致缓冲区满检查是否调用redirectErrorStream并读取合并流并持续读取转换后无声音音频编码不兼容用ffprobe查看音频编码指定-c:a aac重新编码拼接后音画不同步片段帧率不一致检查各片段帧率统一转码后再拼接截图是黑屏截到了片头黑帧换时间点重试取视频30%位置截图播放拖不动进度未支持Range请求抓包看是否返回206实现分段响应中文路径报错编码问题查看FFmpeg错误输出统一用UTF-8并避免中文路径输出文件0字节磁盘满或权限不足检查磁盘和目录权限清理空间或改权限7.2 几个容易被忽略的细节第一个是路径问题。FFmpeg对中文路径和空格路径的处理在不同操作系统上表现不一致。我的做法是统一把输入输出文件放到临时目录,用UUID命名,处理完再移动到目标位置。这样既避免了编码问题,也防止了文件名冲突。第二个是并发控制。FFmpeg是CPU密集型任务,一个转码任务可能占满多个核心。如果Java服务同时处理多个转码请求,机器会直接卡死。我一般会用信号量限制并发数,比如Semaphore(2),同时最多跑两个转码任务,超出的排队等待。private static final Semaphore TRANSCODE_SEMAPHORE = new Semaphore(2); public static void transcodeWithLimit(String input, String output) throws Exception { TRANSCODE_SEMAPHORE.acquire(); try { convert(input, output, "mp4"); } finally { TRANSCODE_SEMAPHORE.release(); } }第三个是临时文件清理。转码过程中会产生大量临时文件,如果异常退出没有清理,磁盘很快会被占满。建议用try-finally确保临时文件被删除,或者定期跑清理任务。7.3 性能优化的几个方向如果转码量很大,可以考虑用硬件加速。NVIDIA显卡可以用h264_nvenc编码器,Intel核显可以用h264_qsv,速度比纯CPU快好几倍。但硬件加速的画质通常略逊于libx264,需要根据业务权衡。ffmpeg -i input.mp4 -c:v h264_nvenc -preset p4 -cq 23 output.mp4-cq是NVENC的质量参数,类似CRF。-preset p4是NVENC的预设,p1最快p7最慢。另一个方向是分片并行。长视频可以切成多个片段并行转码,最后拼接。但这样会引入片段边界的问题,实现复杂度高,除非是超长视频否则不建议。实操心得:转码任务的日志一定要落盘。FFmpeg的报错信息很详细,但如果不保存,出问题时只能看到"退出码1",根本没法排查。我一般会把每个任务的完整输出写到日志文件,按任务ID命名,保留7天。8. 一套可复用的封装设计把上面所有功能整合起来,我一般会设计一个FfmpegService类,对外暴露几个高层方法:convert、merge、capture、probe。内部统一走命令执行工具,统一处理超时、日志、并发限制。配置项比如FFmpeg路径、超时时间、并发数放到配置文件里,不同环境可以调整。FFmpeg路径建议做成可配置的,因为开发机可能是Windows,服务器是Linux,硬编码路径换环境就挂。用@Value("${ffmpeg.path:ffmpeg}")这种形式,默认值ffmpeg表示从PATH里找,特殊环境再覆盖。还有一点,FFmpeg的版本差异会导致某些参数不兼容。比如老版本不支持某些新编码器,新版本废弃了某些旧参数。建议在服务启动时执行一次ffmpeg -version,把版本号打到日志里,出问题时能快速定位是不是版本问题。这套东西我在几个项目里都用过,从日处理几十个视频的小工具到日处理上万条的批处理服务都跑得动。核心就是三点:命令执行要防卡死、并发要限流、临时文件要清理。把这三点做好,剩下的就是按业务需求调参数了。