SpringBoot + FFmpeg + ZLMediaKit 实现本地视频推流

adminadmin 欧易资讯 2026-07-23 37 0

本地视频推流,最烦的不是代码写不出来,是接口返回成功了,ZLMediaKit 里死活看不到流。

我当时第一眼就不信 Java 代码。推流这种东西,先别急着封装服务,先把命令单独跑通。

本地有个视频:

/data/video/test.mp4

ZLMediaKit 本机启动,RTMP 端口用默认的 1935 ,那 FFmpeg 先这么推:

ffmpeg -re -stream_loop -1 -i /data/video/test.mp4 \
-c copy -f flv rtmp://127.0.0.1:1935/live/local_test

然后看 ZLMediaKit 日志,正常会有类似这种东西:

MediaSource online: schema=rtmp, app=live, stream=local_test

浏览器或者 VLC 播放地址一般是:

http://127.0.0.1:8080/live/local_test.live.flv
rtmp://127.0.0.1:1935/live/local_test

这一步不通,后面 SpringBoot 写得再优雅也没用。

我这里用 SpringBoot 做的事情很简单:接收一个本地视频路径,启动一个 FFmpeg 进程,把视频推到 ZLMediaKit。不要把问题搞复杂,推流本质上就是起进程、管进程、收日志、能停止。

先放配置:

stream:
ffmpeg-path:/usr/bin/ffmpeg
zlm-rtmp:rtmp://127.0.0.1:1935/live
video-root:/data/video

配置类不要写一堆花活:

@ConfigurationProperties(prefix = "stream")
publicclassStreamProperties{

private String ffmpegPath;
private String zlmRtmp;
private String videoRoot;

public String getFfmpegPath{
return ffmpegPath;
}

publicvoidsetFfmpegPath(String ffmpegPath){
this.ffmpegPath = ffmpegPath;
}

public String getZlmRtmp{
return zlmRtmp;
}

publicvoidsetZlmRtmp(String zlmRtmp){
this.zlmRtmp = zlmRtmp;
}

public String getVideoRoot{
return videoRoot;
}

publicvoidsetVideoRoot(String videoRoot){
this.videoRoot = videoRoot;
}
}

启动类记得加:

@EnableConfigurationProperties(StreamProperties.class)
@SpringBootApplication
publicclassPushApplication{
publicstaticvoidmain(String[] args){
SpringApplication.run(PushApplication.class, args);
}
}

真正干活的是这个类。

这里我没用什么 FFmpeg Java SDK。推流这种场景,直接 ProcessBuilder 反而最清楚。线上出问题的时候,日志里能看到完整命令,比封装库吞异常强多了。

@Service
publicclassLocalVideoPushService{

privatefinal StreamProperties props;
privatefinal Map running = new ConcurrentHashMap<>;

publicLocalVideoPushService(StreamProperties props){
this.props = props;
}

public String start(String fileName, String streamKey){
if (running.containsKey(streamKey)) {
thrownew IllegalStateException("stream already running: " + streamKey);
}

Path video = Paths.get(props.getVideoRoot, fileName).normalize;

if (!video.startsWith(Paths.get(props.getVideoRoot))) {
thrownew IllegalArgumentException("bad video path");
}

if (!Files.exists(video)) {
thrownew IllegalArgumentException("video not found: " + video);
}

String pushUrl = props.getZlmRtmp + "/" + streamKey;

List cmd = new ArrayList<>;
cmd.add(props.getFfmpegPath);
cmd.add("-re");
cmd.add("-stream_loop");
cmd.add("-1");
cmd.add("-i");
cmd.add(video.toString);
cmd.add("-c:v");
cmd.add("copy");
cmd.add("-c:a");
cmd.add("aac");
cmd.add("-f");
cmd.add("flv");
cmd.add(pushUrl);

try {
ProcessBuilder builder = new ProcessBuilder(cmd);
builder.redirectErrorStream(true);

Process process = builder.start;
running.put(streamKey, process);

consumeLog(streamKey, process);

return pushUrl;
} catch (IOException e) {
thrownew RuntimeException("start ffmpeg failed, cmd=" + String.join(" ", cmd), e);
}
}

publicvoidstop(String streamKey){
Process process = running.remove(streamKey);
if (process == ) {
return;
}

process.destroy;

try {
if (!process.waitFor(3, TimeUnit.SECONDS)) {
process.destroyForcibly;
}
} catch (InterruptedException e) {
Thread.currentThread.interrupt;
process.destroyForcibly;
}
}

privatevoidconsumeLog(String streamKey, Process process){
Thread t = new Thread( -> {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream, StandardCharsets.UTF_8))) {

String line;
while ((line = reader.readLine) != ) {
if (line.contains("frame=") || line.contains("error") || line.contains("failed")) {
System.out.println("[ffmpeg][" + streamKey + "] " + line);
}
}
} catch (IOException e) {
System.out.println("[ffmpeg][" + streamKey + "] read log failed: " + e.getMessage);
} finally {
running.remove(streamKey);
}
});

