网络知识 娱乐 web端播放视频之rtsp协议转HLS

web端播放视频之rtsp协议转HLS

rtsp协议转HLS

  • rtsp转hls协议
    • 一、前言
      • 1、传统安防行业
      • 2、新兴直播行业
    • 二、实现
      • 1、rtsp转为HLS
      • 2、提供http服务
      • 3、组件封装dll
    • 三、测试
      • 1、使用vlc测试hls
      • 2、使用nginx测试hls
      • 3、实时性比对
    • 四、扩展
      • 1、使用ffmpeg指令转换hls
    • 五、文献

rtsp转hls协议

一、前言

不论是从事传统安防监控行业的或是做直播的行业都避免不了做音视频的播放,熟悉音视频播放的朋友应该知道,该行业设计的音视频对接协议很多,包括rtsp、rtmp、hls、onviff、gb28181等等,不同的协议针对不同的行业应用。接下来我们先来说说两个不同音视频行业对视频播放的行业做法,在说明之前我们先来了解一下相关的概念。

相关概念

  • HLS协议

    HTTP Live Streaming(缩写是HLS)是一个由苹果公司提出的基于HTTP的流媒体网络传输协议。是苹果公司QuickTime X和iPhone软件系统的一部分。它的工作原理是把整个流分成一个个小的基于HTTP的文件来下载,每次只下载一些。当媒体流正在播放时,客户端可以选择从许多不同的备用源中以不同的速率下载同样的资源,允许流媒体会话适应不同的数据速率。在开始一个流媒体会话时,客户端会下载一个包含元数据的extended M3U (m3u8)playlist文件,用于寻找可用的媒体流。HLS只请求基本的HTTP报文,与实时传输协议(RTP)不同,HLS可以穿过任何允许HTTP数据通过的防火墙或者代理服务器。它也很容易使用内容分发网络来传输媒体流。苹果公司把HLS协议作为一个互联网草案(逐步提交),在第一阶段中已作为一个非正式的标准提交到IETF。但是,即使苹果偶尔地提交一些小的更新,IETF却没有关于制定此标准的有关进一步的动作。HLS (HTTP Live Streaming)是苹果公司实现的基于 HTTP 的流媒体协议,可以实现流媒体的点播和直播播放。当然,起初是只支持苹果的设备,目前大多数的移动设备也都实现了该功能。HTML5 直接支持该协议。

  • WebRTC,名称源自网页即时通信(英语:Web Real-Time Communication)的缩写,是一个支持网页浏览器进行实时语音对话或视频对话的API。它于2011年6月1日开源并在Google、Mozilla、Opera支持下被纳入万维网联盟的W3C推荐标准。

