上文我们讲到自定义了一个Camera相机控件,本次我们来补充一下续集。
首先,既然是Camera控件,只有拍照怎么行呢,那必须得加上录像呀。话不多说,先上效果图
这里我们优化了下按钮样式,给Camera添加了个默认样式(其实也就是一张黑色的背景图),减少了重复事件代码。完整代码会贴在帖子最后。
这是点击录像之后的样式,可以看到,录像按钮变了样式,正在记录录像时长。
这里是录像完成之后下面的VideoPlayer控件的效果,其实这里还有优化的地方,也就是接收到临时视频文件的时候取一帧作为封面,这里我就不演示了,大家可以自己修改下
好了,现在进入正文。既然要添加录像功能,我们也得搞清楚里面的原理。
- 首先我们上一篇博客讲到了帧动画的概念,那这里录像对我们来讲其实就是从某段时间到某一段时间的帧动画数据集合。我们只需要保存下来这段时间的帧动画即可。
首先,我们定义对外的属性来控制是否录像,还需要定义录像写入器,来将这些录像写入视频文件,后面才能做播放的事情
- /// <summary>
- /// 录像状态
- /// </summary>
- private bool _isRecording;
- /// <summary>
- /// 录像写入器
- /// </summary>
- private VideoWriter? _videoWriter;
- /// <summary>
- /// 是否开启录像
- /// </summary>
- public static readonly StyledProperty<bool> IsRecordingProperty =
- AvaloniaProperty.Register<Camera, bool>(nameof(IsRecording), defaultValue: false);
- /// <summary>
- /// 录像临时文件
- /// </summary>
- public static readonly StyledProperty<string> CurrentVideoProperty =
- AvaloniaProperty.Register<Camera, string>(nameof(CurrentVideo));
- public string CurrentVideo
- {
- get => GetValue(CurrentVideoProperty);
- set => SetValue(CurrentVideoProperty, value);
- }
复制代码 同时,我们得定义监听事件来处理录像功能
- private void OnRecordingChanged(bool recording)
- {
- if (recording)
- {
- StartRecording();
- }
- else
- {
- StopRecording();
- }
- }
- private void StartRecording()
- {
- if (!_isRunning || _isRecording) return;
- var temporaryVideoDirectory = Path.Combine(AppContext.BaseDirectory, "TemporaryVideoFiles");
- if (!Directory.Exists(temporaryVideoDirectory))
- {
- Directory.CreateDirectory(temporaryVideoDirectory);
- }
- CurrentVideo = Path.Combine(temporaryVideoDirectory, $"{DateTime.Now:yyyyMMddHHmmssfff}.avi");
- _isRecording = true;
- // 你可以设置分辨率、帧率和编码格式,这里以 MJPEG 为例
- _videoWriter = new VideoWriter();
- _videoWriter.Open(CurrentVideo, FourCC.MJPG, 30, new OpenCvSharp.Size(640, 480));
- }
- private void StopRecording()
- {
- if (!_isRecording) return;
- _isRecording = false;
- _videoWriter?.Release();
- _videoWriter?.Dispose();
- _videoWriter = null;
- }
复制代码 在这里,我视频数据是保存在文件里的,这个看各位小伙伴自己选择怎么保存视频数据。我这里是选择的最简单的方式
同时,我们得将属性和监听事件挂钩,在构造函数中加入这么一句代码,也就是监听属性变化订阅Changed事件,属性变化会自动调用订阅事件并传入新值
- this.GetObservable(IsRecordingProperty).Subscribe(OnRecordingChanged);
复制代码 到这里,我们的属性和方法就定义完了,我们还得修改一下之前的捕获帧画面CaptureLoop()方法,在while循环中加入几行简单的代码
- if (_isRecording && _videoWriter?.IsOpened()==true)
- {
- _videoWriter.Write(mat);
- }
复制代码 到这里,录像功能就加好了。我们来演示一下看下效果
打开摄像头,点击录像,然后停止录像,可以看到我们的目录下多出了一个视频文件,证明我们录像成功了。
VideoPlayer控件
既然都完成了录像,那怎么得也得播放出来呀,之前有讲过,官方的流媒体控件需要付费才能使用。那我们找不到其他的库那就自己弄一个简单的吧,毕竟自己写的东西还是自己最熟悉,后面扩展也比较好扩展,同样也是在加深自己的学习能力
<ul>定义界面
既然都是播放器了,那肯定要有点样式对吧,至少,得能点击播放和暂停功能,时长这些简单的得要有吧,
首先我们先新建一个UserControl,名为VideoPlayer
- 效果图
- View代码
- <UserControl xmlns="https://github.com/avaloniaui"
- xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
- xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
- xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
- xmlns:icon="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
- mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="600"
- x:>
- <UserControl.Styles>
-
-
- </UserControl.Styles>
- <Grid>
-
- <Image x:Name="PlayerImage" Stretch="Fill" Source="avares://GeneralPurposeProgram/Assets/Camere.jpg"
- HorizontalAlignment="Stretch"
- VerticalAlignment="Stretch" />
-
- <DockPanel x:Name="VideoBottom" IsVisible="true" Classes="VideoBottom" VerticalAlignment="Bottom"
- Background="#80000000" Height="50">
-
-
- <StackPanel Orientation="Horizontal" DockPanel.Dock="Bottom" Spacing="6">
-
- <Button Click="PlayPauseButton_OnClick" BorderBrush="Transparent" BorderThickness="0">
- <icon:MaterialIcon x:Name="PlayBtnIcon" Kind="Play"></icon:MaterialIcon>
- </Button>
-
-
-
- <TextBlock x:Name="SurplusVideoSecond" HorizontalAlignment="Center" VerticalAlignment="Center"
- Text="00:00:00" />
- <TextBlock HorizontalAlignment="Center" VerticalAlignment="Center" Text="/" />
- <TextBlock x:Name="TotalVideoSecond" HorizontalAlignment="Center" VerticalAlignment="Center"
- Text="00:00:00" />
- </StackPanel>
- </DockPanel>
- </Grid>
- </UserControl>
复制代码 这里窗体黑色效果其实也就是一张黑色背景图。窗体有了,那得完善背后的功能了。
<ul>背后原理
首先,这里播放的原理其实和摄像头的原理一样,也是通过线程来处理Image控件切换帧画面效果。但是,我们得明白,视频时可以暂停和播放的,还得实现读秒器和视频时长
那如何控制视频的播放和暂停呢,那我们就要用到线程同步了,这里使用了ManualResetEventSlim,一个线程同步的轻量级版本。
从功能上看,我们的播放功能就是在线程中循环处理出来的效果,那这里暂停和继续播放我们就可以通过阻塞线程来实现我们想要的结果。ManualResetEventSlim有很多个方法,我们这里使用到的就是下面这三个方法
我们在播放的地方加入阻塞线程的方法Wait(token),点击播放和暂停触发Set()和Reset(),来控制线程的阻塞。
接下来,我们上完整代码
[code]public partial class VideoPlayer : UserControl{ public static readonly StyledProperty VideoPathProperty = AvaloniaProperty.Register(nameof(VideoPath)); public static readonly StyledProperty IsPlayingProperty = AvaloniaProperty.Register(nameof(IsPlaying), defaultValue: false); private CancellationTokenSource? _playbackCts; private bool _isPlaying; private readonly ManualResetEventSlim _playbackPauseEvent = new(true); // 初始为允许执行 public string VideoPath { get => GetValue(VideoPathProperty); set => SetValue(VideoPathProperty, value); } public bool IsPlaying { get => GetValue(IsPlayingProperty); set => SetValue(IsPlayingProperty, value); } public VideoPlayer() { InitializeComponent(); this.GetObservable(IsPlayingProperty).Subscribe(OnIsPlayingChanged); this.GetObservable(VideoPathProperty).Subscribe(OnVideoPathChanged); } private void OnIsPlayingChanged(bool playing) { if (playing) { _playbackPauseEvent.Set(); // 恢复播放 if (!_isPlaying) { StartPlayback(); } } else { _playbackPauseEvent.Reset(); // 暂停播放 } } private void OnVideoPathChanged(string path) { StopPlayback(); if (string.IsNullOrEmpty(path)) return; var result = GetVideoDurationInSeconds(path); var duration = TimeSpan.FromSeconds(result); var formatted = duration.ToString(@"hh\:mm\:ss"); TotalVideoSecond.Text = formatted; } private void StartPlayback() { if (_isPlaying || string.IsNullOrEmpty(VideoPath)) return; _playbackCts = new CancellationTokenSource(); _isPlaying = true; var path = VideoPath; SurplusVideoSecond.Text = "00:00:00"; Task.Run(() => PlaybackLoop(path, _playbackCts.Token)); } private void StopPlayback() { _playbackCts?.Cancel(); _playbackPauseEvent.Set(); // 避免死锁 _isPlaying = false; } private void PlaybackLoop(string videoPath, CancellationToken token) { try { using var capture = new VideoCapture(videoPath); if (!capture.IsOpened()) return; var fps = capture.Fps; if (fps { PlayerImage.Source = bitmap; SurplusVideoSecond.Text = elapsed.ToString(@"hh\:mm\:ss"); }); var nextFrameTime = now + frameInterval; var sleepTime = nextFrameTime - stopwatch.Elapsed; if (sleepTime > TimeSpan.Zero) Thread.Sleep(sleepTime); } Dispatcher.UIThread.InvokeAsync(() => { IsPlaying = false; _isPlaying = false; _playbackPauseEvent.Set(); // 避免死锁 PlayBtnIcon.Kind = MaterialIconKind.Play; SurplusVideoSecond.Text = "00:00:00"; }); } catch (Exception ex) { Console.WriteLine($" layback error: {ex.Message}"); } } protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) { base.OnDetachedFromVisualTree(e); StopPlayback(); } private void PlayPauseButton_OnClick(object? sender, RoutedEventArgs e) { IsPlaying = !IsPlaying; PlayBtnIcon.Kind = IsPlaying ? MaterialIconKind.Pause : MaterialIconKind.Play; } private static WriteableBitmap ConvertMatToBitmap(Mat mat) { using var ms = mat.ToMemoryStream(); ms.Seek(0, SeekOrigin.Begin); return WriteableBitmap.Decode(ms); } private static double GetVideoDurationInSeconds(string videoPath) { using var capture = new VideoCapture(videoPath); if (!capture.IsOpened()) throw new InvalidOperationException("无法打开视频文件"); var frameCount = capture.Get(VideoCaptureProperties.FrameCount); // 总帧数 var fps = capture.Get(VideoCaptureProperties.Fps); // 帧率 if (fps |