找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

5973

积分

0

好友

736

主题
发表于 3 天前 | 查看: 16| 回复: 0

本文的推流链路是:FFmpeg 读取本地视频并转码,通过 RTMP 推给 ZLMediaKit,浏览器端再用 flv.js 拉 HTTP-FLV/ HLS 流。后端用 Spring Boot 组织进程管理。

1. 环境准备

开始前先完成两件事:部署 ZLMediaKit 流媒体服务、安装 FFmpeg。

1.1 ZLMediaKit 安装配置

这里用 Docker 部署 ZLMediaKit,先拉取镜像并启动容器:

# 拉取镜像
docker pull zlmediakit/zlmediakit:master

# 启动
docker run -d \
  --name zlm-server \
  -p 1935:1935 \
  -p 8099:80 \
  -p 8554:554 \
  -p 10000:10000 \
  -p 10000:10000/udp \
  -p 8000:8000/udp \
  -v /docker-volumes/zlmediakit/conf/config.ini:/opt/media/conf/config.ini \
  zlmediakit/zlmediakit:master

ZLMediaKit 的 HLS 配置如下(config.ini):

[hls]
broadcastRecordTs=0
deleteDelaySec=300    # 推流的视频保存多久(5分钟)
fileBufSize=65536
filePath=./www    # 保存路径
segDur=2    # 单个.ts 切片时长(秒)。
segNum=1000  # 直播时.m3u8 里最多同时保留多少个切片。
segRetain=9999    # 磁盘上实际保留多少个历史切片

启动后查看状态:

# 查看启动状态
docker logs -f zlm-server

1.2 FFmpeg 安装

# 下载路径
https://www.gyan.dev/ffmpeg/builds/

两个 essentials_build 版本均可,按需选择 7z 或 zip。

FFmpeg 7.0.2 Windows 构建版本下载选择

配置环境变量,把 FFmpeg 的 bin 目录加入 PATH

C:\ffmpeg\ffmpeg-7.0.2-essentials_build\bin

找到 bin 目录后将它配置到 PATH 环境变量中,能输出 FFmpeg 版本信息就表示安装成功。

ffmpeg -version 查看安装结果

2. Spring Boot 后端实现

2.1 添加依赖

引入 Apache Commons Exec,作为 FFmpeg 进程的启动/停止执行器:

<dependencies>

<!-- 进程管理 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-exec</artifactId>
<version>1.3</version>
</dependency>

</dependencies>

2.2 推流配置类

package com.lyk.plugflow.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Data
@Component
@ConfigurationProperties(prefix = "stream")
public class StreamConfig{

    /**
     * ZLMediaKit服务地址
     */
    private String zlmHost;

    /**
     * RTMP推流端口
     */
    private Integer rtmpPort;

    /**
     * HTTP-FLV拉流端口
     */
    private Integer httpPort;

    /**
     * FFmpeg可执行文件路径
     */
    private String ffmpegPath;

    /**
     * 视频存储路径
     */
    private String videoPath;

}

2.3 推流服务类

package com.lyk.plugflow.service;