1、传统安防行业

  • 桌面播放器

    由于音视频涉及到底层的编解码、视频对接协议等特性,需要大量的C/C++开发人员,传统的安防行业公司或做摄像机、录像机等硬件厂商从底层技术上讲都会有自己的一套独立的业务管理软件,其中集成了自家硬件音视频的播放以及行业各种音视频播放协议的对接,在销售自家硬件的同时都会赠送相关的软件,在监控室安装其软件即可做到实时视频、录像回放、云台控制、视频上墙、视频摘要、智能分析等多种多样的功能,其效果一般要好于其他对接方式,

    其竞争优势在于:

    (1)自家开发的软件通过c/c++底层协议对接效率更高、适配性更强、可扩展性更好。

    (2)对接协议丰富,能几乎兼容行业所有的摄像机和接入协议,开放包容性强。

    (3)能适配不同的机器,也可以采用硬件编解码,刷新速度快,视频渲染效率高。

    (4)除传统的安防之外,可集成或扩展自家其他业务系统功能,业务扩展方便。

    解决不了的硬伤:

    (1)用户必须安装指定厂家的桌面应用,无法兼容所有行业硬件或兼容性受限。

    (2)绑定集成商自家业务功能且部分软件都需要额外收费。

    (3)软件存在的问题无法得到及时解决和升级。

    (4)无法跨平台播放,各个平台都需要额外开发一套相应的桌面客户端软件。

  • 浏览器播放

    浏览器播放是传统行业的额外衍生,基于直播行业的兴起,用户对传统行业看视频的要求变得更高,从传统的桌面应用转移到免安装跨平台的安防应用,所以很多厂家相继推出了自己基于浏览器版本的安防系统,当然也包括了自己的业务应用,为了适应浏览器的应用,不同厂家有不同实现方法。

    (1)ActiveX控件(OCX控件)

    ​ 这种控件是微软的推出是基于其windows应用的一种com组件,可以应用到IE浏览器、桌面应用、word、excel开发或基于windows平台开发其他语言的应用中(如c#),对于自身windows平台的跨应用部署有很大优势,基于C/C++开发的厂家来说,从传统桌面应用转浏览器播放投入成本较少,只需将自家桌面应用稍作改动即可开发出自己的OCX控件,完成从桌面应用到浏览器应用的过渡,它有如下特性。

    • 开发语言丰富,微软提供了C++/C#/VB等多种语言开发OCX控件
    • 基于windows平台开发,只能应用于windows平台
    • 可以在windows平台中基于COM接口实现跨服务应用
    • 对浏览器限定较高,只能应用于IE浏览器(目前已经放弃IE使用edge,使用的还是谷歌内核)
    • 所有的浏览器使用都必须要安装微软的数字证书,浏览器配置复杂,需要相关证书费用
    • 无法兼容基于浏览器的视频相关协议如HLS、RTMP、webrtc等

    (2)RTSP协议播放器

    ​ rtsp是基于tcp/ip的握手协议,rtsp指令走tcp协议,数据包可以走基于udp的rtp打包协议或走基于rtsp指令连接的rtp打包协议,当然为了保证数据传输的稳定性,可能需要rtcp控制协议的支持。

    ​ 浏览器不是操作系统,目前不支持tcp/ip协议,不过与tcp/ip协议对应长连接协议就是 websocket协议,所以网络上基于rtsp协议的播放器,其原理还是基于websocket协议负载rtsp指令或rtp数据包来实现音视频数据的传输,在前端接收到音视频数据之后,在使用ecmascripten(c语言转前端js)实现音视频解码成一帧一帧的图像数据,然后将图片渲染到canvas画布中,实现视频的渲染。

    (3)HLS协议播放器

    ​ HLS是基于HTTP协议的边下边播放的协议,由于是基于http协议的,所以它是跨浏览器播放的,其通用性非常好,HLS的实现原理是服务器将视频切割成一小段一小段的ts文件,这些ts文件组成一个m3u8格式的商品描述文件,浏览器下载对应的m3u8文件后,根据其中的内容边下载边播放对应的视频文件。针对HLS协议视频播放有两种:第一种是实时播放,第二种是视频点播,如果是视频点播,则一次性将视频切割好并生成m3u8索引文件即可,如果是实时播放文件在由后台边切割边生成对应的m3u8索引文件,只不过该索引文件不生成结束标志即可,浏览器未发现结束标志会不停的请求索引文件和播放ts文件以达到实时点播的目的。

    (4)websocket协议

    ​ 根据上面的分析,我们知道,浏览器要支持握手协议则必须使用websocket进行播放,我们只需要后台将视频解码成图片,然后通过websocket协议将图片数据推送到前端即可,前端通过canvas画布渲染图片即可。这个在我的博客有提及如何实现以及对应demon,欢迎各位查询我的csdn(lixiang987654321)

2、新兴直播行业

(1)rtmp播放协议

​ 由于flash的强大,往年所有的浏览器都支持flash,我们使用action script即可实现雷同ocx控件的功能,包括设计到系统权限操作的功能,但是自从google带头宣布2021年12月份不在支持flash之后,这一可行方案也变得令人怀疑。由于rtmp是实时播放协议,和rtsp一样其实时性非常好,所以目前来讲依然在很多直播行业广泛应用。它有如下特性:

  • rtmp是实时点播协议,延迟低,实时性非常好
  • rtmp基于flash,要求浏览器必须支持flash插件
  • 基于tcp协议,音视频同传,不存在丢包问题

(2)webrtc播放协议

​ 因为基于rtmp直播协议仍然具有浏览器不可跨域的障碍(flash支持、权限限制、跨浏览器),无法正在能满足直播行业的低延时、跨平台、跨浏览器的需求,为了解决视频的采集及播放问题,Google相应推出了webrtc,目前已得到大部分浏览器支持,所以未来webrtc是直播行业的福音。

​ 总的来说,传统安防行业桌面应用向浏览器直播式的应用转变不是偶然,而是必然,是势在必行,技术永远是更新变化适应用户需求的,所以浏览器播放视频是一个硬性需求,是未来的发展趋势。而今天我要介绍的就是如何使用ffmpeg将传统行业的必然支持的rtsp协议视频转切割为ts文件并通过浏览器直接播放出来,而浏览器并不需要安装任何插件以及做任何配置即可实现web端播放实时视频的需求。

二、实现

1、rtsp转为HLS

使用ffmpeg获取到rtsp视频流,然后将rtsp视频流解复用然后复用为ts视频流并生成m3u8格式的索引文件是实现rtsp协议转HLS协议的问题关键,所以我们这里要实现就是rtsp取流以及hls编码。使用ffmpeg有两种方式

  • 直接使用ffmpeg.exe创建进行进行转码控制

    这种方式每一个命令都需要创建一个对应的ffmpeg进程,在关闭的时候需要手动杀死ffmpeg进程,这个方式对于不熟悉编解码的小白非常合适,这里我们不做小白。

  • 使用ffmpeg代码生成自己的应用程序进行转码

    为了能完全将ffmpeg植入到我们定制化的应用中,我们必须使用ffmpeg库从代码层上入手将输入流转化为ts流并提供http服务。

(1)rtsp取流

这里我们不展示完整代码,我们需要大致的ffmpeg取流流程是

  • 创建输入上下文
  • 打开视频输入流
  • 查询相关流和解码器
  • 循环读取帧数据
  • 处理帧数据
  • 关闭输入流

以上几个过程基本上是不会变化的,核心代码如下所示

		// 创建输入格式上下文
		m_input_context = avformat_alloc_context();
		if (NULL == m_input_context)
		{
			throw CIOException(RTSP_ERROR_INTERNAL, "创建输入格式上下文失败");
		}
		
		AVDictionary* param = NULL;
		// 打开rtsp地址
		int error = avformat_open_input(&m_input_context, url.c_str(), NULL, &param);
		if (error < 0)
		{
			throw CIOException(RTSP_ERROR_INTERNAL, "打开Rtsp地址[%s]错误[%d]", url.c_str(), error);
		}

		// 查找流信息
		error = avformat_find_stream_info(m_input_context, NULL);
		if (error < 0)
		{
			throw CIOException(RTSP_ERROR_INTERNAL, "查找流错误[%d]", error);
		}

		// 输出视频信息
		av_dump_format(m_input_context, -1, url.c_str(), 0);

		// 查找音视频流
		for (size_t index = 0; index nb_streams; ++index)
		{
			// 视频处理
			if (m_input_context->streams[index]->codec->codec_type == AVMEDIA_TYPE_VIDEO)
			{
				m_video_stream_id = index;
				video->width = m_input_context->streams[index]->codec->width;
				video->height = m_input_context->streams[index]->codec->height;
				video->pix_fmt = m_input_context->streams[index]->codec->pix_fmt;
				if (m_input_context->streams[index]->r_frame_rate.den > 0)
				{
					video->frame_rate = m_input_context->streams[index]->r_frame_rate.num/m_input_context->streams[index]->r_frame_rate.den;
					DebugString("视频帧率为:%dn", video->frame_rate);
				}
				else if (m_input_context->streams[index]->codec->framerate.den > 0)
				{
					video->frame_rate = m_input_context->streams[index]->codec->framerate.num/m_input_context->streams[index]->codec->framerate.den;
					DebugString("视频帧率为:%dn", video->frame_rate);
				}
			}
			// 音频处理
			else if (m_input_context->streams[index]->codec->codec_type == AVMEDIA_TYPE_AUDIO)
			{
				m_audio_stream_id = index;
				audio->sample_rate = m_input_context->streams[index]->codec->sample_rate;
				audio->channels = m_input_context->streams[index]->codec->channels;
				audio->sample_fmt = GetBitsBySampleFormat(m_input_context->streams[index]->codec->sample_fmt);
			}
		}

		// 无音频也无视频
		if (-1 == m_video_stream_id && -1 == m_audio_stream_id)
		{
			throw CIOException(RTSP_ERROR_INVALID_PARAM, "无效流地址[%s],未找到音视频流", url.c_str());
		}

		// 字节过滤器
		if ((strstr(m_input_context->iformat->name, "flv") != NULL) || 
			(strstr(m_input_context->iformat->name, "mp4") != NULL) || 
			(strstr(m_input_context->iformat->name, "mov") != NULL)) 
		{
			if (m_input_context->streams[m_video_stream_id]->codec->codec_id == AV_CODEC_ID_H264)
			{
				// 这里注意:"h264_mp4toannexb",一定是这个字符串,无论是 flv,mp4,mov格式
				m_vbsf_h264_toannexb = av_bitstream_filter_init("h264_mp4toannexb"); 
			} 
		}

以上部分是打开输入流部分源码,接下来就是读取编码并解码部分

	// 读取数据
	while (!m_stop)
	{
		// 初始化packet
		AVPacket packet;
		av_init_packet(&packet);

		// 从输入源读取一帧
		int error = av_read_frame(m_input_context, &packet);
		if(error < 0)
		{
			if (AVERROR_EOF == error)
			{
				DebugString("流读取结束,退出编码线程.n");
				WriteIndexFile(m_first_file_no, m_last_file_no, true, m_segment_durations);
				break;
			}
			else
			{
				DebugString("读取数据帧失败,错误码:%dn", error);
				av_free_packet(&packet);
				boost::this_thread::sleep(boost::posix_time::microseconds(1));
				continue;
			}
		}

		// 复制packet
		error = av_dup_packet(&packet);
		if (error < 0) 
		{
			DebugString("复制packet失败[%d]n",error);
			continue;
		}

		// 输出到文件
		WritePacket(&packet);

		// 释放数据
		av_free_packet(&packet);
	}

核心的部分其实是在转码部分WritePacket,下面会进行说明

(2)hls转码

针对读取到的数据包,我们要进行复用即编码过程,将解码得到的数据帧(作为编码的一个包)编码为ts流,这个过程需要注意的有几点

  • 编码为ts流的过程中达到设定的ts流长度时候需要单独生成一个ts文件并创建下一个ts文件
  • 生成ts文件的时候需要实时更新m3u8文件
  • 定时删除多余的ts文件,以免ts文件过多
  • 打开ts文件写入帧头,关闭ts文件是必须写入帧尾

ts文件写入过程如下,这里我摘录了核心代码

    // 视频时间戳计算
	if (packet->stream_index == m_video_stream_id) 
	{
		// h264转annexb
		if (m_vbsf_h264_toannexb != NULL)
		{
			// 每个AVPacket的data添加了H.264的NALU的起始码{0,0,0,1}
			// 每个IDR帧数据前面添加了SPS和PPS
			AVPacket new_pkt = *packet; 
			int a = av_bitstream_filter_filter(m_vbsf_h264_toannexb,                                           
											   m_out_video_stream->codec, NULL, 
											   &new_pkt.data,
											   &new_pkt.size, 
											   packet->data, packet->size, 
											   packet->flags & AV_PKT_FLAG_KEY); 
			if (a > 0)
			{                
				// *packet = new_pkt;
				av_free_packet(packet); 
				packet->pts = new_pkt.pts;
				packet->dts = new_pkt.dts;
				packet->duration = new_pkt.duration;
				packet->flags = new_pkt.flags;
				packet->stream_index = new_pkt.stream_index;
				packet->data = new_pkt.data;  
				packet->size = new_pkt.size;
			}             
			else if (a filter->name, packet->stream_index,
						m_out_video_stream->codec->codec? m_out_video_stream->codec->codec->name:"copy");
				av_free_packet(packet); 
			}
		}

		packet->pts = av_rescale_q_rnd(packet->pts, m_input_context->streams[m_video_stream_id]->time_base, m_out_video_stream->time_base, AV_ROUND_NEAR_INF);
		packet->dts = av_rescale_q_rnd(packet->dts, m_input_context->streams[m_video_stream_id]->time_base, m_out_video_stream->time_base, AV_ROUND_NEAR_INF);
		packet->duration = av_rescale_q(packet->duration,m_input_context->streams[m_video_stream_id]->time_base, m_out_video_stream->time_base);
		
		// 注意添加音视频顺序与package打包write顺序保持一致否则写入失败
		packet->stream_index = VIDEO_ID;
	}
	else if (packet->stream_index == m_audio_stream_id)
	{
		packet->pts = av_rescale_q_rnd(packet->pts, m_input_context->streams[m_audio_stream_id]->time_base, m_out_audio_stream->time_base, AV_ROUND_NEAR_INF);
		packet->dts = av_rescale_q_rnd(packet->dts, m_input_context->streams[m_audio_stream_id]->time_base, m_out_audio_stream->time_base, AV_ROUND_NEAR_INF);
		packet->duration = av_rescale_q(packet->duration,m_input_context->streams[m_audio_stream_id]->time_base, m_out_audio_stream->time_base);
		
		// 注意添加音视频顺序与package打包write顺序保持一致否则写入失败
		packet->stream_index = AUDIO_ID;
	}

	// 当前时间-上一时间戳=包周期(+0.5为了取整)
	unsigned int current_segment_duration = (unsigned int)(segment_time - m_previouse_time_stamp + 0.5);
	
	// 更新当前分片周期
	if (m_last_file_no == 0)
	{
		m_segment_durations[m_last_file_no] = (current_segment_duration > 0 ? current_segment_duration : 1);
	}
	else 
	{
		m_segment_durations[m_last_file_no - m_first_file_no + 1] = (current_segment_duration > 0 ? current_segment_duration : 1);
	}

	// 达到设定的分片周期(达到生成一个ts文件限定)
	int error = 0;
	if (segment_time - m_previouse_time_stamp >= m_segmentDuration) 
	{
		// 写文件尾部信息
		error = av_write_trailer(m_output_context);
		if (error pb);
		avio_close(m_output_context->pb);

		// 达到设定的最大分片数则开始从第一个删除文件
		if (m_maxFileSize > 0 && m_last_file_no > 0 && m_last_file_no - m_first_file_no >= m_maxFileSize - 1)
		{
			m_remove_file = true;
			m_first_file_no++;
		}
		else 
		{
			m_remove_file = false;
		}

		// 更新m3u8文件信息:追加该ts文件的信息
		WriteIndexFile(m_first_file_no, ++m_last_file_no, false, m_segment_durations);

		// 达到最大时开始移除第一个文件
		if (m_remove_file) 
		{
			char remove_filename[1024] = {0};
			sprintf(remove_filename,"%s/%s-%I64u.ts",m_dir.c_str(), m_filePrefix.c_str(), m_first_file_no - 1);
			::remove(remove_filename);
		}

		// 输出当前ts文件名称
		sprintf(m_output_file_name,"%s/%s-%I64u.ts", m_dir.c_str(), m_filePrefix.c_str(), m_output_index++);
		if (avio_open(&m_output_context->pb, m_output_file_name, AVIO_FLAG_WRITE) < 0)
		{
			DebugString("打开文件失败[%s]n", m_output_file_name);
			return RTSP_ERROR_INTERNAL;
		}

		// 在每一个新文件中写入一个头
		error = avformat_write_header(m_output_context, NULL);
		if (error != 0)
		{
			DebugString("写文件[%s]头失败%dn", error);
			return RTSP_ERROR_INTERNAL;
		}

		// 当前文件最后时间戳作为上一个ts文件时间戳
		m_previouse_time_stamp = segment_time;
	}

