接到任务需要把摄像头视频流保存到本地MP4,javacv中整合了ffmpeg和opencv的API,可以在java中很方便的调用。javacv版本为1.5.7,按照资料代码逻辑很简单,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
FFmpegLogCallback.set();
String url = "rtmp://10.0.24.23/live/3001";
//创建一个拉流器,url可以是音视频文件或流媒体地址
FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(url);
grabber.start();

String localUrl = "/tmp/javacv.mp4";
FFmpegFrameRecorder recorder = new FFmpegFrameRecorder(localUrl, grabber.getImageWidth(), grabber.getImageHeight(), grabber.getAudioChannels());
recorder.setFormat("mp4");
recorder.setFrameRate(grabber.getFrameRate());
recorder.setPixelFormat(avutil.AV_PIX_FMT_YUV420P);
recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);
recorder.setVideoBitrate(grabber.getVideoBitrate());
recorder.start();

LocalDateTime startTime = LocalDateTime.now();
Frame frame;
while (startTime.plusSeconds(200).compareTo(LocalDateTime.now()) > 0 
&& (frame = grabber.grab()) != null) {
    recorder.record(frame);
}
recorder.stop();
grabber.stop();

测试视频流是文件、rtsp时都可以正常录制,但使用rtmp时出现了报错:

1
2
3
4
5
6
7
Caused by: org.bytedeco.javacv.FFmpegFrameRecorder$Exception: avcodec_send_frame() error -541478725: Error sending a video frame for encoding. (For more details, make sure FFmpegLogCallback.set() has been called.)
	at org.bytedeco.javacv.FFmpegFrameRecorder.recordImage(FFmpegFrameRecorder.java:1056) ~[javacv-1.5.7.jar:1.5.7]
	at org.bytedeco.javacv.FFmpegFrameRecorder.record(FFmpegFrameRecorder.java:961) ~[javacv-1.5.7.jar:1.5.7]
	at org.bytedeco.javacv.FFmpegFrameRecorder.record(FFmpegFrameRecorder.java:954) ~[javacv-1.5.7.jar:1.5.7]
	at cn.sibat.cvtest.MainThread.run(MainThread.java:70) ~[classes/:na]
	at org.springframework.boot.SpringApplication.callRunner(SpringApplication.java:771) [spring-boot-2.7.2.jar:2.7.2]
	... 5 common frames omitted

实际出错的源码位置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public synchronized boolean recordImage(int width, int height, int depth, int channels, int stride, int pixelFormat, Buffer ... image) throws Exception {
    try (PointerScope scope = new PointerScope()) {

...省略...
        /* encode the image */
        picture.quality(video_c.global_quality());
        /* 此处判断了avcodec_send_frame 函数返回小于0则直接抛出异常 */
        if ((ret = avcodec_send_frame(video_c, image == null || image.length == 0 ? null : picture)) < 0
                && image != null && image.length != 0) {
            throw new Exception("avcodec_send_frame() error " + ret + ": Error sending a video frame for encoding.");
        }
        picture.pts(picture.pts() + 1); // magic required by libx264
...省略...
}

avcodec_send_frame() 是ffmpeg的视频编码函数,函数返回-541478725(AVERROR_EOF)的意思是检测到了文件结束,但实际流并没有结束。此处javacv判断我们要在一个已经结束的流中进行编码,于是直接抛出了异常。开始以为是我本地rtmp推流的问题,但是换了一个在网上找的开放的rtmp流(伊拉克 Al Sharqiya 电视台:rtmp://ns8.indexforce.com/home/mystream)问题依旧。简单记录下排查流程。

1. 在github上,提交个issues

issues地址:https://github.com/bytedeco/javacv/issues/1858

javacv作者响应很快,但并没有明确定位到问题。详细信息可以去issues中查看,后续也可能会更新。

2. 尝试降低版本

V1.5.7 –> V1.5.6:没有变化

V1.5.7 –> V1.5.4:出现了新的异常信息:avcodec_encode_video2() error -542398533: Could not encode video packet.

avcodec_encode_video2()是ffmpeg 3.1版本之前用于视频编码的函数,3.1版本以后替换成了avcodec_send_frame()

V1.5.7 –> V1.5.5:没有报错,录制成功!

根据1.5.4版本的报错,发现一个非常类似的情况:https://github.com/bytedeco/javacv/issues/1563

该BUG在1.5.5修复了,猜测这就是1.5.5版本可以成功的原因。但是1.5.6开始,引用的ffmpeg中编码函数改为avcodec_send_frame(),重新引入了1.5.4中类似的问题。

3. 代码优化

虽然1.5.5可以暂时实现功能,但没有找到具体问题点,心里不踏实,所以花了2天时间,在代码继续做一些debug,试图找到什么突破。

1
2
3
4
5
6
while (startTime.plusSeconds(200).compareTo(LocalDateTime.now()) > 0 
&& (frame = grabber.grab()) != null) {
    System.out.println(frame.getTypes());
    recorder.record(frame);
}

1
2
3
4
5
6
7
[DATA]
[AUDIO]
[VIDEO]
[AUDIO]
[VIDEO]
[AUDIO]
...

将每次拉流后,获取的frame类型打印出来,发现只有第一帧是[DATA]类型,其他帧是[VIDEO]或[AUDIO]类型。

当把第一帧忽略掉后,视频就可以正常录制了!最终代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
FFmpegLogCallback.set();
String url = "rtmp://10.0.24.23/live/3001";
//创建一个拉流器,url可以是音视频文件或流媒体地址
FFmpegFrameGrabber grabber = new FFmpegFrameGrabber(url);
grabber.start();

String localUrl = "/tmp/javacv.mp4";
FFmpegFrameRecorder recorder = new FFmpegFrameRecorder(localUrl, grabber.getImageWidth(), grabber.getImageHeight(), grabber.getAudioChannels());
recorder.setFormat("mp4");
recorder.setFrameRate(grabber.getFrameRate());
recorder.setPixelFormat(avutil.AV_PIX_FMT_YUV420P);
recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);
recorder.setVideoBitrate(grabber.getVideoBitrate());
recorder.start();

LocalDateTime startTime = LocalDateTime.now();
Frame frame = grabber.grab();
System.out.println("忽略掉第一帧,类型为:" + frame.getTypes());
while (startTime.plusSeconds(200).compareTo(LocalDateTime.now()) > 0 
&& (frame = grabber.grab()) != null) {
    recorder.record(frame);
}
recorder.stop();
grabber.stop();

由于不了解RTMP协议原理,不清楚第一帧在这个过程中起到的作用,猜测可能就是javacv在处理这一“DATA”帧时出现了判断错误。