使用 zlmediakit 录像
最近有项目需要后端录制视频,在朋友推荐下使用了 zlmediakit 服务,解决了录像录制的问题。
一、zlmediakit 安装与配置
使用 docker-compose 部署,将 conf 和 media 目录映射出来,方便配置文件的修改和录像等文件的持久化。
docker-compose.yml 配置如下,我已经打开了推流录制,方便后面流程使用:
version: "3.9"
services:
zlmediakit:
image: registry.cn-beijing.aliyuncs.com/wuhm/zlmediakit:master
container_name: zlmediakit
privileged: true
# 指定加载配置
command: /opt/media/bin/MediaServer -c /opt/media/conf/config.ini
ports:
- "11935:1935"
- "180:80"
- "1554:554"
- "9009:9000/udp"
- "10000:10000/tcp"
- "10000:10000/udp"
- "30000-30500:30000-30500/tcp"
- "30000-30500:30000-30500/udp"
volumes:
# 自定义MediaServer
# - ./bin:/opt/media/bin
# 自定义config.ini
- ./conf/config.ini:/opt/media/conf/config.ini
- ./media:/opt/media/bin/www
# 自定义ffmpeg
# - ./ffmpeg/bin:/home/bin
environment:
TZ: "Asia/Shanghai"
restart: "on-failure:3"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
conf 目录下只有一个 config.ini 配置文件,比较重要的配置是 secret,API 调用需要。内容如下:
[api]
#是否调试http api,启用调试后,会打印每次http请求的内容和回复
apiDebug=1
#一些比较敏感的http api在访问时需要提供secret,否则无权限调用
#如果是通过127.0.0.1访问,那么可以不提供secret
secret=abcd123456qwerty
#截图保存路径根目录,截图通过http api(/index/api/getSnap)生成和获取
snapRoot=./www/snap/
#默认截图图片,在启动FFmpeg截图后但是截图还未生成时,可以返回默认的预设图片
defaultSnap=./www/logo.png
#downloadFile http接口可访问文件的根目录,支持多个目录,不同目录通过分号(;)分隔
downloadRoot=./www
[ffmpeg]
#FFmpeg可执行程序路径,支持相对路径/绝对路径
bin=/usr/bin/ffmpeg
#FFmpeg拉流再推流的命令模板,通过该模板可以设置再编码的一些参数
cmd=%s -re -i %s -c:a aac -strict -2 -ar 44100 -ab 48k -c:v libx264 -f flv %s
#FFmpeg生成截图的命令,可以通过修改该配置改变截图分辨率或质量
snap=%s -i %s -y -f mjpeg -frames:v 1 -an %s
#FFmpeg日志的路径,如果置空则不生成FFmpeg日志
#可以为相对(相对于本可执行程序目录)或绝对路径
log=./ffmpeg/ffmpeg.log
# 自动重启的时间(秒), 默认为0, 也就是不自动重启. 主要是为了避免长时间ffmpeg拉流导致的不同步现象
restart_sec=0
#转协议相关开关;如果addStreamProxy api和on_publish hook回复未指定转协议参数,则采用这些配置项
[protocol]
#转协议时,是否开启帧级时间戳覆盖
# 0:采用源视频流绝对时间戳,不做任何改变
# 1:采用zlmediakit接收数据时的系统时间戳(有平滑处理)
# 2:采用源视频流时间戳相对时间戳(增长量),有做时间戳跳跃和回退矫正
modify_stamp=2
#转协议是否开启音频
enable_audio=1
#添加acc静音音频,在关闭音频时,此开关无效
add_mute_audio=1
#无人观看时,是否直接关闭(而不是通过on_none_reader hook返回close)
#此配置置1时,此流如果无人观看,将不触发on_none_reader hook回调,
#而是将直接关闭流
auto_close=0
#推流断开后可以在超时时间内重新连接上继续推流,这样播放器会接着播放。
#置0关闭此特性(推流断开会导致立即断开播放器)
#此参数不应大于播放器超时时间;单位毫秒
continue_push_ms=15000
#平滑发送定时器间隔,单位毫秒,置0则关闭;开启后影响cpu性能同时增加内存
#该配置开启后可以解决一些流发送不平滑导致zlmediakit转发也不平滑的问题
paced_sender_ms=0
#是否开启转换为hls(mpegts)
enable_hls=1
#是否开启转换为hls(fmp4)
enable_hls_fmp4=0
#是否开启MP4录制
enable_mp4=1
#是否开启转换为rtsp/webrtc
enable_rtsp=1
#是否开启转换为rtmp/flv
enable_rtmp=1
#是否开启转换为http-ts/ws-ts
enable_ts=1
#是否开启转换为http-fmp4/ws-fmp4
enable_fmp4=1
#是否将mp4录制当做观看者
mp4_as_player=0
#mp4切片大小,单位秒
mp4_max_second=3600
#mp4录制保存路径
mp4_save_path=./www
#hls录制保存路径
hls_save_path=./www
media 目录就是将一些录像等文件挂在出来,启动前建个空目录即可,如图所示:

