autorenew

使用 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 目录就是将一些录像等文件挂在出来,启动前建个空目录即可,如图所示:

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 上。