基于FFmpeg实现的高性能视频录制框架,支持从多种输入源(本地视频、摄像头、网络流、桌面等)中提取连续的图像帧。
受支持的输入源
- 本地视频文件,支持多文件
- 摄像头实时捕获
- 流媒体(RTSP、RTMP、HLS等)
- 桌面屏幕录制(Windows、Linux XOrg)
受支持的平台和操作系统
OS | Runtime | x86 | x64 | ARM | ARM64 | LoongArch64 |
---|---|---|---|---|---|---|
Windows | .NET Core 3.1+ | √ | √ | |||
Linux | .NET Core 3.1+ | √ | √ | √ | √ |
通过Nuget包管理器安装WithSalt.FFmpeg.Recorder
或
通过Terminal安装:
dotnet add package WithSalt.FFmpeg.Recorder
Windows系统,可前往 https://github.com/BtbN/FFmpeg-Builds/releases 下载编译好的FFmpeg。然后将ffmpeg.exe放入应用程序根目录,或者放入下一小节中支持自动搜索的目录。
建议在项目配置中新增条件编译参数,在编译Windows环境运行的引用程序时,自动复制ffmpeg。
<ItemGroup Condition="'$(OS)' == 'Windows_NT' OR '$(RuntimeIdentifier)' == 'win-x64'">
<None Update="runtimes\win-x64\bin\ffmpeg.exe">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
Linux系统(例如Debian;Ubuntu),通过命令:sudo apt install ffmpeg
安装。
可以参考项目中Demo示例中ffmpeg路径。
工欲善其事必先利其器。在调用任何库提供的API之前,让我们先告诉程序ffmpeg所在目录,以及进行一些基础配置。
//使用默认的ffmpeg加载器
FFmpegHelper.SetDefaultFFmpegLoador();
调用SetDefaultFFmpegLoador
之后,会进行以下初始化操作:
-
程序会从以下路径开始搜索ffmpeg应用程序所在位置
- 优先查找与当前进程架构匹配的运行时目录,如:.\runtimes\win-x64\bin\ffmpeg.exe
- 应用程序所在根目录,如:<应用程序目录>\ffmpeg.exe
- 在应用程序目录下的bin目录,如:<应用程序目录>\bin\ffmpeg.exe
- [Windows]系统变量
Path
下面的所有目录 - [Linux]ffmpeg安装目录,包括:/usr/bin;/usr/local/bin;/usr/share
当以上路径均找不到时,抛出异常。
-
设置ffmpeg工作目录
工作目录默认设置为应用程序根目录,且创建tmp
目录作为ffmpeg临时文件存放目录
如果进行桌面录制
FFMpegArgumentProcessor ffmpegCmd = new FFmpegArgumentsBuilder()
.WithDesktopInput()
.WithRectangle(new SKRect(0, 0, 1920, 1080))
.WithFramerate(60)
.WithImageHandle((frameIndex, bitmap) =>
{
if (!frameChannel.Writer.TryWrite((frameIndex, bitmap)))
{
bitmap.Dispose();
}
})
.WithOutputQuality(OutputQuality.Medium)
.Build()
//.NotifyOnProgress(frame => Console.WriteLine($"Frame {frame} captured."), TimeSpan.FromSeconds(1))
;
上述代码解释:使用输入源为桌面,录制的区域从左上角0,0坐标开始,录制屏幕1920x1080的区域。当图片产生之后,通过WithImageHandle回调,放入frameChannel中。
使用其他的输入源,调用对应的API即可,比如:WithCameraInput()。
以下是录制屏幕的完整demo
using System.Diagnostics;
using System.Threading.Channels;
using FFMpegCore;
using SkiaSharp;
using WithSalt.FFmpeg.Recorder;
using WithSalt.FFmpeg.Recorder.Models;
namespace ConsoleAppDemo
{
internal class Program
{
static async Task Main(string[] args)
{
if (Directory.Exists("output"))
{
Directory.Delete("output", true);
}
Directory.CreateDirectory("output");
//使用默认的ffmpeg加载器
FFmpegHelper.SetDefaultFFmpegLoador();
Channel<(long frameIndex, SKBitmap data)> frameChannel = Channel.CreateBounded<(long frameIndex, SKBitmap data)>(
new BoundedChannelOptions(10)
{
FullMode = BoundedChannelFullMode.Wait
});
//计算FPS
int uiFrameCount = 0;
Stopwatch lastUiFpsUpdate = Stopwatch.StartNew();
Stopwatch totalUiFpsUpdate = Stopwatch.StartNew();
int currentUiFps = 0;
long totalUiFps = 0;
// 启动写入任务
var cts = new CancellationTokenSource();
var writeTask = Task.Run(async () =>
{
totalUiFpsUpdate.Restart();
while (!cts.IsCancellationRequested && await frameChannel.Reader.WaitToReadAsync(cts.Token))
{
SKBitmap? latestBitmap = null;
long frameIndex = 0;
// 取出所有可用帧,只保留最后一帧
while (frameChannel.Reader.TryRead(out (long frameIndex, SKBitmap bitmap) data))
{
latestBitmap?.Dispose();
latestBitmap = data.bitmap;
frameIndex = data.frameIndex;
}
try
{
if (latestBitmap != null)
{
// 更新FPS计数器
uiFrameCount++;
if (lastUiFpsUpdate.ElapsedMilliseconds >= 1000)
{
currentUiFps = uiFrameCount;
totalUiFps += uiFrameCount;
uiFrameCount = 0;
lastUiFpsUpdate.Restart();
TimeSpan totalElapsed = totalUiFpsUpdate.Elapsed;
int avgFps = (int)(totalUiFps / Math.Max(1, totalElapsed.TotalSeconds));
Console.Write($"\r{(int)totalElapsed.TotalHours:00}:{totalElapsed.Minutes:00}:{totalElapsed.Seconds:00} | Current FPS: {currentUiFps} | AVG FPS: {avgFps} ");
}
//Console.WriteLine("收到图片帧");
SaveBitmapAsImage(latestBitmap, $"output/{frameIndex}.jpg", SKEncodedImageFormat.Jpeg, 100);
}
}
finally
{
latestBitmap?.Dispose();
}
}
});
await DesktopTest(frameChannel);
Console.WriteLine("Done.");
}
private static Action? _cancel = null;
static async Task DesktopTest(Channel<(long frameIndex, SKBitmap data)> frameChannel)
{
FFMpegArgumentProcessor ffmpegCmd = new FFmpegArgumentsBuilder()
.WithDesktopInput()
.WithRectangle(new SKRect(0, 0, 0, 0))
.WithFramerate(60)
.WithImageHandle((frameIndex, bitmap) =>
{
if (!frameChannel.Writer.TryWrite((frameIndex, bitmap)))
{
bitmap.Dispose();
}
})
.WithOutputQuality(OutputQuality.Medium)
.Build()
.CancellableThrough(out _cancel)
//.NotifyOnProgress(frame => Console.WriteLine($"Frame {frame} captured."), TimeSpan.FromSeconds(1))
;
var cmd = ffmpegCmd.Arguments;
Console.WriteLine($"FFMpeg命令:{Environment.NewLine}ffmpeg {cmd}");
await ffmpegCmd.ProcessAsynchronously();
}
/// <summary>
/// 将 SKBitmap 保存为指定格式的图片文件
/// </summary>
/// <param name="bitmap">要保存的 SKBitmap 实例</param>
/// <param name="filePath">保存的文件路径</param>
/// <param name="imageFormat">图像格式(PNG、JPEG 等)</param>
/// <param name="quality">编码质量(针对有损格式,如 JPEG)</param>
static void SaveBitmapAsImage(SKBitmap bitmap, string filePath, SKEncodedImageFormat imageFormat, int quality)
{
if (bitmap == null)
throw new ArgumentNullException(nameof(bitmap));
using (SKImage image = SKImage.FromBitmap(bitmap))
using (SKData data = image.Encode(imageFormat, quality))
using (FileStream stream = File.OpenWrite(filePath))
{
data.SaveTo(stream);
}
}
}
}
- 强烈建议使用异步队列处理图像帧
通常情况下,比如说使用CPU进行图像别,是比较慢的,处理输入远远跟不上ffmpeg获取视频帧的速度。使用异步队列可保证管道畅通,且配合合理丢帧策略,可以让开发的引用程序非常丝滑。可以参考示例项目。 - 随应用程序携带ffmpeg应用程序时,建议放到运行时目录中,支持自动搜索,应用程序目录也不会乱糟糟的
比如:runtimes\win-x64\bin\ffmpeg.exe
https://github.com/withsalt/BemfaCloud/tree/main/src/Examples
- 本软件使用MIT开源协议。
- 本软件使用了 FFmpeg( https://ffmpeg.org ),FFmpeg 受 LGPL/GPL 许可证保护。
感谢这些伟大的开源项目。