更新m3u8文件的过程如下

int CRtspClient::WriteIndexFile(UINT64 first_segment, UINT64 last_segment, bool end, unsigned int* segment_durations)
{
	// m3u8文件
	char m3u8_file_pathname[256] = {0};
	sprintf(m3u8_file_pathname,"%s/index.m3u8", m_dir.c_str());

	// 打开m3u8文件
	FILE * index_fp = fopen(m3u8_file_pathname,"w");
	if (!index_fp)
	{
		DebugString("打开索引文件[%s]失败n", m3u8_file_pathname);
		return -1;
	}

	// 写入缓存内容
	char * write_buf = (char *)malloc(sizeof(char) * 1024);
	if (!write_buf) 
	{
		DebugString("写索引文件分配内存失败[%s]n", m3u8_file_pathname);
		fclose(index_fp);
		return -1;
	}
	memset(write_buf, 0, sizeof(char)*1024);

	// 格式化m3u8文件头部
	if (m_maxFileSize > 0)
	{
		// #EXT-X-MEDIA-SEQUENCE: 播放列表文件中每个媒体文件的URI都有一个唯一的序列号。
		// URI的序列号等于它之前那个RUI的序列号加一(没有填0)
		sprintf(write_buf,"#EXTM3Un#EXT-X-TARGETDURATION:%lun#EXT-X-MEDIA-SEQUENCE:%I64un", m_segmentDuration, first_segment);
	}
	else 
	{
		sprintf(write_buf,"#EXTM3Un#EXT-X-TARGETDURATION:%lun", m_segmentDuration);
	}

	// 写m3u8文件头部
	if (fwrite(write_buf, strlen(write_buf), 1, index_fp) != 1) 
	{
		DebugString("写索引文件内容失败[%s]n", m3u8_file_pathname);
		free(write_buf);
		fclose(index_fp);
		return -1;
	}

	// 写m3u8文件分片信息
	for (UINT64 i = first_segment; last_segment > 0 && i <= last_segment; i++) 
	{
		sprintf(write_buf,"#EXTINF:%u,n%s-%I64u.tsn",segment_durations[i-1], m_filePrefix.c_str(), i);
		if (fwrite(write_buf, strlen(write_buf), 1, index_fp) != 1) 
		{
			DebugString("写索引文件切片信息失败[%s]n", m3u8_file_pathname);
			free(write_buf);
			fclose(index_fp);
			return -1;
		}
	}

	// 写m3u8文件尾部
	if (end) 
	{
		sprintf(write_buf,"#EXT-X-ENDLISTn");
		if (fwrite(write_buf, strlen(write_buf), 1, index_fp) != 1)
		{
			DebugString("写索引文件结束信息失败[%s]n", m3u8_file_pathname);
			free(write_buf);
			fclose(index_fp);
			return -1;
		}
	}

	// 关闭索引文件
	free(write_buf);
	fclose(index_fp);
	return 0;
}