import com.lyk.plugflow.config.StreamConfig;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.exec.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@Slf4j
@Service
public class StreamService{

@Autowired
private StreamConfig streamConfig;

// 存储推流进程
private final Map<String, DefaultExecutor> streamProcesses = new ConcurrentHashMap<>();

// 添加手动停止标记
private final Map<String, Boolean> manualStopFlags = new ConcurrentHashMap<>();

/**
 * 开始推流
 */
public boolean startStream(String videoPath, String streamKey){
try {
// 检查视频文件是否存在
            File videoFile = new File(videoPath);
if (!videoFile.exists()) {
                log.error("视频文件不存在: {}", videoPath);
return false;
            }

// 构建RTMP推流地址
            String rtmpUrl = String.format("rtmp://%s:%d/live/%s",
                    streamConfig.getZlmHost(), streamConfig.getRtmpPort(), streamKey);

// 构建FFmpeg命令
            CommandLine cmdLine = getCommandLine(videoPath, rtmpUrl);

// 创建执行器
            DefaultExecutor executor = new DefaultExecutor();
            executor.setExitValue(0);

// 设置watchdog用于进程管理
            ExecuteWatchdog watchdog = new ExecuteWatchdog(ExecuteWatchdog.INFINITE_TIMEOUT);
            executor.setWatchdog(watchdog);

// 设置输出流
            ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
            PumpStreamHandler streamHandler = new PumpStreamHandler(outputStream);
            executor.setStreamHandler(streamHandler);

// 异步执行
            executor.execute(cmdLine, new ExecuteResultHandler() {
@Override
public void onProcessComplete(int exitValue){
                    log.info("推流完成, streamKey: {}, exitValue: {}", streamKey, exitValue);
                    streamProcesses.remove(streamKey);
                }

@Override
public void onProcessFailed(ExecuteException e){
boolean isManualStop = manualStopFlags.remove(streamKey);
if (isManualStop) {
                        log.info("推流已手动停止, streamKey: {}", streamKey);
                    } else {
                        log.error("推流失败, streamKey: {}, error: {}", streamKey, e.getMessage());
                    }
                    streamProcesses.remove(streamKey);
                }
            });

// 保存进程引用
            streamProcesses.put(streamKey, executor);

            log.info("开始推流, streamKey: {}, rtmpUrl: {}", streamKey, rtmpUrl);
return true;

        } catch (Exception e) {
            log.error("推流启动失败", e);
return false;
        }
    }

private CommandLine getCommandLine(String videoPath, String rtmpUrl){
        CommandLine cmdLine = new CommandLine(streamConfig.getFfmpegPath());
        cmdLine.addArgument("-re"); // 按原始帧率读取
        cmdLine.addArgument("-i");
        cmdLine.addArgument(videoPath);
        cmdLine.addArgument("-c:v");
        cmdLine.addArgument("libx264"); // 视频编码
        cmdLine.addArgument("-c:a");
        cmdLine.addArgument("aac"); // 音频编码
        cmdLine.addArgument("-f");
        cmdLine.addArgument("flv"); // 输出格式
        cmdLine.addArgument("-flvflags");
        cmdLine.addArgument("no_duration_filesize");
        cmdLine.addArgument(rtmpUrl);
return cmdLine;
    }

/**
 * 停止推流
 */
public boolean stopStream(String streamKey){
try {
            DefaultExecutor executor = streamProcesses.get(streamKey);
if (executor != null) {
// 设置手动停止标记
                manualStopFlags.put(streamKey, true);

                ExecuteWatchdog watchdog = executor.getWatchdog();
if (watchdog != null) {
                    watchdog.destroyProcess();
                } else {
                    log.warn("进程没有watchdog,无法强制终止, streamKey: {}", streamKey);
                }
                streamProcesses.remove(streamKey);
                log.info("停止推流成功, streamKey: {}", streamKey);
return true;
            }
return false;
        } catch (Exception e) {
            log.error("停止推流失败", e);
return false;
        }
    }

/**
 * 获取拉流地址
 */
public String getPlayUrl(String streamKey, String protocol){
return switch (protocol.toLowerCase()) {
case "flv" -> String.format("http://%s:%d/live/%s.live.flv",
                    streamConfig.getZlmHost(), streamConfig.getHttpPort(), streamKey);
case "hls" -> String.format("http://%s:%d/live/%s/hls.m3u8",
                    streamConfig.getZlmHost(), streamConfig.getHttpPort(), streamKey);
default -> null;
        };
    }

/**
 * 检查推流状态
 */
public boolean isStreaming(String streamKey){
return streamProcesses.containsKey(streamKey);
    }
}

2.4 配置文件

stream:
  zlm-host: 192.168.159.129
  rtmp-port: 1935
  http-port: 8099
  ffmpeg-path: ffmpeg
  video-path: \videos\

# 文件上传配置
spring:
  servlet:
    multipart:
      max-file-size: 1GB
      max-request-size: 1GB

3. 使用说明

3.1 推流流程

  • 启动 ZLMediaKit 服务
  • 上传视频文件到服务器
  • 调用推流接口,指定视频路径和推流密钥
  • Spring Boot 执行 FFmpeg 命令推流到 ZLMediaKit

3.2 播放流程

  • 获取推流地址(HTTP-FLV 或 HLS)
  • 支持实时播放和回放
ffmpeg -re -i "C:\Users\lyk19\Videos\8月9日.mp4" -c:v libx264 -preset ultrafast -tune zerolatency -c:a aac -ar 44100 -b:a 128k -f flv rtmp://192.168.159.129:1935/live/stream

前端通过 flv.js 拉流播放,下面是一个完整页面示例:


<!DOCTYPE html>
<html lang="zh-CN">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FLV直播播放器</title>
<style>
  body {
    margin: 0;
    padding: 20px;
    font-family: Arial, sans-serif;
    background-color: #f0f0f0;
  }

  .player-container {
    max-width: 800px;
    margin: 0 auto;
    background: white;
    border-radius: 8px;
    padding: 20px;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  }

  #videoElement {
    width: 100%;
    height: 450px;
    background-color: #000;
    border-radius: 4px;
  }

  .controls {
    margin-top: 15px;
    text-align: center;
  }

  button {
    padding: 10px 20px;
    margin: 0 5px;
    border: none;
    border-radius: 4px;
    background-color: #007bff;
    color: white;
    cursor: pointer;
    font-size: 14px;
  }

  button:hover {
    background-color: #0056b3;
  }

  button:disabled {
    background-color: #ccc;
    cursor: not-allowed;
  }

  .status {
    margin-top: 10px;
    padding: 10px;
    border-radius: 4px;
    text-align: center;
  }

  .status.success {
    background-color: #d4edda;
    color: #155724;
  }

  .status.error {
    background-color: #f8d7da;
    color: #721c24;
  }

  .status.info {
    background-color: #d1ecf1;
    color: #0c5460;
  }
</style>
</head>