t.setName("ffmpeg-log-" + streamKey);
t.setDaemon(true);
t.start;
}
}

这里有几个地方我会特意保留。

第一个是 redirectErrorStream(true) 。FFmpeg 的日志大部分走错误流,你不读它,它可能把缓冲区打满,进程就卡住。这个坑挺脏,接口看着没报错,推流也没继续。

第二个是 -re 。本地文件如果不加这个参数,FFmpeg 会按最快速度推,几秒钟把一个视频怼完。你要的是模拟直播,就得按原始帧率推。

第三个是 -stream_loop -1 。本地测试通常希望视频循环推,不然刚调好播放器,流结束了。

Controller 可以写得薄一点:

@RestController
@RequestMapping("/video/push")
publicclassVideoPushController{

privatefinal LocalVideoPushService pushService;

publicVideoPushController(LocalVideoPushService pushService){
this.pushService = pushService;
}

@PostMapping("/start")
public Mapstart(@RequestParam String file,
@RequestParam String stream){
String url = pushService.start(file, stream);

Map ret = new LinkedHashMap<>;
ret.put("stream", stream);
ret.put("pushUrl", url);
ret.put("flv", "http://127.0.0.1:8080/live/" + stream + ".live.flv");
return ret;
}

@PostMapping("/stop")
public Mapstop(@RequestParam String stream){
pushService.stop(stream);

Map ret = new LinkedHashMap<>;
ret.put("stream", stream);
ret.put("stopped", true);
return ret;
}
}

调用一下:

curl -X POST "http://127.0.0.1:8081/video/push/start?file=test.mp4&stream=room_1001"

返回:

{
"stream": "room_1001",
"pushUrl": "rtmp://127.0.0.1:1935/live/room_1001",
"flv": "http://127.0.0.1:8080/live/room_1001.live.flv"
}

ZLMediaKit 这边我一般先看三个点。

一个是端口有没有开:

netstat -tunlp | grep -E "1935|8080"

一个是流有没有注册上:

MediaSource online: schema=rtmp, app=live, stream=room_1001

还有一个是 FFmpeg 日志里有没有这种错误:

Connection refused
Server returned 404
Broken pipe
Invalid data found when processing input

Connection refused 基本就是 ZLMediaKit 没起来,或者 RTMP 端口不是 1935。

Broken pipe 我一般先怀疑播放器断了,或者 ZLMediaKit 把流踢了。

Invalid data 多半是视频文件本身有问题,先拿 ffprobe 看一下:

ffprobe /data/video/test.mp4

还有个细节, -c copy 不是永远能用。MP4 里如果音频编码不适合 FLV,推到 RTMP 时可能出问题。所以我上面代码里视频复制,音频转成 aac 。这不是最省 CPU 的写法,但本地视频推流这个场景,稳定比省那点 CPU 重要。

如果视频编码也不兼容,那就老老实实转 H264:

ffmpeg -re -stream_loop -1 -i /data/video/test.mp4 \
-c:v libx264 -preset veryfast -c:a aac -f flv \
rtmp://127.0.0.1:1935/live/room_1001

Java 里也一样,把 -c:v copy 换成 -c:v libx264 ,再加一个 -preset veryfast 。

这个方案不复杂,SpringBoot 不负责“推流算法”,它只负责把 FFmpeg 进程管好。ZLMediaKit 也不用你在 Java 里硬连 SDK,先让它接住流,再把播放地址吐出去。

真上线的话,再补两件事:一个是启动服务时清理残留 FFmpeg 进程,另一个是定时检查 process.isAlive ,别让页面上显示“推流中”,实际 FFmpeg 早就挂了。推流服务最怕这种假活着。

版权声明

本文仅代表作者观点,不代表xx立场。
本文系作者授权xx发表,未经许可,不得转载。

喜欢0评论已闭