m3u8生成的案例如下

#EXTM3U
#EXT-X-TARGETDURATION:5
#EXT-X-MEDIA-SEQUENCE:36
#EXTINF:5,
lixx-36.ts
#EXTINF:5,
lixx-37.ts
#EXTINF:5,
lixx-38.ts
#EXTINF:5,
lixx-39.ts
#EXTINF:5,
lixx-40.ts
#EXTINF:5,
lixx-41.ts
#EXTINF:5,
lixx-42.ts
#EXTINF:5,
lixx-43.ts
#EXTINF:5,
lixx-44.ts
#EXTINF:5,
lixx-45.ts
#EXT-X-ENDLIST

其中#EXTM3U表示m3u8文件的标志,EXT-X-TARGETDURATION表示每个切片分分片周期,EXT-X-MEDIA-SEQUENCE表示第一个分片周期从第几开始,#EXTINF:5,表示当前分片有5秒,当前分片名为lixx-36.ts,#EXT-X-ENDLIST表示这个m3u8文件已经结束,如果是实时的则不写入这个结束标志,播放器会一直请求下载文件直到播放器关闭为止。

关于m3u8文件其他字段含义如下所示

HLS Playlists

Playlist 文件的格式是起源于 M3U, 并且继承两个 tag: EXTM3U 和 EXTINF 下面的 tags 通过 BNF-style 语法来指定.

  • 一个 Playlist 文件必须通过 URI(.m3u8 或 m3u) 或者 HTTP Content-Type 来识别(application/vnd.apple.mpegurl 或 audio/mpegurl).
  • 换行符可以用 n 或者 rn.
  • 以 # 开头的是 tag 或者注释, 以 #EXT 开头的是 tag, 其余的为注释, 在解析时应该忽略.
  • Playlist 里面的 URI 可以用绝对地址或者相对地址, 如果使用相对地址, 那么是相对于 Playlist 文件的地址.

