技术背景
GB28181 的补录功能是一种用于弥补视频数据缺失的重要机制。在实际的视频监控场景中,由于网络不稳定、设备故障等多种因素,可能会导致视频数据在上云或存储过程中出现缺失,无法保证数据的完整性。GB28181 的补录功能就是为了解决这一问题而设计的,其目的是在数据缺失后,通过特定的机制拉取缺失时间段的本地录像,以补齐云端或存储系统中的视频数据。
实现原理与机制
监测与标记:系统会实时监测视频流上云或传输的状态,一旦发现视频流中断,立即标记通道信息和中断时间点。
补录触发:根据中断时间点,自动触发补录机制,与设备进行交互,尝试拉取设备本地存储中对应缺失时间段的录像流。如果某时间段的录像片段在补录过程中失败,系统会将该片段放入队列等待下一次拉取,但如果在规定时间内(例如 48 小时)仍无法补录,系统将放弃对该片段的补录。
数据合并:将拉取到的设备本地录像与已有的云端或存储系统中的录像进行合并,从而实现视频数据的补齐。
应用场景与限制
应用场景:适用于对视频数据完整性要求较高的场景,如安防监控系统、交通监控系统等。例如,在一个城市的交通监控系统中,如果某个路口的监控设备因网络故障导致一段时间内的视频数据缺失,补录功能可以在网络恢复后自动拉取缺失的视频数据,保证交通监控数据的连续性和完整性。
限制条件:一般来说,补录功能可能会受到设备本地存储容量、存储时间、网络状况等因素的限制。例如,如果设备本地没有存储缺失时间段的录像,或者设备离线,那么就无法进行补录。另外,对于单个片段缺失时间过短(如小于 2 秒)或过长(如大于 1 小时)的情况,可能也不支持补录。
实际操作与配置
在支持 GB28181 补录功能的监控系统或平台中,用户需要进行相应的配置和设置,例如开启录像补录功能、设置补录计划等。一些系统还提供补录报告功能,用户可以查看每日录像的完整度、补录总时长、补录明细等信息。
Android平台GB28181设备接入模块实现
本文以大牛直播SDK的Android平台GB28181设备接入模块为例,目前,Android平台GB28181接入模块,实现的功能如下:
- [视频格式]H.264/H.265(Android H.265硬编码);
- [音频格式]G.711 A律、AAC;
- [音量调节]Android平台采集端支持实时音量调节;
- [H.264硬编码]支持H.264特定机型硬编码;
- [H.265硬编码]支持H.265特定机型硬编码;
- [软硬编码参数配置]支持gop间隔、帧率、bit-rate设置;
- [软编码参数配置]支持软编码profile、软编码速度、可变码率设置;
- 支持横屏、竖屏推流;
- Android平台支持后台service推送屏幕(推送屏幕需要5.0+版本);
- 支持纯视频、音视频PS打包传输;
- 支持RTP OVER UDP和RTP OVER TCP被动模式(TCP媒体流传输客户端);
- 支持信令通道网络传输协议TCP/UDP设置;
- 支持注册、注销,支持注册刷新及注册有效期设置;
- 支持设备目录查询应答;
- 支持心跳机制,支持心跳间隔、心跳检测次数设置;
- 支持移动设备位置(MobilePosition)订阅和通知;
- 适用国家标准:GB/T 28181—2016、GB/T28181—2022;
- 支持语音广播;
- 支持语音对讲;
- 支持图像抓拍;
- 支持历史视音频文件检索;
- 支持历史视音频文件下载;
- 支持历史视音频文件回放;
- 支持云台控制和预置位查询;
- [实时水印]支持动态文字水印、png水印;
- [镜像]Android平台支持前置摄像头实时镜像功能;
- [实时静音]支持实时静音/取消静音;
- [实时快照]支持实时快照;
- [降噪]支持环境音、手机干扰等引起的噪音降噪处理、自动增益、VAD检测;
- [外部编码前视频数据对接]支持YUV数据对接;
- [外部编码前音频数据对接]支持PCM对接;
- [外部编码后视频数据对接]支持外部H.264数据对接;
- [外部编码后音频数据对接]外部AAC数据对接;
- [扩展录像功能]支持和录像SDK组合使用,录像相关功能。
可以知道,云端平台补录的前提是,需要Android端GB28181设备接入模块,支持本地录像,并确保本地录像和实时回传的流,同一个分辨率码率帧率。这样,平台发起补录请求的时候(实际上是类似于历史视音频回放),会给出需要补录的时间段数据,然后,Android平台GB2818设备接入端,把涉及到的文件名列出来,并回给云端国标平台,平台发起INVITE回传请求,拉取数据即可。需要注意的是,云端国标平台需要补录的数据,可能会跨文件读取。
由于云端录像补录,走的是GB28181历史视音频文件回放操作,我们来看下,历史视音频文件回放基本要求:
- 需采用 SIP 协议中的 Invite 方法实现会话连接;
- 采用SIP扩展协议Info方法的消息体携带视音频回放控制命令;
- 采用 RTP/RTCP 协议实现媒体传输;
- 媒体回放控制命令引用MANSRTSP协议中的 PlayPause、Teardown 的请求消息和应答消息;
- 历史视音频的回放宜支持媒体流保活机制。
基本流程如下:
本文结合Android平台GB28181设备接入侧和GB28181国标平台侧,理下基本流程:
1、GB28181平台侧向Android平台GB28181设备接入侧发送Invite消息,消息头域携带Subject字段,表明点播的视频源ID、发送方媒体流序列号、媒体流接受者ID、接收端媒体流序列号标志等参数。消息中携带SDP信息,s字段为“Playback”代表历史回放,u字段代表回放通道ID和回放类型,t字段代表回放时间段,增加y字段描述SSRC值;
2、Android GB28181设备接入侧收到国标平台侧的Invite请求后,回复200OK,并携带SDP消息体, SDP中描述了安卓设备发送媒体流的IP、端口、媒体格式、SSRC字段等内容;
3、国标平台侧收到Android国标设备侧返回的200OK响应后,向Android国标设备侧发送ACK请求,请求中不携带消息体,完成与Android国标设备侧的Invite会话建立过程;
4、Android GB28181设备侧按Invite SDP中给出的IP地址和端口等信息,发送音视频RTP包(推荐PS RTP包)到媒体服务器;
5、回放过程中,播放端通过向SIP服务器发送会话内Info+MANSRTSP消息(SIP服务器再转发给安卓设备端)进行回放控制,包括视频暂停、播放、快放、慢放、随机拖放等操作;
6、Android GB28181设备侧在文件回放结束后发送会话内Message消息,通知SIP服务器回放已结束;
7、国标平台侧收到媒体通知消息后做相应的处理,之后国标服务侧向Android国标设备侧发送BYE消息;
8、Android平台GB28181设备侧收到BYE消息后,回复200 OK,会话断开,并释放相关资源。
相关类技术设计如下:
java 代码解读复制代码/*
* Created by daniusdk.com
* WeChat: xinsheng120
*/
package com.gb.ntsignalling;
public interface GBSIPAgent {
void addPlaybackListener(GBSIPAgentPlaybackListener playbackListener);
void removePlaybackListener(GBSIPAgentPlaybackListener playbackListener);
/*
*响应Invite Playback 200 OK
*/
boolean respondPlaybackInviteOK(long id, String deviceId, String startTime, String stopTime, MediaSessionDescription localMediaDescription);
/*
*响应Invite Playback 其他状态码
*/
boolean respondPlaybackInvite(int statusCode, long id, String deviceId);
/*
* 媒体流发送者在回放结束后发Message消息通知SIP服务器回放文件已发送完成
* notifyType 必须是"121"
*/
boolean notifyPlaybackMediaStatus(long id, String deviceId, String notifyType);
/*
*终止Playback会话
*/
void terminatePlayback(long id, String deviceId, boolean isSendBYE);
/*
*终止所有Playback会话
*/
void terminateAllPlaybacks(boolean isSendBYE);
}
/**
* 信令Playback Listener
*/
package com.gb.ntsignalling;
public interface GBSIPAgentPlaybackListener {
/*
*收到s=Playback的历史回放Invite
*/
void ntsOnInvitePlayback(long id, String deviceId, SessionDescription sessionDescription);
/*
*发送Playback invite response 异常
*/
void ntsOnPlaybackInviteResponseException(long id, String deviceId, int statusCode, String errorInfo);
/*
* 收到CANCEL Playback INVITE请求
*/
void ntsOnCancelPlayback(long id, String deviceId);
/*
* 收到Ack
*/
void ntsOnAckPlayback(long id, String deviceId);
/*
* 播放命令
*/
void ntsOnPlaybackMANSRTSPPlayCommand(long id, String deviceId);
/*
* 暂停命令
*/
void ntsOnPlaybackMANSRTSPPauseCommand(long id, String deviceId);
/*
* 快进/慢进命令
*/
void ntsOnPlaybackMANSRTSPScaleCommand(long id, String deviceId, double scale);
/*
* 随机拖动命令
*/
void ntsOnPlaybackMANSRTSPSeekCommand(long id, String deviceId, double position_sec);
/*
* 停止命令
*/
void ntsOnPlaybackMANSRTSPTeardownCommand(long id, String deviceId);
/*
* 收到Bye
*/
void ntsOnByePlayback(long id, String deviceId);
/*
* 不是在收到BYE Message情况下, 终止Playback
*/
void ntsOnTerminatePlayback(long id, String deviceId);
/*
* Playback会话对应的对话终止, 一般不会触发这个回调,目前只有在响应了200K, 但在64*T1时间后还没收到ACK,才可能会出发
收到这个, 请做相关清理处理
*/
void ntsOnPlaybackDialogTerminated(long id, String deviceId);
}
/**
* 部分JNI接口, rtp ps 打包发送等代码C++实现
*/
public class SmartPublisherJniV2 {
/**
* Open publisher(启动推送实例)
*
* @param ctx: get by this.getApplicationContext()
*
* @param audio_opt:
* if 0: 不推送音频
* if 1: 推送编码前音频(PCM)
* if 2: 推送编码后音频(aac/pcma/pcmu/speex).
*
* @param video_opt:
* if 0: 不推送视频
* if 1: 推送编码前视频(NV12/I420/RGBA8888等格式)
* if 2: 推送编码后视频(AVC/HEVC)
* if 3: 层叠加模式
*
* This function must be called firstly.
*
* @return the handle of publisher instance
*/
public native long SmartPublisherOpen(Object ctx, int audio_opt, int video_opt, int width, int height);
/**
* 设置流类型
* @param type: 0:表示 live 流, 1:表示 on-demand 流, SDK默认为0(live流)
* 注意: 流类型设置当前仅对GB28181媒体流有效
* @return {0} if successful
*/
public native int SetStreamType(long handle, int type);
/**
* 投递视频 on demand包, 当前只用于GB28181推送, 注意ByteBuffer对象必须是DirectBuffer
*
* @param codec_id: 编码id, 当前支持H264和H265, 1:H264, 2:H265
*
* @param packet: 视频数据, 包格式请参考H264/H265 Annex B Byte stream format, 例如:
* 0x00000001 nal_unit 0x00000001 ...
* H264 IDR: 0x00000001 sps 0x00000001 pps 0x00000001 IDR_nal_unit .... 或 0x00000001 IDR_nal_unit ....
* H265 IDR: 0x00000001 vps 0x00000001 sps 0x00000001 pps 0x00000001 IDR_nal_unit .... 或 0x00000001 IDR_nal_unit ....
*
* @param offset: 偏移量
* @param size: packet size
* @param pts_us: 时间戳, 单位微秒
* @param is_pts_discontinuity: 是否时间戳间断,0:未间断,1:间断
* @param is_key: 是否是关键帧, 0:非关键帧, 1:关键帧
* @param codec_specific_data: 可选参数,可传null, 对于H264关键帧包, 如果packet不含sps和pps, 可传0x00000001 sps 0x00000001 pps
* ,对于H265关键帧包, 如果packet不含vps,sps和pps, 可传0x00000001 vps 0x00000001 sps 0x00000001 pps
* @param codec_specific_data_size: codec_specific_data size
* @param width: 图像宽, 可传0
* @param height: 图像高, 可传0
*
* @return {0} if successful
*/
public native int PostVideoOnDemandPacketByteBuffer(long handle, int codec_id,
ByteBuffer packet, int offset, int size, long pts_us, int is_pts_discontinuity, int is_key,
byte[] codec_specific_data, int codec_specific_data_size,
int width, int height);
/**
* 投递音频on demand包, 当前只用于GB28181推送, 注意ByteBuffer对象必须是DirectBuffer
*
* @param codec_id: 编码id, 当前支持PCMA和AAC, 65536:PCMA, 65538:AAC
* @param packet: 音频数据
* @param offset:packet偏移量
* @param size: packet size
* @param pts_us: 时间戳, 单位微秒
* @param is_pts_discontinuity: 是否时间戳间断,0:未间断,1:间断
* @param codec_specific_data: 如果是AAC的话,需要传 Audio Specific Configuration
* @param codec_specific_data_size: codec_specific_data size
* @param sample_rate: 采样率
* @param channels: 通道数
*
* @return {0} if successful
*/
public native int PostAudioOnDemandPacketByteBuffer(long handle, int codec_id,
ByteBuffer packet, int offset, int size, long pts_us, int is_pts_discontinuity,
byte[] codec_specific_data, int codec_specific_data_size,
int sample_rate, int channels);
/**
* on demand source完成seek后, 请调用
* @return {0} if successful
*/
public native int OnSeekProcessed(long handle);
/**
* 启动 GB28181 媒体流
*
* @return {0} if successful
*/
public native int StartGB28181MediaStream(long handle);
/**
* 停止 GB28181 媒体流
*
* @return {0} if successful
*/
public native int StopGB28181MediaStream(long handle);
/**
* 关闭推送实例,结束时必须调用close接口释放资源
*
* @return {0} if successful
*/
public native int SmartPublisherClose(long handle);
}
/**
* Listener部分实现代码
*/
public class PlaybackListenerImpl implements com.gb.ntsignalling.GBSIPAgentPlaybackListener {
/*
*收到s=Playback的文件下载Invite
*/
@Override
public void ntsOnInvitePlayback(long id, String deviceId, SessionDescription sdp) {
if (!post_task(new PlaybackListenerImpl.OnInviteTask(this.context_, this.is_exit_, this.senders_map_, deviceId, sdp, id))) {
Log.e(TAG, "ntsOnInvitePlayback post_task failed, " + RecordSender.make_print_tuple(id, deviceId, sdp.getTime().getStartTime(), sdp.getTime().getStopTime()));
// 这里不发488, 等待事务超时也可以的
GBSIPAgent agent = this.context_.get_agent();
if (agent != null)
agent.respondPlaybackInvite(488, id, deviceId);
}
}
/*
*发送Playback invite response 异常
*/
@Override
public void ntsOnPlaybackInviteResponseException(long id, String deviceId, int statusCode, String errorInfo) {
Log.i(TAG, "ntsOnPlaybackInviteResponseException, status_code:" + statusCode + ", "
+ RecordSender.make_print_tuple(id, deviceId) + ", error_info:" + errorInfo);
RecordSender sender = senders_map_.remove(id);
if (null == sender)
return;
PlaybackListenerImpl.StopDisposeTask task = new PlaybackListenerImpl.StopDisposeTask(sender);
if (!post_task(task))
task.run();
}
/*
* 收到CANCEL Playback INVITE请求
*/
@Override
public void ntsOnCancelPlayback(long id, String deviceId) {
Log.i(TAG, "ntsOnCancelPlayback, " + RecordSender.make_print_tuple(id, deviceId));
RecordSender sender = senders_map_.remove(id);
if (null == sender)
return;
PlaybackListenerImpl.StopDisposeTask task = new PlaybackListenerImpl.StopDisposeTask(sender);
if (!post_task(task))
task.run();
}
/*
* 收到Ack
*/
@Override
public void ntsOnAckPlayback(long id, String deviceId) {
Log.i(TAG, "ntsOnAckPlayback, "+ RecordSender.make_print_tuple(id, deviceId));
RecordSender sender = senders_map_.get(id);
if (null == sender) {
Log.e(TAG, "ntsOnAckPlayback get sender is null, " + RecordSender.make_print_tuple(id, deviceId));
GBSIPAgent agent = this.context_.get_agent();
if (agent != null)
agent.terminatePlayback(id, deviceId, false);
return;
}
PlaybackListenerImpl.StartTask task = new PlaybackListenerImpl.StartTask(sender, this.senders_map_);
if (!post_task(task))
task.run();
}
/*
* 收到Bye
*/
@Override
public void ntsOnByePlayback(long id, String deviceId) {
Log.i(TAG, "ntsOnByePlayback, "+ RecordSender.make_print_tuple(id, deviceId));
RecordSender sender = this.senders_map_.remove(id);
if (null == sender)
return;
PlaybackListenerImpl.StopDisposeTask task = new PlaybackListenerImpl.StopDisposeTask(sender);
if (!post_task(task))
task.run();
}
/*
* 播放命令
*/
@Override
public void ntsOnPlaybackMANSRTSPPlayCommand(long id, String deviceId) {
RecordSender sender = this.senders_map_.get(id);
if (null == sender) {
Log.e(TAG, "ntsOnPlaybackMANSRTSPPlayCommand can not get sender " + RecordSender.make_print_tuple(id, deviceId));
return;
}
sender.post_play_command();
Log.i(TAG, "ntsOnPlaybackMANSRTSPPlayCommand " + RecordSender.make_print_tuple(id, deviceId));
}
/*
* 暂停命令
*/
@Override
public void ntsOnPlaybackMANSRTSPPauseCommand(long id, String deviceId) {
RecordSender sender = this.senders_map_.get(id);
if (null == sender) {
Log.e(TAG, "ntsOnPlaybackMANSRTSPPauseCommand can not get sender " + RecordSender.make_print_tuple(id, deviceId));
return;
}
sender.post_pause_command();
Log.i(TAG, "ntsOnPlaybackMANSRTSPPauseCommand " + RecordSender.make_print_tuple(id, deviceId));
}
/*
* 快进/慢进命令
*/
@Override
public void ntsOnPlaybackMANSRTSPScaleCommand(long id, String deviceId, double scale) {
if (scale < 0.01) {
Log.e(TAG, "ntsOnPlaybackMANSRTSPScaleCommand invalid scale:" + scale + " " + RecordSender.make_print_tuple(id, deviceId));
return;
}
RecordSender sender = this.senders_map_.get(id);
if (null == sender) {
Log.e(TAG, "ntsOnPlaybackMANSRTSPScaleCommand can not get sender, scale:" + scale + " " + RecordSender.make_print_tuple(id, deviceId));
return;
}
sender.post_scale_command(scale);
Log.i(TAG, "ntsOnPlaybackMANSRTSPScaleCommand, scale:" + scale + " " + RecordSender.make_print_tuple(id, deviceId));
}
/*
* 随机拖动命令
*/
@Override
public void ntsOnPlaybackMANSRTSPSeekCommand(long id, String device_id, double position_sec) {
if (position_sec < 0.0) {
Log.e(TAG, "ntsOnPlaybackMANSRTSPSeekCommand invalid seek pos:" + position_sec + ", " + RecordSender.make_print_tuple(id, device_id));
return;
}
RecordSender sender = this.senders_map_.get(id);
if (null == sender) {
Log.e(TAG, "ntsOnPlaybackMANSRTSPSeekCommand can not get sender " + RecordSender.make_print_tuple(id, device_id));
return;
}
long offset_ms = sender.get_file_start_time_offset_ms();
position_sec += (offset_ms/1000.0);
sender.post_seek_command(position_sec);
Log.i(TAG, "ntsOnPlaybackMANSRTSPSeekCommand seek pos:" + RecordSender.out_point_3(position_sec) + "s, " + RecordSender.make_print_tuple(id, device_id));
}
/*
* 停止命令
*/
@Override
public void ntsOnPlaybackMANSRTSPTeardownCommand(long id, String device_id) {
CallTerminatePlaybackTask call_terminate_task = new CallTerminatePlaybackTask(this.context_, id, device_id, true);
post_task(call_terminate_task);
RecordSender sender = this.senders_map_.remove(id);
if (null == sender) {
Log.w(TAG, "ntsOnPlaybackMANSRTSPTeardownCommand can not remove sender " + RecordSender.make_print_tuple(id, device_id));
return;
}
Log.i(TAG, "ntsOnPlaybackMANSRTSPTeardownCommand " + RecordSender.make_print_tuple(id, device_id));
PlaybackListenerImpl.StopDisposeTask task = new PlaybackListenerImpl.StopDisposeTask(sender);
if (!post_task(task))
task.run();
}
/*
* 不是在收到BYE Message情况下, 终止Playback
*/
@Override
public void ntsOnTerminatePlayback(long id, String deviceId) {
Log.i(TAG, "ntsOnTerminatePlayback, "+ RecordSender.make_print_tuple(id, deviceId));
RecordSender sender = this.senders_map_.remove(id);
if (null == sender)
return;
PlaybackListenerImpl.StopDisposeTask task = new PlaybackListenerImpl.StopDisposeTask(sender);
if (!post_task(task))
task.run();
}
/*
* Playback会话对应的对话终止, 一般不会触发这个回调,目前只有在响应了200K, 但在64*T1时间后还没收到ACK,才可能会出发
收到这个, 请做相关清理处理
*/
@Override
public void ntsOnPlaybackDialogTerminated(long id, String deviceId) {
Log.i(TAG, "ntsOnPlaybackDialogTerminated, "+ RecordSender.make_print_tuple(id, deviceId));
RecordSender sender = this.senders_map_.remove(id);
if (null == sender)
return;
PlaybackListenerImpl.StopDisposeTask task = new PlaybackListenerImpl.StopDisposeTask(sender);
if (!post_task(task))
task.run();
}
}
总结
在国标平台侧需要实现云端录像补录时,Android平台GB28181设备接入端,查到后的文件列表,直接发给云端国标平台就好。需要注意的是,如果云端平台缺失的时间部分的数据,无对应的设备本地录像,是无法完成补录的,如果是设备侧或视频通道离线,也无法实现国标云端录像补录功能。
评论记录:
回复评论: