文章目录
- [1 本地视频推流](#1 本地视频推流)
-
- [1.1 简介](#1.1 简介)
-
- [1.1.1 FFmpeg](#1.1.1 FFmpeg)
- [1.1.2 ZLMediaKit](#1.1.2 ZLMediaKit)
- [1.2 环境准备](#1.2 环境准备)
-
- [1.2.1 ZLMediaKit 安装配置](#1.2.1 ZLMediaKit 安装配置)
- [1.2.2 FFmpeg 安装(可选)](#1.2.2 FFmpeg 安装(可选))
- [1.3 整合](#1.3 整合)
-
- [1.3.1 pom和配置](#1.3.1 pom和配置)
- [1.3.2 推流配置类](#1.3.2 推流配置类)
- [1.3.3 推流服务类](#1.3.3 推流服务类)
- [1.3.4 前端部分](#1.3.4 前端部分)
1 本地视频推流
1.1 简介
FFmpeg
是一套强大的开源音视频处理工具,能够对本地视频进行解码、转码和推流。ZLMediaKit
则是一个轻量级、高效的流媒体服务器,负责接收并分发视频流。
两者结合时,通常由FFmpeg
将处理后的视频流,以RTMP
或RTSP
等协议推送至ZLMediaKit
服务器,再由ZLMediaKit
实现多终端、多协议的实时直播分发。
1.1.1 FFmpeg
FFmpeg
音视频编码、转换、处理的瑞士军刀,是一套可以记录、转换数字音频、视频,并能将其转化为流的开源计算机程序。它提供了录制、转换以及流化音视频的完整解决方案
主要网址:
- FFmpeg 官网:https://www.ffmpeg.org
- FFmpeg 中文网:https://ffmpeg.github.net.cn
- GitHub: https://github.com/FFmpeg/FFmpeg
特点:
- 完整的音视频解决方案
- 先进的编解码库libavcodec
- 跨平台支持
核心工具:FFmpeg
主要包含三个强大的命令行工具:
ffmpeg
:用于音视频转码、转换。例如,将一个AVI文件转换为TS文件并设置视频码率:ffmpeg -i input.avi -b:v 640k output.ts
ffplay
:一个简单的媒体播放器,可以用来快速播放音视频文件。例如:ffplay test.avi
ffprobe
:用于查看多媒体文件的信息,比如格式、编码、流详情等。
1.1.2 ZLMediaKit
ZLMediaKit
:运营级流媒体服务器与服务框架,基于C++11
的高性能运营级流媒体服务框架主要网址:
- GitHub: https://github.com/ZLMediaKit/ZLMediaKit
- Gitee(国内镜像): https://gitee.com/xia-chu/ZLMediaKit
特点:
- 基于C++11,高性能
- 支持多种流媒体协议及互转
- 支持全平台,低延迟
- 核心定位:它是一个流媒体服务器,负责接收、分发和转换各种格式的媒体流。
- 协议支持:非常全面,支持
RTSP
、RTMP
、HLS
、HTTP-FLV
、WebSocket-FLV
、WebRTC
等主流协议,并能在这些协议间互相转换。
1.2 环境准备
1.2.1 ZLMediaKit 安装配置
下载安装
bash
# 拉取镜像
docker pull zlmediakit/zlmediakit:master
# 启动
docker run -d \
--name zlm-server \
-p 1935:1935 \
-p 8080: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
配置文件 (config.ini)
bash
[hls]
broadcastRecordTs=0
deleteDelaySec=600 # 推流的视频保存多久(10分钟)
fileBufSize=65536
filePath=./www # 保存路径
segDur=2 # 单个.ts 切片时长(秒)。
segNum=1000 # 直播时.m3u8 里最多同时保留多少个切片。
segRetain=9999 # 磁盘上实际保留多少个历史切片
启动服务
bash
# 查看启动状态
docker logs -f zlm-server
1.2.2 FFmpeg 安装(可选)
下载路径:https://www.gyan.dev/ffmpeg/builds/
选择正式版的ffmpeg,这两个都可以选
配置环境变量找到 bin 目录,将其配到 path 环境变量中,执行 ffmpeg --version
验证
与java集合方面:
Jaffree
:需要本地/服务器已经有 ffmpeg 命令(需要配置路径)。bytedeco/javacpp-presets
:依赖里自带 FFmpeg 原生库(无需单独安装 ffmpeg)。Commons Exec / ProcessBuilder
:要安装 ffmpeg
1.3 整合
1.3.1 pom和配置
xml
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-exec</artifactId>
<version>1.3</version>
</dependency>
<!-- jaffree 需要的 -->
<dependency>
<groupId>com.github.kokorin.jaffree</groupId>
<artifactId>jaffree</artifactId>
<version>0.11.0</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<version>1.7.25</version> <!-- 版本可调整 -->
</dependency>
<!-- bytedeco -->
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>ffmpeg-platform</artifactId>
<version>6.0-1.5.10</version> <!-- 版本号示例,请检查最新 -->
</dependency>
配置
yml
# 文件上传配置
spring:
servlet:
multipart:
max-file-size: 1GB
max-request-size: 1GB
stream:
zlm-host: 127.0.0.1
rtmp-port: 1935
http-port: 8099
ffmpeg-path: ffmpeg
video-path: \videos\
1.3.2 推流配置类
java
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;
}
1.3.3 推流服务类
java
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;
}
}
//此处是用 直接 runtime 方式 构建也可以用 Jaffree 方式
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);
}
}
1.3.4 前端部分
html
<!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://127.0.0.1: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>