Attribute Lists

  • 有的 tags 的值是 Attribute Lists.
  • 一个 Attribute List 是一个用逗号分隔的 attribute/value 对列表.
  • 格式为: AttributeName=AttributeValue.

Basic Tags

Basic Tags 可以用在 Media Playlist 和 Master Playlist 里面.

  • EXTM3U: 必须在文件的第一行, 标识是一个 Extended M3U Playlist 文件.
  • EXT-X-VERSION: 表示 Playlist 兼容的版本.

Media Segment Tags

每一个 Media Segment 通过一系列的 Media Segment tags 跟一个 URI 来指定. 有的 Media Segment tags 只应用与下一个 segment, 有的则是应用所有下面的 segments. 一个 Media Segment tag 只能出现在 Media Playlist 里面.

  • EXTINF: 用于指定 Media Segment 的 duration
  • EXT-X-BYTERANGE: 用于指定 URI 的 sub-range
  • EXT-X-DISCONTINUITY: 表示不连续.
  • EXT-X-KEY: 表示 Media Segment 已加密, 该值用于解密.
  • EXT-X-MAP: 用于指定 Media Initialization Section.
  • EXT-X-PROGRAM-DATE-TIME: 和 Media Segment 的第一个 sample 一起来确定时间戳.
  • EXT-X-DATERANGE: 将一个时间范围和一组属性键值对结合到一起.

Media Playlist Tags

Media Playlist tags 描述 Media Playlist 的全局参数. 同样地, Media Playlist tags 只能出现在 Media Playlist 里面.

  • EXT-X-TARGETDURATION: 用于指定最大的 Media Segment duration.
  • EXT-X-MEDIA-SEQUENCE: 用于指定第一个 Media Segment 的 Media Sequence Number.
  • EXT-X-DISCONTINUITY-SEQUENCE: 用于不同 Variant Stream 之间同步.
  • EXT-X-ENDLIST: 表示结束.
  • EXT-X-PLAYLIST-TYPE: 可选, 指定整个 Playlist 的类型.
  • EXT-X-I-FRAMES-ONLY: 表示每个 Media Segment 描述一个单一的 I-frame.

Master Playlist Tags

