前言
从摄像头获取的一般是RTSP协议的视频流,但浏览器不能直接播放RTSP流视频,需要流媒体服务进行转码操作。
浏览器支持的几种协议的简单对比:
| 协议 | http-flv | rtmp | hls | dash |
|---|---|---|---|---|
| 传输方式 | http流 | tcp流 | http | http |
| 视频封装格式 | flv | flv tag | Ts文件 | Mp4 3gp webm |
| 延时 | 低 | 低 | 高 | 高 |
| 数据分段 | 连续流 | 连续流 | 切片文件 | 切片文件 |
| Html5播放 | 可通过html5解封包播放(flv.js) | 不支持 | 可通过html5解封包播放(hls.js) | 如果dash文件列表是mp4webm文件,可直接播放 |
rtmp协议需要flash支持,flash已经被淘汰了,所以直接不考虑这种方式。
这里选择实现 http-flv和hls格式的流媒体转换。
实现原理
Java CV
Java CV是Java在是视觉领域的一个工具包,详情了解,请参考官方文档或大佬的文章:JavaCV入门指南
JavaCV是计算机视觉领域的开发人员(OpenCV、FFmpeg、libdc1394、PGR FlyCapture、OpenKinect、li.lsense、CL PS3 Eye Driver、videoInput、ARToolKitPlus、flandmark、Leptonica和Tesseract)常用库的JavaCPP预置的包装器,并提供实用的程序类使它们的功能更容易在Java平台上使用,包括Android。
JavaCV还提供了硬件加速的全屏图像显示(CanvasFrame和GLCanvasFrame)、在多核(并行)上并行执行代码的简便方法、照相机和投影机的用户友好的几何和颜色校准(GeometricCalibrator,ProCamometricCalibrato)r,ProCamColorCalibrator),特征点的检测和匹配(ObjectFinder),一组用于实现投影仪-照相机系统的直接图像对齐的类(主要是GNImageAligner、ProjectiveTransformer、ProjectiveColorTransformer、ProCamTransformer和ReflectanceInitializer),一个blob分析包(BLUB),以及JavaCV类中的各种功能。其中一些类还具有OpenCL和OpenGL的对应类,它们的名称以CL结尾或以GL开始,即:JavaCVCL、GLCanvasFrame等。
FFmpeg
FFmpeg是一个视频格式转换工具。项目中的转码操作,主要通过这个工具来完成。
当然,是通过Java的方式调用。工具的相关库文件都封装在Java CV的依赖中。
FFmpeg介绍
FFmpeg是一套可以用来记录、转换数字音频、视频,并能将其转化为流的开源计算机程序。采用LGPL或GPL许可证。它提供了录制、转换以及流化音视频的完整解决方案。它包含了非常先进的音频/视频编解码库libavcodec,为了保证高可移植性和编解码质量,libavcodec里很多code都是从头开发的。
代码实现
创建拉流器
/**
* 创建拉流器
*
* @return boolean
*/
public MediaThread createGrabber() {
log.info("{} | {} | {} | 创建拉流器……", sourceAddress, videoType, conversionType);
// 拉流器
grabber = new FFmpegFrameGrabber(sourceAddress);
// 超时时间(5秒)
grabber.setOption("stimoout", "5000000");
grabber.setOption("threads", "1");
grabber.setPixelFormat(avutil.AV_PIX_FMT_YUV420P);
// 设置缓存大小,提高画质、减少卡顿花屏
grabber.setOption("buffer_size", "1024000");
// grabber.setOption("buffer_size", "100");
// 如果为rtsp流,增加配置
if ("rtsp".equals(sourceAddress.substring(0, 4))) {
// 设置打开协议tcp / udp
grabber.setOption("rtsp_transport", "tcp");
//首选TCP进行RTP传输
grabber.setOption("rtsp_flags", "prefer_tcp");
//设置超时时间
// -stimeout的单位是us 微秒(1秒=1*1000*1000微秒)。
grabber.setOption("stimeout", "5*1000*1000");
}
try {
grabber.start();
grabberStatus = true;
} catch (FrameGrabber.Exception e) {
log.error("{} | 创建拉流器异常!", sourceAddress, e);
setProgress(StreamMediaConstant.SERVICE_START_PROGRESS_FAILURE, "创建拉流器失败!");
}
return this;
}
创建转码录制器
Flv
Flv方式转码时,将数据存在缓存中,然后推送刚给客户端。
/**
* flv转码
*/
private void createATranscoderFlv() {
if (StringUtils.isBlank(playAddress)) {
// 生成观看地址
this.playAddress = StreamMediaUtil.generatePlaybackAddress() + ".flv";
}
recorder = new FFmpegFrameRecorder(flvOutputStream, grabber.getImageWidth(), grabber.getImageHeight(), grabber.getAudioChannels());
recorder.setFormat(this.videoType);
// 判断是否支持转复用
if (this.supportReuse()) {
try {
log.info("{} | 启动转复用录制器……", sourceAddress);
recorder.start(grabber.getFormatContext());
recorderStatus = true;
transferFlag = true;
} catch (FrameRecorder.Exception e) {
log.error("{} | 启动转复用录制器失败!", sourceAddress, e);
setProgress(StreamMediaConstant.SERVICE_START_PROGRESS_FAILURE, "启动转复用录制器失败!");
}
return;
}
// 转码
log.info("{} | 启动Flv转码录制器……", sourceAddress);
recorder.setInterleaved(false);
recorder.setVideoOption("tune", "zerolatency");
recorder.setVideoOption("preset", "ultrafast");
recorder.setVideoOption("crf", "26");
recorder.setVideoOption("threads", "1");
recorder.setFrameRate(25);// 设置帧率
recorder.setGopSize(25);// 设置gop,与帧率相同,相当于间隔1秒chan's一个关键帧
// recorder.setVideoBitrate(500 * 1000);// 码率500kb/s
recorder.setVideoCodecName("libx264");
// recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);
recorder.setPixelFormat(avutil.AV_PIX_FMT_YUV420P);
// recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC);
recorder.setAudioCodecName("aac");
try {
recorder.start();
recorderStatus = true;
} catch (FrameRecorder.Exception e) {
log.error("{} | 创建转码录制器异常!", sourceAddress, e);
setProgress(StreamMediaConstant.SERVICE_START_PROGRESS_FAILURE, "创建转码录制器失败!");
}
}
Hls
Hls方式转码时,需要将生成视频切片存在本地磁盘。
/**
* hls转码
*/
private void createATranscoderHls() {
// 创建文件夹
// 生成观看地址
if (StringUtils.isBlank(playAddress)) {
playAddress = StreamMediaUtil.generatePlaybackAddress();
}
String path = StreamMediaConstant.HLS_DIR + "/" + playAddress + "/";
thisVideoPath = path;
// 检查文件夹是否存在,如果不存在,则创建
File file = new File(path);
if (!file.exists()) {
file.mkdir();
}
path += StreamMediaConstant.VIDEO_FILE_NAME_HLS;
recorder = new FFmpegFrameRecorder(path, grabber.getImageWidth(), grabber.getImageHeight(), grabber.getAudioChannels());
recorder.setFormat("hls");
//关于hls_wrap的说明,hls_wrap表示重复覆盖之前ts切片,这是一个过时配置,ffmpeg官方推荐使用hls_list_size 和hls_flags delete_segments代替hls_wrap
//设置单个ts切片的时间长度(以秒为单位)。默认值为2秒
recorder.setOption("hls_time", "2");
//不根据gop间隔进行切片,强制使用hls_time时间进行切割ts分片
// recorder.setOption("hls_flags", "split_by_time");
//设置播放列表条目的最大数量。如果设置为0,则列表文件将包含所有片段,默认值为5
// 当切片的时间不受控制时,切片数量太小,就会有卡顿的现象
recorder.setOption("hls_list_size", "4");
//自动删除切片,如果切片数量大于hls_list_size的数量,则会开始自动删除之前的ts切片,只保留hls_list_size个数量的切片
recorder.setOption("hls_flags", "delete_segments");
//ts切片自动删除阈值,默认值为1,表示早于hls_list_size+1的切片将被删除
recorder.setOption("hls_delete_threshold", "1");
/*hls的切片类型:
* 'mpegts':以MPEG-2传输流格式输出ts切片文件,可以与所有HLS版本兼容。
* 'fmp4':以Fragmented MP4(简称:fmp4)格式输出切片文件,类似于MPEG-DASH,fmp4文件可用于HLS version 7和更高版本。
*/
recorder.setOption("hls_segment_type", "mpegts");
//指定ts切片生成名称规则,按数字序号生成切片,例如'file%03d.ts',就会生成file000.ts,file001.ts,file002.ts等切片文件
// recorder.setOption("hls_segment_filename", path + "-%03d.ts");
recorder.setOption("hls_segment_filename", path + "-%5d.ts");
// 设置第一个切片的编号
recorder.setOption("start_number", String.valueOf(tsCont));
recorder.setPixelFormat(avutil.AV_PIX_FMT_YUV420P);
// 判断是否支持转复用
if (this.supportReuse()) {
try {
log.info("{} | 启动Hls转复用录制器……", sourceAddress);
recorder.start(grabber.getFormatContext());
recorderStatus = true;
transferFlag = true;
} catch (FrameRecorder.Exception e) {
log.error("{} | 启动转复用录制器失败!", sourceAddress, e);
setProgress(StreamMediaConstant.SERVICE_START_PROGRESS_FAILURE, "启动转复用录制器失败!");
}
return;
}
// 转码
log.info("{} | 启动Hls转码录制器……", sourceAddress);
// 设置零延迟
recorder.setVideoOption("tune", "zerolatency");
// 快速
recorder.setVideoOption("preset", "ultrafast");
// recorder.setVideoOption("crf", "26");
// recorder.setVideoOption("threads", "1");
recorder.setFrameRate(25);// 设置帧率
recorder.setGopSize(25);// 设置gop,与帧率相同,相当于间隔1秒chan's一个关键帧
// recorder.setVideoBitrate(500 * 1000);// 码率500kb/s
recorder.setVideoCodec(avcodec.AV_CODEC_ID_H264);
recorder.setPixelFormat(avutil.AV_PIX_FMT_YUV420P);
recorder.setAudioCodec(avcodec.AV_CODEC_ID_AAC);
try {
recorder.start();
recorderStatus = true;
} catch (FrameRecorder.Exception e) {
log.error("{} | 创建转码录制器异常!", sourceAddress, e);
setProgress(StreamMediaConstant.SERVICE_START_PROGRESS_FAILURE, "创建转码录制器失败!");
}
}
关于转复用与转码
转码:将视频解析成一帧帧数据,再按照指定格式封装成视频流。
转复用:获取的视频流不进行转码操作,直接再封装。
显然转复的形式性能消耗要小很多。但是这样用的前提,不进行转码操作的视频、音频格式也能被客户端解析。
参考:blog.csdn.net/eguid_1/art…
blog.csdn.net/eguid_1/art…
执行转码操作
Hls
/**
* 开启转流服务
*
*/
public void transform() {
if (!grabberStatus || !recorderStatus) {
return;
}
log.info("{} | 开启转流操作……", sourceAddress);
try {
grabber.flush();
} catch (FrameGrabber.Exception e) {
log.error("{} | 清空拉流器缓存失败!", sourceAddress, e);
}
String mapKey = sourceAddress + "-" + videoType;
setProgress(StreamMediaConstant.SERVICE_START_PROGRESS_SUCCESS, "");
StreamMediaConstant.MEDIA_INFO_MAP.put(mapKey, this);
// 服务启动完成
starting = false;
//时间戳计算
int errorCount = 0;
long currentTimeMillis;
running = true;
while (running) {
currentTimeMillis = System.currentTimeMillis();
// 出错次数过多时,重新建立连接或重启
if (errorCount > 10 || reConnection) {
if (transferFlag) {
// 重新建立连接对转复用不生效,所以进行重启服务
restartService = true;
break;
}
try {
log.info("{} | 重新建立链接……", sourceAddress);
grabber.restart();
grabber.flush();
// 重新建立建立成功,将错误次数清零
errorCount = 0;
} catch (FrameGrabber.Exception e1) {
log.error("{} | 重新建立连接失败", sourceAddress, e1);
errorCount++;
}
}
// 转流操作
try {
if (transferFlag) {
//转复用
AVPacket pkt = grabber.grabPacket();
if (null == pkt || pkt.isNull()) {
log.error("{} | pkt is null", sourceAddress);
errorCount++;
continue;
}
recorder.recordPacket(pkt);
av_packet_unref(pkt);
} else {
//转码
Frame frame = grabber.grabFrame();
if (frame == null) {
log.error("{} | frame is null", sourceAddress);
errorCount++;
continue;
}
recorder.record(frame);
}
} catch (Exception e) {
log.error("{} | 转流操作异常!", sourceAddress, e);
errorCount++;
}
// 检查hls连接是否存活。每3分钟执行一次
if (currentTimeMillis - httpCheckTime > (1000 * 60 * 3)) {
httpCheckTime = currentTimeMillis;
checkHlsHttp();
}
}
// close包含stop和release方法。录制文件必须保证最后执行stop()方法
try {
recorder.close();
grabber.close();
if (null != tscCache) {
tscCache.clear();
}
} catch (IOException e) {
log.error("{} | 转流操作结束,关闭流异常!", sourceAddress, e);
}
// 删除本地文件
File file = new File(thisVideoPath);
if (file.exists()) {
log.info("{} | 转流结束,删除本地文件。", sourceAddress);
FileUtil.del(file);
}
// 需要重启
if (restartService) {
// 1分钟之内只重启一次
log.info("{} | 重新启动……", sourceAddress);
restartService = false;
this.grabberStatus = false;
this.recorderStatus = false;
starting = true;
reConnection = false;
lastRestartTime = System.currentTimeMillis();
this.createGrabber().createRecodeRecorder().transform();
}
log.info("{} | 转流操作结束", sourceAddress);
StreamMediaConstant.MEDIA_INFO_MAP.remove(mapKey);
}
Flv
/**
* 开启转流服务
*
* @return view
*/
public void transform() {
if (!grabberStatus || !recorderStatus) {
return;
}
log.info("{} | 开启转流服务……", sourceAddress);
try {
grabber.flush();
} catch (FrameGrabber.Exception e) {
log.error("{} | 清空拉流器缓存失败", sourceAddress, e);
}
if (flvHeader == null) {
flvHeader = flvOutputStream.toByteArray();
flvOutputStream.reset();
}
String mapKey = sourceAddress + "-" + videoType;
setProgress(StreamMediaConstant.SERVICE_START_PROGRESS_SUCCESS, "");
StreamMediaConstant.MEDIA_INFO_MAP.put(mapKey, this);
// 服务启动完成
starting = false;
//时间戳计算
int errorCount = 0;
running = true;
long videoTS;
while (running) {
// 出错次数过多时,重新建立连接或重启
if (errorCount > 10) {
if (transferFlag) {
// 转码重新建立连接对转复用不生效,所以进行重启服务
restartService = true;
break;
}
try {
log.info("{} | 重新建立链接……", sourceAddress);
grabber.restart();
grabber.flush();
// 重新建立建立成功,将错误次数清零
errorCount = 0;
} catch (FrameGrabber.Exception e1) {
log.error("{} | 重新建立连接失败", sourceAddress, e1);
errorCount++;
}
}
try {
if (transferFlag) {
//转复用
AVPacket pkt = grabber.grabPacket();
if (null == pkt || pkt.isNull()) {
log.error("{} | pkt is null", sourceAddress);
errorCount++;
continue;
}
recorder.recordPacket(pkt);
av_packet_unref(pkt);
} else {
//转码
Frame frame = grabber.grabFrame();
if (frame == null) {
log.error("{} | frame is null", sourceAddress);
errorCount++;
continue;
}
recorder.record(frame);
}
} catch (Exception e) {
log.error("{} | 转流操作异常", sourceAddress, e);
errorCount++;
}
if (flvOutputStream.size() > 0) {
theLatestData = flvOutputStream.toByteArray();
// 发送视频到前端
sendFlvFrameData(theLatestData);
flvOutputStream.reset();
}
}
// close包含stop和release方法。录制文件必须保证最后执行stop()方法
try {
recorder.close();
grabber.close();
flvOutputStream.close();
} catch (IOException e) {
log.error("{} | 转流操作结束,关闭流异常。", sourceAddress, e);
}
// 进行重启
if (restartService) {
log.info("{} | 重新启动……", sourceAddress);
flvOutputStream = new ByteArrayOutputStream();
restartService = false;
this.grabberStatus = false;
this.recorderStatus = false;
starting = true;
firstPushAfterReStart = true;
this.createGrabber().createRecodeRecorder().transform();
}
log.info("{} | 结束转流服务。", sourceAddress);
StreamMediaConstant.MEDIA_INFO_MAP.remove(mapKey);
}
监听Hls请求
Hls协议的视频流,其本质是将视频切成一个个小的视频片段,然后客户端通过HTTP请求来获取这些视频片段,实现播放。
所以监听Hls协议的请求,只需要实现根据请求参数,从磁盘读取文件的功能即可。
public void getHlsFile(HttpServletRequest request, HttpServletResponse response, String playName) {
String ipAddress = StreamMediaUtil.getIpAddress(request);
response.addHeader("Content-Disposition", "attachment;filename=play.m3u8");
response.setHeader("Access-Control-Allow-Origin", "*");
if (playName.toLowerCase().endsWith("m3u8")) {
response.setContentType("application/x-mpegURL");
} else {
response.setContentType("video/MP2T");
}
/*
.M3U8 application/x-mpegURL or vnd.apple.mpegURL
.ts video/MP2T
*/
if (null == httpMap.get(ipAddress)) {
log.info("{} | {} 新增连接!当前连接数:{}", sourceAddress, ipAddress, httHlsClientMap.size() + 1);
}
httHlsClientMap.put(ipAddress, System.currentTimeMillis());
httpMap.put(ipAddress, System.currentTimeMillis());
String pathFile = thisVideoPath + playName;
byte[] fileByte = getFileByte(pathFile);
if (null == fileByte) {
return;
}
ServletOutputStream outputStream = null;
try {
outputStream = response.getOutputStream();
outputStream.write(fileByte);
outputStream.flush();
} catch (IOException e) {
e.printStackTrace();
} finally {
if (null != outputStream) {
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
/**
* 获取文件数据
*
* @param pathFile 文件地址
* @return
*/
private byte[] getFileByte(String pathFile) {
// 服务正在重启
if (starting || restartService) {
while (true) {
if (starting || restartService) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
break;
}
}
}
Object bites = null;
if (pathFile.endsWith(".ts")) {
// 获取最新的ts下标
tsCont = Integer.parseInt(pathFile.substring(pathFile.lastIndexOf("-") + 1, pathFile.lastIndexOf(".")));
if (this.tscCache.containsKey(pathFile)) {
bites = tscCache.get(pathFile);
}
}
if (null != bites) {
return (byte[]) bites;
}
File file = new File(pathFile);
if (!file.exists()) {
return null;
}
FileReader fileReader = FileReader.create(file);
byte[] bytes = fileReader.readBytes();
// 这里可以缓存一部分数据,当多个客户端同时播放时,减少磁盘文件读取速度。当缓存满后,会清除先放进去的对象
tscCache.put(pathFile, bytes, (1000 * 6));
return bytes;
}
监听Http-Flv请求
与Hls协议不同,Flv协议是以建立长连接的方式,来向客户端传送数据。
长连接可以通过websocket,也可以是其他方式。
这里使用了另外一种,直接以Http建立长连接。其本质是一个请求时间无限长的普通Http请求。
/**
* 新增http客戶端
*/
public void addFlvHttpClient(HttpServletRequest request, HttpServletResponse response) {
if (running) {
response.addHeader("Content-Disposition", "attachment;filename=\"" + playAddress + "\"");
response.setContentType("video/x-flv");
response.setHeader("Connection", "keep-alive");
response.setHeader("accept_ranges", "bytes");
response.setHeader("pragma", "no-cache");
response.setHeader("cache_control", "no-cache");
response.setHeader("transfer_encoding", "CHUNKED");
response.setHeader("SERVER", "hmsm");
String ipAddress = StreamMediaUtil.getIpAddress(request);
response.setStatus(200);
ServletOutputStream outputStream = null;
try {
outputStream = response.getOutputStream();
} catch (IOException e) {
log.error("{} | {} outputStream获取失败!", sourceAddress, ipAddress, e);
}
if (null == outputStream) {
return;
}
try {
outputStream.write(flvHeader, 0, flvHeader.length);
outputStream.flush();
} catch (IOException e) {
log.error("{} | {} 写入头部数据失败!", sourceAddress, ipAddress, e);
}
httpFlvClientMap.put(ipAddress, outputStream);
httpMap.put(ipAddress, System.currentTimeMillis());
log.info("{} | {} 新增连接!当前连接数:{}", sourceAddress, ipAddress, httpMap.size());
while (true) {
// 线程不释放,保持连接。
if (null == httpFlvClientMap.get(ipAddress)) {
// 当链接被移除后,则结束方法,关闭线程。
log.error("{} | {} 结束线程!", sourceAddress, ipAddress);
break;
}
try {
// 线程睡眠,只要线程不结束,就不会断掉http连接
// 不知道是否有更好的方式
Thread.sleep(1000 * 60 * 1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
private void sendFlvFrameData(byte[] data) {
Iterator<Map.Entry<String, ServletOutputStream>> iterator = httpFlvClientMap.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<String, ServletOutputStream> next = iterator.next();
ServletOutputStream outputStream = null;
try {
outputStream = next.getValue();
outputStream.write(data, 0, data.length);
outputStream.flush();
} catch (java.lang.Exception e) {
try {
if (null != outputStream) {
outputStream.close();
}
} catch (IOException ioException) {
ioException.printStackTrace();
}
httpMap.remove(next.getKey());
iterator.remove();
log.error("{} | {} | 推流失败,关闭连接。剩余连接:{}", sourceAddress, next.getKey(), httpMap.size(), e);
}
}
firstPushAfterReStart = false;
hasClient();
}
注意事项
- 转复用的方式长期执行后,会出现
null == pkt的情况,目前怀疑是和buffer_size的设置或视频源有关,没有找到解决方案,这里通过重启服务来应对。 - Hls的方式转流时,切片数量和切片时间都不应该太小,否则会出现延迟和卡顿的情况。当然这个和服务器性能和网络情况有关。
- 测试发现,Flv的形式,延迟在3~5秒左右,Hls的形式,延迟最低在6秒左右。Flv的性能也优于Hls。
源码地址
参考
简述HLS,HTTP,RTSP,RTMP协议的区别
JavaCV开发实战教程(JavaCV教程)
代码大量参考: EasyMedia