浏览器也可以直接访问这个文件,如图所示:

二、客户端推流
2.1 依赖引入
可以直接用 FFmpeg,但我的开发语言是 Java,我发现有现成的包可用:
<!-- FFmpeg Java 包装器 -->
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacv-platform</artifactId>
<version>1.5.8</version>
</dependency>
2.2 开始推流
这里推流拿到是一个未加密的 flv 地址。
// ZLMediaKit配置
String zlmHost = "10.64.5.85";
String zlmApiPort = "180";
String zlmRtmpPort = "11935";
String zlmSecret = "abcd123456qwerty";
String appName = "live";
// 生成流ID
String streamId = "device_" + deviceCode + "_" + recordTask.getStartTime();
// 构建RTMP推流地址
String rtmpPushUrl = String.format("rtmp://%s:%s/%s/%s", zlmHost, zlmRtmpPort, appName, streamId);
/**
* 启动RTMP推流任务 (FLV -> RTMP -> ZLM) - 使用JavaCV
*/
private void startRtmpPushTask(String flvUrl, String rtmpPushUrl, String deviceCode, RecordTask recordTask) {
// 创建后台推流任务
new Thread(() -> {
FFmpegFrameGrabber grabber = null;
FFmpegFrameRecorder recorder = null;
try {
log.info("开始RTMP推流: {} -> {}", flvUrl, rtmpPushUrl);
// 1. 创建和配置输入抓取器
grabber = createConfiguredGrabber(flvUrl);
log.info("FFmpegFrameGrabber配置完成,开始启动...");
grabber.start();
log.info("FFmpegFrameGrabber启动成功");
// 2. 获取流属性
int width = grabber.getImageWidth();
int height = grabber.getImageHeight();
double frameRate = grabber.getVideoFrameRate();
if (frameRate <= 0) frameRate = 25;
log.info("流属性: 宽度={}, 高度={}, 帧率={}", width, height, frameRate);
// 3. 创建和配置输出录制器
recorder = new FFmpegFrameRecorder(rtmpPushUrl, width, height);
recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);
recorder.setFormat("flv");
recorder.setFrameRate(frameRate);
recorder.setVideoBitrate(2000000);
recorder.setPixelFormat(org.bytedeco.ffmpeg.global.avutil.AV_PIX_FMT_YUV420P);
// H264编码设置
recorder.setOption("preset", "fast");
recorder.setOption("profile", "baseline");
recorder.setOption("level", "3.1");
// RTMP特定设置
recorder.setOption("rtmp_live", "live");
recorder.setOption("rtmp_buffer", "1000");
// 音频配置
if (grabber.getAudioChannels() > 0) {
recorder.setAudioChannels(1);
recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC);
recorder.setSampleRate(44100);
recorder.setAudioBitrate(64000);
log.info("音频配置: 通道=1, 采样率=44100, 比特率=64000");
}
recorder.start();
// 4. 保存到RecordTask以便生命周期管理
recordTask.setPushRecorder(recorder);
recordTask.setPushGrabber(grabber);
// 5. 发送第一帧以建立流
Frame firstFrame = grabber.grabFrame();
if (firstFrame != null) {
firstFrame.timestamp = 0;
recorder.record(firstFrame);
recordTask.setFirstFrameSent(true);
log.info("第一帧已发送,推流建立");
}
// 6. 持续推流循环,帧率控制
long startTime = System.currentTimeMillis();
int frameCount = 1;
long frameIntervalMs = (long) (1000.0 / frameRate);
long expectedNextFrameTime = startTime + frameIntervalMs;
long lastLogTime = startTime;
while ("RECORDING".equals(recordTask.getStatus())) {
Frame frame = grabber.grabFrame();
if (frame == null) {
log.warn("抓取到空帧,可能输入流结束");
break;
}
long currentTime = System.currentTimeMillis();
if (currentTime >= expectedNextFrameTime) {
// 设置连续时间戳
frame.timestamp = frameCount * 1000000L / (long) frameRate;
recorder.record(frame);
frameCount++;
expectedNextFrameTime = startTime + (frameCount * frameIntervalMs);
// 每30秒记录一次推流状态
if (currentTime - lastLogTime > 30000) {
long duration = (currentTime - startTime) / 1000;
double actualFps = frameCount / (duration + 0.001);
log.info("推流进行中: 设备={}, 时长={}秒, 帧数={}, 实际帧率={:.2f}fps",
deviceCode, duration, frameCount, actualFps);
lastLogTime = currentTime;
}
} else {
// 等待到下一帧时间
long sleepTime = Math.min(expectedNextFrameTime - currentTime, 10);
if (sleepTime > 0) {
Thread.sleep(sleepTime);
}
}
}
long totalDuration = (System.currentTimeMillis() - startTime) / 1000;
log.info("推流完成: 设备={}, 总时长={}秒, 总帧数={}, 平均帧率={:.2f}fps",
deviceCode, totalDuration, frameCount, frameCount / (totalDuration + 0.001));
} catch (Exception e) {
log.error("RTMP推流异常: 设备={}, 错误={}", deviceCode, e.getMessage(), e);
} finally {
// 清理资源
if (recorder != null) {
try {
recorder.recordSamples(null); // 发送结束信号
recorder.stop();
recorder.release();
} catch (Exception e) {
log.error("释放录制器失败", e);
}
}
if (grabber != null) {
try {
grabber.stop();
grabber.release();
} catch (Exception e) {
log.error("释放抓取器失败", e);
}
}
// 从RecordTask中清除引用
recordTask.setPushRecorder(null);
recordTask.setPushGrabber(null);
}
}, "rtmp-push-" + deviceCode).start();
log.info("RTMP推流任务已启动: 设备={}", deviceCode);
}
三、录像保存
3.1 启动 ZLM 录制
这里 zlm 的配置与推流相同,streamId 也是推流后拿到的。
/**
* 通过API启动ZLM录制
*/
public void startZLMRecordingViaAPI(String zlmHost, String zlmApiPort, String secret,
String appName, String streamId) {
try {
// 构建ZLM录制API请求
String apiUrl = String.format("http://%s:%s/index/api/startRecord", zlmHost, zlmApiPort);
// 录制参数 - 优化为1分钟分片
Map<String, Object> params = new HashMap<>();
params.put("secret", secret);
params.put("type", 0); // 0=hls,1=mp4
params.put("vhost", "__defaultVhost__");
params.put("app", appName);
params.put("stream", streamId);
params.put("customized_path", "/opt/media/record/" + streamId); // 自定义录制路径
params.put("max_second", 30); // 30秒分片
params.put("continue_push_ms", 2000); // 断流后继续录制2秒
params.put("enable_fmp4", 0); // 禁用fmp4,使用标准mp4
// 发送HTTP请求启动录制
String response = sendHttpRequest(apiUrl, params);
log.info("ZLM启动录制响应: {}", response);
// 检查响应结果
if (!response.contains("\"code\":0")) {
log.warn("ZLM启动录制可能失败,但继续尝试录制: {}", response);
} else {
log.info("ZLM录制启动成功,分片时长: 30秒");
}
} catch (Exception e) {
log.error("调用ZLM录制API失败", e);
}
}
3.2 停止 ZLM 录制
/**
* 通过API停止ZLM录制
*/
public void stopZLMRecordingViaAPI(String zlmHost, String zlmApiPort, String secret,
String appName, String streamId) {
try {
String apiUrl = String.format("http://%s:%s/index/api/stopRecord", zlmHost, zlmApiPort);
Map<String, Object> params = new HashMap<>();
params.put("secret", secret);
params.put("type", 0); // 与启动时保持一致
params.put("vhost", "__defaultVhost__");
params.put("app", appName);
params.put("stream", streamId);
String response = sendHttpRequest(apiUrl, params);
log.info("ZLM停止录制响应: {}", response);
} catch (Exception e) {
log.error("调用ZLM停止录制API失败", e);
}
}
3.3 获取录制文件
获取录制文件需要一定时间,比如我录制 5 分钟的视频,可能要等上一两分钟才能在目录里找到文件。
/**
* 获取ZLM录制文件列表 - 只扫描实际目录
*/
public List<String> getZLMRecordFiles(String zlmHost, String zlmApiPort, String streamId) {
List<String> recordFiles = new ArrayList<>();
try {
// 构建录制文件URL模式: http://zlmHost:zlmApiPort/record/live/streamId/
String baseUrl = String.format("http://%s:%s/record/live/%s", zlmHost, zlmApiPort, streamId);
// 获取当前日期,ZLM按日期创建录制目录
LocalDateTime now = LocalDateTime.now();
String dateStr = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
String recordDirUrl = baseUrl + "/" + dateStr + "/";
log.info("只通过目录扫描查找mp4文件: {}", recordDirUrl);
// 只使用目录扫描方法,不再尝试其他方法
recordFiles = scanDirectoryForMp4Files(recordDirUrl);
if (recordFiles.size() > 0) {
log.info("目录扫描发现 {} 个mp4文件", recordFiles.size());
// 输出文件列表用于调试
if (recordFiles.size() <= 10) {
log.info("发现的文件列表: {}", String.join(", ",
recordFiles.stream()
.map(url -> url.substring(url.lastIndexOf("/") + 1))
.collect(Collectors.toList())));
}
} else {
log.warn("目录扫描未发现任何mp4文件,检查目录: {}", recordDirUrl);
}
} catch (Exception e) {
log.error("获取ZLM录制文件列表失败", e);
}
return recordFiles;
}
正常录制成功后,可以在 zlmediakit 上查询到录制好的文件:

有了视频文件,我们就可以做存储的工作了,我的做法是存放到 MinIO 上。