Master Playlist tags 定义 Variant Streams, Renditions 和 其他显示的全局参数. Master Playlist tags 只能出现在 Master Playlist 中.

  • EXT-X-MEDIA: 用于关联同一个内容的多个 Media Playlist 的多种 renditions.
  • EXT-X-STREAM-INF: 用于指定一个 Variant Stream.
  • EXT-X-I-FRAME-STREAM-INF: 用于指定一个 Media Playlist 包含媒体的 I-frames.
  • EXT-X-SESSION-DATA: 存放一些 session 数据.
  • EXT-X-SESSION-KEY: 用于解密.

Media or Master Playlist Tags

这里的 tags 可以出现在 Media Playlist 或者 Master Playlist 中. 但是如果同时出现在同一个 Master Playlist 和 Media Playlist 中时, 必须为相同值.

  • EXT-X-INDEPENDENT-SEGMENTS: 表示每个 Media Segment 可以独立解码.
  • EXT-X-START: 标识一个优选的点来播放这个 Playlist.

(2)hls转码

2、提供http服务

http服务是c/c++开发者的硬伤,没有现成成熟的标准库,为了能将m3u8和ts文件以http协议共享出去,我们可以借助nginx或者自身服务提供http服务

  • nginx对外提供http服务

    nginx是一个小型、高性能的反向代理服务器,支持http和https等。关于nginx的配置则非常简单,这里我不在描述。如果使用这种模式提供http,性能不用担心,但是存在的问题就是

    • 需要额外安装和配置nginx服务
    • 中间产生的文件目录要需要动态代理或代理指定唯一父级别代理目录(与生成ts文件的服务匹配)
    • 无法动态回收无点播的实时临时文件
  • 自身应用提供http服务

    这种方式不需要安装和配置外部服务,依靠服务本身的http能力对外提供http服务,并且可以动态监控http请求的视频路径,做到实时回收临时文件能力,这里我们可以借助github上的httplib项目,很轻松的达到该目的。这里不在提供源码。

3、组件封装dll

为了能演示效果,我将rtsp转hls协议的核心源码封装成了一个dll组件供各个项目使用,接口很简单,包括初始化库、打开rtsp地址、保存为hls接口、关闭rtsp以及反初始化5个接口,各个接口如下所示

/************************************************************************
** 文  件:
**	Rtsp2HLSSdk.h
** 功  能:
**	提供rtsp协议转hls协议功能
** 作  者:
**	email:lixx2048@163.com
**  wechat:lixiang6153
**  QQ:941415509
** 日  期:
**	2021/10/31	14:00:00
** 说  明:
**	rtsp视频目前仅支持h264格式,如需支持h265请自行开发(这个比较简单)
** 版  本:
**	2021/11/11	14:00:00		1.0		lixx2048@163.com	
/************************************************************************/
#pragma once

#ifdef RTSPCLIENTSDK_EXPORTS
#define RTSPCLIENTSDK_API extern "C" __declspec(dllexport)
#else
#define RTSPCLIENTSDK_API extern "C" __declspec(dllimport)
#endif

namespace RtspHls
{
	// 系统句柄-兼容32以及64
	typedef void* RTSP_HLS_HANDLE;

	// 系统错误码
	enum E_RTSP_HLS_ERROR_CODE
	{
		// 成功,没有错误
		RTSP_ERROR_SUCCESS				=  0,
		// 无效的句柄
		RTSP_ERROR_INVALID_HANDLE		= -1,	
		// 内存溢出
		RTSP_ERROR_MEMOUT				= -2,		
		// 参数错误
		RTSP_ERROR_INVALID_PARAM		= -3,	
		// 服务内部错误,查看参数是否正确
		RTSP_ERROR_INTERNAL				= -4,	
		// 已经读取完毕,已没有数据
		RTSP_ERROR_NO_MORE_DATA			= -5,
		// 不支持该的类型
		RTSP_ERROR_NOT_SUPPORT			= -6,
		// 用户名密码错误
		RTSP_ERROR_USER_PASSWORD		= -7,
		// 调用顺序错误
		RTSP_ERROR_BAD_INVOKE_ORDER		= -8,
		// 视频源异常
		RTSP_ERROR_BAD_VIDEO_SOURCE		= -9,
	};

	// 视频参数
	typedef struct 
	{
		// 视频宽度
		int width;
		// 视频高度
		int height;
		// 像素格式
		int pix_fmt;
		// 视频帧率
		int frame_rate;
	}RTSP_VIDEO_INFO;

	// 音频参数
	typedef struct
	{
		// 音频采样率
		int sample_rate;
		// 声道数量
		int channels;
		// 样本格式
		int sample_fmt;
	}RTSP_AUDIO_INFO;

	//====================================================================
	// 功  能:
	//	rtsp sdk库初始化
	// 参  数:
	//	logLevel	:  日志打印级别
	// 返回值:
	//	无
	// 说  明:
	//	当初始化系统的时候调用该api,用于库内部资源初始化
	//  -8:不打印 0:崩溃日志 8:致命 16:错误 24:警告 32:信息 40:详情
	//====================================================================
	RTSPCLIENTSDK_API void Rtsp2HLS_InitLib(int logLevel);

	//====================================================================
	// 功  能:
	//	rtmp sdk库卸载
	// 参  数:
	//	无
	// 返回值:
	//	无
	// 说  明:
	//	当系统退出的时候调用该api,用于库内部资源卸载
	//====================================================================
	RTSPCLIENTSDK_API void Rtsp2HLS_UnInitLib(void);

