
在实际项目中,经常需要处理摄像头视频流,特别是RTSP协议的IPC摄像头。本文将介绍如何使用纯C#实现RTSP拉流,并将视频流保存为MP4文件,无需依赖外部库如FFmpeg。
技术选型
在.NET生态中,处理RTSP流通常有以下几种选择:
-
FFmpeg.Autogen:通过P/Invoke调用FFmpeg库
-
LibVLC:使用VLC的绑定库
-
RtspClientSharp:纯C#实现的RTSP客户端
为了做到"纯C#实现",我选择了RtspClientSharp 作为RTSP客户端库,配合SharpAvi进行MP4编码。
环境准备
首先创建.NET 6.0控制台应用程序,并添加NuGet包:
dotnet add package RtspClientSharp
dotnet add package SharpAvi
dotnet add package SharpAvi.Output
实现原理
整个流程可以分为三个主要部分:
-
RTSP连接和视频数据接收
-
H.264/H.265视频帧解码
-
AVI/MP4封装写入
代码实现
1. 创建RTSP客户端
using RtspClientSharp;
using RtspClientSharp.RawFrames;
using RtspClientSharp.RawFrames.Video;
public class RtspToMp4Recorder : IDisposable
{
private readonly string _rtspUrl;
private readonly string _outputPath;
private readonly string _username;
private readonly string _password;
private IRtspClient _rtspClient;
private VideoFileWriter _videoWriter;
private bool _isRecording;
private DateTime _startTime;
private readonly object _lockObject = new object();
public RtspToMp4Recorder(string rtspUrl, string outputPath, string username = "", string password = "")
{
_rtspUrl = rtspUrl;
_outputPath = outputPath;
_username = username;
_password = password;
}
public async Task StartRecordingAsync()
{
var connectionParameters = new ConnectionParameters(new Uri(_rtspUrl))
{
RequiredTcpPort = RtspPort.Auto,
UseTcpForRtsp = true,
UseTcpForRtp = true,
RtpTransport = RtpTransportProtocol.TCP
};
if (!string.IsNullOrEmpty(_username))
{
connectionParameters.UserAgent = _username;
connectionParameters.Password = _password;
}
_rtspClient = new RtspClient(connectionParameters);
_rtspClient.FrameReceived += OnFrameReceived;
_videoWriter = new VideoFileWriter();
_videoWriter.Open(_outputPath, 1920, 1080, 25, VideoCodec.Mpeg4);
_isRecording = true;
_startTime = DateTime.Now;
Console.WriteLine($"开始录制: {_rtspUrl}");
await _rtspClient.StartAsync();
// 保持连接
while (_isRecording)
{
await Task.Delay(1000);
}
}
public void StopRecording()
{
_isRecording = false;
_rtspClient?.Stop();
_videoWriter?.Close();
Console.WriteLine($"录制结束,文件保存至: {_outputPath}");
}
}
2. 处理视频帧
private void OnFrameReceived(object sender, RawFrame rawFrame)
{
if (!_isRecording) return;
try
{
// 处理视频帧
if (rawFrame is RawVideoFrame videoFrame)
{
ProcessVideoFrame(videoFrame);
}
}
catch (Exception ex)
{
Console.WriteLine($"处理帧时出错: {ex.Message}");
}
}
private void ProcessVideoFrame(RawVideoFrame videoFrame)
{
lock (_lockObject)
{
byte[] frameData = new byte[videoFrame.FrameSegment.Count];
Array.Copy(videoFrame.FrameSegment.Array,
videoFrame.FrameSegment.Offset,
frameData, 0,
videoFrame.FrameSegment.Count);
// 根据帧类型处理
switch (videoFrame)
{
case RawH264Frame h264Frame:
ProcessH264Frame(h264Frame, frameData);
break;
case RawH265Frame h265Frame:
ProcessH265Frame(h265Frame, frameData);
break;
case RawJpegFrame jpegFrame:
ProcessJpegFrame(jpegFrame, frameData);
break;
}
}
}
3. H.264帧处理
private void ProcessH264Frame(RawH264Frame h264Frame, byte[] frameData)
{
// 解析H.264 NALU
int nalType = frameData[0] & 0x1F;
// 根据NALU类型处理
switch (nalType)
{
case 7: // SPS
Console.WriteLine("收到SPS帧");
// 保存SPS用于解码器初始化
break;
case 8: // PPS
Console.WriteLine("收到PPS帧");
break;
case 5: // IDR帧(关键帧)
Console.WriteLine($"收到IDR帧,大小: {frameData.Length} bytes");
// 关键帧可以作为新的GOP开始
WriteVideoFrame(frameData, true);
break;
default: // 非关键帧
if (nalType <= 5)
{
WriteVideoFrame(frameData, false);
}
break;
}
}
private void WriteVideoFrame(byte[] frameData, bool isKeyFrame)
{
try
{
// 使用SharpAvi写入视频帧
// 注意:这里需要根据实际视频格式进行适当处理
_videoWriter?.WriteVideoFrame(frameData);
// 简单的帧率控制
TimeSpan elapsed = DateTime.Now - _startTime;
int expectedFrames = (int)(elapsed.TotalSeconds * 25);
// 可选:输出录制状态
if (_videoWriter.FramesWritten % 100 == 0)
{
Console.WriteLine($"已写入 {_videoWriter.FramesWritten} 帧");
}
}
catch (Exception ex)
{
Console.WriteLine($"写入视频帧失败: {ex.Message}");
}
}
4. 注意,RTSP数据需要处理后在获取NAL罗数据,在写入MP4文件
/// <summary>
/// 去掉 Annex-B 起始码(00 00 01 / 00 00 00 01),返回裸 NAL 数据。
/// </summary>
private byte[] StripAnnexBPrefix(ReadOnlySpan<byte> nal)
{
int offset = 0;
if (nal.Length >= 4 && nal[0] == 0x00 && nal[1] == 0x00 && nal[2] == 0x00 && nal[3] == 0x01)
{
offset = 4;
}
else if (nal.Length >= 3 && nal[0] == 0x00 && nal[1] == 0x00 && nal[2] == 0x01)
{
offset = 3;
}
return nal[offset..].ToArray();
}