<body>
<div class="player-container">
<h1>FLV直播播放器</h1>
<video id="videoElement" controls muted>
        您的浏览器不支持视频播放
</video>

<div class="controls">
<button id="playBtn">播放</button>
<button id="pauseBtn" disabled>暂停</button>
<button id="stopBtn" disabled>停止</button>
<button id="muteBtn">静音</button>
</div>

<div id="status" class="status info">
        准备就绪,点击播放开始观看直播
</div>
</div>

<!-- 使用flv.js库 -->
<script src="https://cdn.jsdelivr.net/npm/flv.js@1.6.2/dist/flv.min.js"></script>

<script>
let flvPlayer = null;
const videoElement = document.getElementById('videoElement');
const playBtn = document.getElementById('playBtn');
const pauseBtn = document.getElementById('pauseBtn');
const stopBtn = document.getElementById('stopBtn');
const muteBtn = document.getElementById('muteBtn');
const statusDiv = document.getElementById('status');

// 你的流地址
const streamUrl = 'http://192.168.159.129:8099/live/stream.live.flv';

function updateStatus(message, type) {
    statusDiv.textContent = message;
    statusDiv.className = `status ${type}`;
    console.log(`[${type.toUpperCase()}] ${message}`);
}

function updateButtons(playEnabled, pauseEnabled, stopEnabled) {
    playBtn.disabled = !playEnabled;
    pauseBtn.disabled = !pauseEnabled;
    stopBtn.disabled = !stopEnabled;
}

// 检查浏览器支持
if (!flvjs.isSupported()) {
    updateStatus('您的浏览器不支持FLV播放,请使用Chrome、Firefox或Edge浏览器', 'error');
    playBtn.disabled = true;
}

// 播放功能
playBtn.addEventListener('click', function () {
try {
if (flvPlayer) {
    flvPlayer.destroy();
}

// 创建FLV播放器
flvPlayer = flvjs.createPlayer({
    type: 'flv',
    url: streamUrl,
    isLive: true
}, {
    enableWorker: false,
    lazyLoad: true,
    lazyLoadMaxDuration: 3 * 60,
    deferLoadAfterSourceOpen: false,
    autoCleanupSourceBuffer: true,
    enableStashBuffer: false
});

flvPlayer.attachMediaElement(videoElement);
flvPlayer.load();

// 监听事件
flvPlayer.on(flvjs.Events.ERROR, function (errorType, errorDetail, errorInfo) {
    console.error('FLV播放器错误:', errorType, errorDetail, errorInfo);
    updateStatus(`播放错误: ${errorDetail}`, 'error');
});

flvPlayer.on(flvjs.Events.LOADING_COMPLETE, function () {
    updateStatus('流加载完成', 'success');
});

flvPlayer.on(flvjs.Events.RECOVERED_EARLY_EOF, function () {
    updateStatus('从早期EOF恢复', 'info');
});

// 开始播放
videoElement.play().then(() => {
    updateStatus('正在播放直播流', 'success');
    updateButtons(false, true, true);
}).catch(error => {
    console.error('播放失败:', error);
    updateStatus('播放失败: ' + error.message, 'error');
});

} catch (error) {
console.error('创建播放器失败:', error);
updateStatus('创建播放器失败: ' + error.message, 'error');
}
});

// 暂停功能
pauseBtn.addEventListener('click', function () {
if (videoElement && !videoElement.paused) {
    videoElement.pause();
    updateStatus('播放已暂停', 'info');
    updateButtons(true, false, true);
}
});

// 停止功能
stopBtn.addEventListener('click', function () {
if (flvPlayer) {
    flvPlayer.pause();
    flvPlayer.unload();
    flvPlayer.destroy();
    flvPlayer = null;
}

videoElement.src = '';
videoElement.load();

updateStatus('播放已停止', 'info');
updateButtons(true, false, false);
});

// 静音功能
muteBtn.addEventListener('click', function () {
    videoElement.muted = !videoElement.muted;
    muteBtn.textContent = videoElement.muted ? '取消静音' : '静音';
    updateStatus(videoElement.muted ? '已静音' : '已取消静音', 'info');
});

// 视频事件监听
videoElement.addEventListener('loadstart', function () {
    updateStatus('开始加载视频流...', 'info');
});

videoElement.addEventListener('canplay', function () {
    updateStatus('视频流已准备就绪', 'success');
});

videoElement.addEventListener('playing', function () {
    updateStatus('正在播放直播流', 'success');
    updateButtons(false, true, true);
});

videoElement.addEventListener('pause', function () {
    updateStatus('播放已暂停', 'info');
    updateButtons(true, false, true);
});

videoElement.addEventListener('error', function (e) {
    updateStatus('视频播放出错', 'error');
    updateButtons(true, false, false);
});
</script>
</body>

</html>



上一篇:亚马逊取消订单仍送 RTX 5080,上万元显卡客服竟说免单
下一篇:Redis+Caffeine两级缓存实战:本地缓存选型与一致性解决方案
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-9-17 13:12 , Processed in 0.562704 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表