	//====================================================================
	// 功  能:
	//	创建rstp播放句柄
	// 参  数:
	//	[输入]	use_tcp		:	是否使用rtsp over tcp模式
	// 返回值:
	//	成功返回非空句柄,失败返回NULL
	// 说  明:
	//	该接口创建的句柄用于后续接口,不使用时通过Rtsp2HLS_Close关闭
	//====================================================================
	RTSPCLIENTSDK_API RTSP_HLS_HANDLE Rtsp2HLS_Create(bool use_tcp);

	//====================================================================
	// 功  能:
	//	打开rtsp视频流
	// 参  数:
	//	[输入]	handle		:	Rtsp2HLS_Create返回
	//	[输入]	url			:	rtsp地址
	//	[输出]	video		:	视频参数信息
	//	[输出]	audio		:	音频参数信息
	// 返回值:
	//	详细见E_RTSP_ERROR_CODE说明
	// 说  明:
	//	可以通过返回的音视频参数信息进行录像等控制
	//====================================================================
	RTSPCLIENTSDK_API E_RTSP_HLS_ERROR_CODE Rtsp2HLS_Open(RTSP_HLS_HANDLE handle, const char* url, RTSP_VIDEO_INFO* video, RTSP_AUDIO_INFO* audio);

	//====================================================================
	// 功  能:
	//	开始播放rtsp音视频流
	// 参  数:
	//	[输入]	handle		:	Rtsp2HLS_Create返回
	//	[输入]	decode		:	回调数据是否需要解码得到YUV420P裸数据
	// 返回值:
	//	详细见E_RTSP_ERROR_CODE说明
	// 说  明:
	//	Rtsp2HLS_Open之后没有数据返回必须启动播放后才有数据返回
	//====================================================================
	RTSPCLIENTSDK_API E_RTSP_HLS_ERROR_CODE Rtsp2HLS_Play(RTSP_HLS_HANDLE handle, bool decode);

	//====================================================================
	// 功  能:
	//	将数据保存到本地文件或网络地址中
	// 参  数:
	//	[输入]	handle			:	Rtsp2HLS_Create返回
	//	[输入]	fileDir			:	m3u8文件目录
	//	[输入]	filePrefix		:	ts文件前缀(文件名=前缀_序号.ts)
	//	[输入]	maxFileSize		:	保存最大文件数,大于等于0,0为无限个
	//	[输入]	segmentDuration	:	每一个文件的切片周期,默认10秒
	// 返回值:
	//	详细见E_RTSP_ERROR_CODE说明
	// 说  明:
	//	保存到网络地址注意服务端已经处于启动状态否则保存失败
	//====================================================================
	RTSPCLIENTSDK_API E_RTSP_HLS_ERROR_CODE Rtsp2HLS_Save(RTSP_HLS_HANDLE handle, const char* fileDir, const char* filePrefix = "lixx", unsigned int maxFileSize = 20, unsigned int segmentDuration = 10);

	//====================================================================
	// 功  能:
	//	抓拍图片并存储到本地
	// 参  数:
	//	[输入]	handle		:	Rtsp2HLS_Create返回
	//	[输入]	filePath	:	本地文件全路径
	// 返回值:
	//	详细见E_RTSP_ERROR_CODE说明
	// 说  明:
	//	1、必须保证本地磁盘必须有写权限
	//	2、该接口为异步接口,抓拍需要时间,抓拍成功与否需查看图片是否存在
	//	3、目前仅支持JPEG格式图片
	//	4、为了效率,该接口仅支持解码模式下即Rtsp2HLS_Play参数decode为true时
	//====================================================================
	RTSPCLIENTSDK_API E_RTSP_HLS_ERROR_CODE Rtsp2HLS_Capture(RTSP_HLS_HANDLE handle, const char* filePath);

	//====================================================================
	// 功  能:
	//	关闭rstp播放句柄
	// 参  数:
	//	[输入]	handle		:	Rtsp2HLS_Create返回
	// 返回值:
	//	详细见E_RTSP_ERROR_CODE说明
	// 说  明:
	//	关闭Rtsp2HLS_Create创建的rtsp播放句柄
	//====================================================================
	RTSPCLIENTSDK_API E_RTSP_HLS_ERROR_CODE Rtsp2HLS_Close(RTSP_HLS_HANDLE handle);
};

该sdk的使用就很简单了,这里也写了一个demon供测试

// Rtsp2HLSDemon.cpp : Defines the entry point for the console application.
//
#include "stdafx.h"

int _tmain(int argc, _TCHAR* argv[])
{
	// 初始化rtsp2hls库
	Rtsp2HLS_InitLib(40);

	// 获取输入rtsp地址
	char rtsp[1024] = {0};
	cout <> rtsp;

	if (strlen(rtsp) == 0 || strcmp(rtsp, "no") == 0)
	{
		strcpy_s(rtsp, 1024, "rtsp://admin:dw123456@192.168.8.136:554/h264/ch1/sub/av_stream");
		//strcpy_s(rtsp, 1024, "./avier1.mp4");
	}

	// 创建rtsp转换句柄
	RTSP_HLS_HANDLE handle = Rtsp2HLS_Create(true);
	if (NULL == handle)
	{
		cout << "创建句柄失败!" << endl;
		return 0;
	}

	// 打开rtsp视频流
	RTSP_VIDEO_INFO video;
	RTSP_AUDIO_INFO audio;
	E_RTSP_HLS_ERROR_CODE error = Rtsp2HLS_Open(handle, rtsp, &video, &audio);
	if (RTSP_ERROR_SUCCESS != error)
	{
		cout << "打开rtsp失败:" << rtsp << endl;
		return 0;
	}

	// 保存hls视频流
	error = Rtsp2HLS_Save(handle, "./test", "lixx", 10, 5);
	if (RTSP_ERROR_SUCCESS != error)
	{
		cout << "保存m3u8文件失败:" << error << endl;
		return 0;
	}

	// 开始解码播放
	error = Rtsp2HLS_Play(handle, false);
	if (RTSP_ERROR_SUCCESS != error)
	{
		cout << "播放rtsp视频失败:" << error <> cmd;
		if (0 == strcmp(cmd, "exit"))
		{
			break;
		}
	}

	// 关闭转码句柄
	Rtsp2HLS_Close(handle);

	// 反初始化网络库
	Rtsp2HLS_UnInitLib();
	return 0;
}

三、测试

这里我准备的摄像机为海康摄像机地址为192.168.8.136,根据海康对外提供的地址,获取的rtsp地址如下

rtsp://admin:dw123456@192.168.8.136:554/h264/ch1/sub/av_stream

我们可以使用vlc测试视频是否可以查看
请添加图片描述
接下来我们启动一个rtsp转hls的demon程序,然后输入rtsp地址(或输入no则采用以上默认rtsp地址)
请添加图片描述
程序会自动解复用和复用为ts文件,具体可以看到ffmpeg的日志详情
请添加图片描述
同时,我们也可以看到程序目录下已经实时在生成ts文件以及m3u8索引文件,然后可以输入"exit"退出程序(当然不退出有可以作为直播一直看下去),这里我们后面为了演示nginx,需要停止后将所有文件拷贝到nginx代理目录进行代理查看hls视频。
请添加图片描述

1、使用vlc测试hls

我们可以直接将index.m3u8文件拖拽到vlc中进行播放,查看视频播放是否正常
请添加图片描述
可以看到切割的m3u8播放是没有问题的

注意:低版本的vlc可能不支持m3u8文件的播放,此外vlc对m3u8支持性也不是很好,在播放下一个分片的时候会卡顿一下。

2、使用nginx测试hls

我们将刚生成的ts文件夹test全部拷贝到nginx的html目录,进行反向代理
请添加图片描述
nginx的反向代理配置如下所示,保持默认(这里我仅仅追加了允许跨域访问配置)

    server {
        listen       80;
        server_name  localhost;

        #charset koi8-r;

        #access_log  logs/host.access.log  main;
		
		add_header Access-Control-Allow-Origin *;
		add_header Access-Control-Max-Age 1728000;
		add_header Access-Control-Allow-Methods GET,POST,OPTIONS;
		add_header Access-Control-Allow-Headers  'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';

        location / {
            root   html;
            index  index.html index.htm;
        }

        #error_page  404              /404.html;

        # redirect server error pages to the static page /50x.html
        #
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }

        # proxy the PHP scripts to Apache listening on 127.0.0.1:80
        #
        #location ~ .php$ {
        #    proxy_pass   http://127.0.0.1;
        #}

        # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
        #
        #location ~ .php$ {
        #    root           html;
        #    fastcgi_pass   127.0.0.1:9000;
        #    fastcgi_index  index.php;
        #    fastcgi_param  SCRIPT_FILENAME  /scripts$fastcgi_script_name;
        #    include        fastcgi_params;
        #}

        # deny access to .htaccess files, if Apache's document root
        # concurs with nginx's one
        #
        #location ~ /.ht {
        #    deny  all;
        #}
    }

然后,我们可以随便找一个m3u8在线播放器测试hls播放是否正常,这里我随便搜索了一个在线播放器:http://tool.liumingye.cn/m3u8/,然后输入如下hsl地址

http://localhost/test/index.m3u8

可以看到在线播放器播放正常且很流畅
请添加图片描述

3、实时性比对

我们可以通过vlc直接点播rtsp(实时协议)与hls播放器点播进行比对,查看hls的延时性,经过测试可以发现hls实时性切片时长设置为1秒的时候,延时性可以控制在2秒的左右演示,这个已经非常接近实时播放了!!这里我给出一个动画展示hls的实时性
请添加图片描述

四、扩展

1、使用ffmpeg指令转换hls

我们可以直接使用ffmpeg.exe启动进程进行rtsp转hls,这个对于不熟悉c/c++开发的来说是非常方便的,执行指令如下所示

ffmpeg -i rtsp://admin:dw123456@192.168.8.136:554/h264/ch1/sub/av_stream -s 640x360 -fflags flush_packets -max_delay 1 -an -flags -global_header -hls_time 1 -hls_list_size 5 -hls_wrap 5 -vcodec copy -y  E:nginx-1.18.0html1.m3u8
  • 我们拉去的rtsp地址为:rtsp://admin:dw123456@192.168.8.136:554/h264/ch1/sub/av_stream
  • 保存的m3u8文件为:E:nginx-1.18.0html1.m3u8
  • hls_list_size指定最多5个ts文件,多余或自动循环删除
  • hls_time分片时间为2s

因为保存在nginx代理目录,我们直接通过地址即可访问hls文件:http://localhost/1/1.m3u8
请添加图片描述

五、文献

感谢如下作者分享:

https://github.com/Streamedian/html5_rtsp_player

源码获取、合作、技术交流请获取如下联系方式:

QQ交流群:961179337
请添加图片描述
微信账号:lixiang6153
公众号:IT技术快餐
电子邮箱:lixx2048@163.com