网络摄像头录像器
在此,我们将尝试创建一个利用作为可连接至 PC 的外部设备之一的网络摄像头的 Extension。
首先,在RC+的停靠窗口中,实现网络摄像头图像预览功能 (初级篇)。
接下来,添加图像录制功能 (中级篇)。随着 SPEL+ 程序的启动开始录制,程序结束时停止录制。考虑到程序可能长时间运行,每隔 5 秒创建一个新文件进行录制,并只保留最新的 2 个文件。
这旨在为系统添加类似于汽车行车记录仪的功能。在设备启动过程中等场景下,通过监控机器人作业,一旦程序意外停止,可以通过录制的视频后续目视确认发生了什么情况。
如果 PC 已连接至网络,也可以实现如发送异常通知等与录制数据结合的应用。
那么,现在开始吧。
■ 初级篇
按照 [开始使用] 的步骤,创建新的 Extension 项目。
- 项目名称设为 WebCamRecorder。
- 初始功能,勾选Main menu and tool bar item和Docking window。
- 在 ARM64 版 Windows 上,请将配置设置为 x64。
在 Visual Studio 上进行构建和调试,确认功能正常运行。
- 在 RC+ 主菜单的扩展标签下,菜单项中会新增
WebCamRecorder(xx)(xx 为显示语言名),选择菜单项后若能显示停靠窗口则表示操作成功。
- 在 RC+ 主菜单的扩展标签下,菜单项中会新增
模板确认已完成,暂时关闭 RC+。
在 Visual Studio 的解决方案资源管理器中,双击 WebCamRecorder 项目,并进行以下修改。
- 将 TargetFramework 修改为 net8.0-windows10.0.19041.0。
- 这样,便可使用 Windows Media Foundation 的 API。以下修改也与此相关。Windows Media Foundation 是作为 DirectShow 的后继,从 Windows Vista 起标准搭载于操作系统中的基于 COM 的 API 集合。目前,.NET 的标准库尚未纳入该 API,但通过使用如 Microsoft.Windows.CsWin32 等工具,可以像标准库一样进行调用。
- 添加
<EnableWindowsTargeting>true</EnableWindowsTargeting>行。(在<PropertyGroup>内) - 添加
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>行。
- 将 TargetFramework 修改为 net8.0-windows10.0.19041.0。
选择“工具”>“NuGet 包管理器”>“管理解决方案的 NuGet 包”,打开界面。
- 在“引用”标签下,搜索 Microsoft.Windows.CsWin32 包,安装最新版稳定版 (本书验证版本为 v0.3.264)。
- Microsoft.Windows.CsWin32 是用于简化 C# 调用 Windows API 的库。详情见https://github.com/microsoft/CsWin32 。
- 在“引用”标签下,搜索 Microsoft.Windows.CsWin32 包,安装最新版稳定版 (本书验证版本为 v0.3.264)。
在 Visual Studio 的项目中,添加 NativeMethods.txt 和 NativeMethods.json 文件。
这些文件是使用 Microsoft.Windows.CsWin32 调用 Windows API 所必需。
NativeMethods.txt
MFStartup MFShutdown MFCreateAttributes MFEnumDeviceSources MFCreateSourceReaderFromMediaSource MFCreateMediaType MFCreateSinkWriterFromURL MFCreateSample MFCreateAlignedMemoryBuffer (中略) CoInitializeEx CoTaskMemFree COINITNativeMethods.json
{ "$schema": "https://aka.ms/CsWin32.schema.json", "public": true }
向项目中添加以下文件。
CameraInfo.cs
- 该文件用于编写表示摄像头的类 CameraInfo。
(前略) namespace WebCamRecorder { /// <summary> /// Camera information /// </summary> public class CameraInfo { /// <summary> /// Friendly name (may not be unique) /// </summary> public string FriendlyName { get; } /// <summary> /// Unique symbolic link /// </summary> public string SymbolicLink { get; } /// <summary> /// Constructor /// </summary> /// <param name="friendlyName">Friendly name</param> /// <param name="symbolicLink">Symbolic link</param> public CameraInfo( string friendlyName, string symbolicLink ) { FriendlyName = friendlyName; SymbolicLink = symbolicLink; } } }CameraInfoCollection.cs
- 该文件用于编写表示摄像头列表,并获取指定摄像头媒体源 (在 Windows Media Foundation 中作为数据处理入口对象) 的类 CameraInfoCollection。
(前略) namespace WebCamRecorder { (中略) /// <summary> /// Camera collection object /// </summary> public sealed class CameraInfoCollection : IDisposable { /// <summary> /// Camera information /// </summary> public List<CameraInfo> CameraInfos = []; /// <summary> /// Source activates /// </summary> private unsafe IMFActivate_unmanaged** _sourceActivates; /// <summary> /// Constructor /// </summary> /// <param name="sourceActivates">Source activates</param> public unsafe CameraInfoCollection( IMFActivate_unmanaged** sourceActivates ) { _sourceActivates = sourceActivates; } /// <summary> /// Create media source for the specified camera /// </summary> /// <param name="cameraInfo">Selected camera</param> /// <returns>Media source object</returns> public unsafe IMFMediaSource? GetMediaSource( CameraInfo cameraInfo ) { var index = CameraInfos.FindIndex( (x) => ( x != null && x.FriendlyName == cameraInfo.FriendlyName && x.SymbolicLink == cameraInfo.SymbolicLink ) ); if (index < 0) { return null; } else { if (Marshal.GetObjectForIUnknown((nint)_sourceActivates[index]) is not IMFActivate managedSourceActivate) { return null; } var mediaSource = managedSourceActivate.ActivateObject(typeof(IMFMediaSource).GUID) as IMFMediaSource; Marshal.ReleaseComObject(managedSourceActivate); return mediaSource; } } (后略)IFrameProcessor.cs
- 该文件用于编写处理从摄像头获取图像的接口 IFrameProcessor。
(前略) namespace WebCamRecorder { /// <summary> /// Frame processor interface /// </summary> public interface IFrameProcessor { /// <summary> /// Initialize the processor /// </summary> /// <param name="width">Frame width</param> /// <param name="height">Frame height</param> /// <param name="stride">Frame stride</param> /// <param name="bitRate">Bit rate</param> public void Initialize( uint width, uint height, uint stride, uint bitRate ); /// <summary> /// Termniate the processor /// </summary> public void Terminate(); /// <summary> /// Process the frame /// </summary> /// <param name="frame">Frame data</param> /// <param name="duration">Duration</param> public void Process( byte[] frame, long duration ); /// <summary> /// Request stopping /// </summary> public void Stop(); /// <summary> /// The processing is currently stopping or not /// </summary> public bool IsStopped { get; } } }CameraManager.cs
- 该文件用于编写 CameraManager 类,实现拍摄并依次将图像数据传递给图像处理器 (即实现上述 IFrameProcessor 的类实例)。
(前略) namespace WebCamRecorder { (中略) /// <summary> /// Camera manager /// </summary> public class CameraManager { /// <summary> /// List of frame processors /// </summary> public List<IFrameProcessor> FrameProcessors { get; } = []; (中略) /// <summary> /// List available cameras /// </summary> /// <returns>Collection object</returns> public unsafe CameraInfoCollection? ListCameras() { (中略) /// <summary> /// Read a frame from source /// </summary> /// <param name="sourceReader">Source reader object</param> /// <param name="frame">Buffer</param> /// <param name="duration">Variable to get duration</param> /// <returns>1: got it, 0: not got, -1: error</returns> private static unsafe int ReadFrame( IMFSourceReader sourceReader, byte[] frame, out long duration ) { (中略) /// <summary> /// Start the processings /// </summary> /// <param name="cameraInfo">Selected camera</param> /// <returns>Task</returns> public async Task Start( CameraInfo cameraInfo ) { while (!_done) { const int _waitMSec = 10; await Task.Delay(_waitMSec); } await Task.Run(() => { HRESULT hr; hr = PInvoke.CoInitializeEx(COINIT.COINIT_MULTITHREADED); if (hr.Failed) { return; } hr = PInvoke.MFStartup(PInvoke.MF_VERSION, PInvoke.MFSTARTUP_FULL); if (hr.Succeeded) { _done = false; var sourceReader = CreateSourceReader(cameraInfo); if (sourceReader != null) { GetVideoInfos( sourceReader, out var width, out var height, out var stride, out var bitRate ); var frame = new byte[stride * height]; foreach (var frameProcessor in FrameProcessors) { frameProcessor.Initialize(width, height, stride, bitRate); } _stopping = false; while (true) { var status = ReadFrame(sourceReader, frame, out var duration); if (status < 0) { break; } else if (status > 0) { foreach (var frameProssor in FrameProcessors) { frameProssor.Process(frame, duration); } } if (_stopping && FrameProcessors.All(x => x.IsStopped)) { break; } } foreach (var frameProcessor in FrameProcessors) { frameProcessor.Terminate(); } Marshal.ReleaseComObject(sourceReader); } _ = PInvoke.MFShutdown(); _done = true; _stoppedAction?.Invoke(); } }); } (后略)Previewer.cs
- 该文件用于编写用于预览摄像头图像的类 Previewer。
- PreviewImage 是用于在窗口显示图像的Image控件。初始化时创建位图数据,并设置为 PreviewImage 的 Source,之后将 CameraManager 传递的图像数据写入位图。
(前略) namespace WebCamRecorder { (中略) /// <summary> /// Image previewer for the camera /// </summary> public class Previewer : IFrameProcessor { /// <summary> /// Image control /// </summary> public Image? PreviewImage; /// <summary> /// Bitmap /// </summary> private WriteableBitmap? _bitmap; (中略) /// <inheritdoc /> public void Initialize( uint width, uint height, uint stride, uint bitRate ) { _width = (int)width; _height = (int)height; _stride = (int)stride; Application.Current.Dispatcher.Invoke(() => { _bitmap = new( _width, _height, 96, 96, PixelFormats.Bgr32, null ); if (PreviewImage != null) { PreviewImage.Source = _bitmap; } }); } (中略) /// <inheritdoc /> public void Process( byte[] frame, long duration ) { if (_bitmap == null) { return; } Application.Current.Dispatcher.Invoke(() => { _bitmap.WritePixels( new Int32Rect(0, 0, _width, _height), frame, _stride, 0 ); }); } (后略)- 该文件用于编写用于预览摄像头图像的类 Previewer。
编辑 DockingWindow 文件夹下的 DockingWindowContent.xaml 文件。
考虑到图像可能大于窗口,配置 ScrollViewer,并将 DockPanel 移至其内部。
删除原有的 TextBlock 和 Grid。
添加包含 Label 和 ComboBox 的 StackPanel。
- 在本 Extension 中,显示 ComboBox 下拉菜单时 (若已执行),会停止拍摄处理,并获取已连接摄像头的列表。从下拉菜单选择摄像头后,开始拍摄处理。这些处理稍后会描述,预期将以下属性和命令添加至视图模型,并绑定至相应位置。
- 表示摄像头列表的 Cameras(
ReactiveCollection<CameraInfo>) - 表示所选摄像头在列表中的索引 SelectedCameraIndex(
ReactivePropertySlim<int>) - 用于(重新)获取摄像头列表的命令 RefreshCamerasCommand(ReactiveCommand)
- 表示摄像头列表的 Cameras(
- 请关注 Label 的 Content。绑定至 Captions[CaptionCamera].Value。在 Extension 中,需根据 RC+ 的显示语言对需本地化的字符串进行管理,相关内容需记录在 Captions.xlsx 文件中。详细内容后述,通过在 Captions.xlsx 的 symbol 列定义名称 (此处为 CaptionCamera),以同样方式绑定,实现 RC+ 显示语言下的本地化。
- 在本 Extension 中,显示 ComboBox 下拉菜单时 (若已执行),会停止拍摄处理,并获取已连接摄像头的列表。从下拉菜单选择摄像头后,开始拍摄处理。这些处理稍后会描述,预期将以下属性和命令添加至视图模型,并绑定至相应位置。
添加名为 PreviewImage 的 Image 控件。
<UserControl x:Class="WebCamRecorder.DockingWindow.DockingWindowContent" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:i="http://schemas.microsoft.com/xaml/behaviors" xmlns:local="clr-namespace:WebCamRecorder.DockingWindow" mc:Ignorable="d" d:DesignHeight="450" d:DesignWidth="800"> <UserControl.DataContext> <local:DockingWindowContentViewModel /> </UserControl.DataContext> <ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"> <DockPanel Background="White" LastChildFill="True"> <StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="10"> <Label Content="{Binding Captions[CaptionCamera].Value}" /> <ComboBox ItemsSource="{Binding Cameras}" SelectedIndex="{Binding SelectedCameraIndex.Value}" IsReadOnly="True" DisplayMemberPath="FriendlyName" MinWidth="200" Margin="10,0,0,0"> <i:Interaction.Triggers> <i:EventTrigger EventName="DropDownOpened"> <i:InvokeCommandAction Command="{Binding RefreshCamerasCommand}" /> </i:EventTrigger> </i:Interaction.Triggers> </ComboBox> </StackPanel> <Image x:Name="PreviewImage" Width="640" Height="480" Stretch="UniformToFill" HorizontalAlignment="Left" /> </DockPanel> </ScrollViewer> </UserControl>
编辑 DockingWindow 文件夹下的 DockingWindowContentViewModelAddition.cs 文件。
- DockingWindow 文件夹中还包含 DockingWindowContentViewModel.cs 文件,将在这两个文件中描述 DockingWindowContentViewModel 类。
DockingWindowContentViewModel.cs 包含关闭、保存等功能,以及内容编辑用的复制、剪切、粘贴等方法,可按需编写处理逻辑。
在本 Extension 中,仅在窗口关闭时添加停止拍摄处理的逻辑。
(前略) /// <inheritdoc /> public Task<bool> CloseAsync() { _cameraManager.Stop(); return Task.FromResult(true); } (后略)
DockingWindowContentViewModelAddition.cs 包含视图模型构造函数和窗口生成后仅调用一次的 WindowCreated 方法。将窗口专有的属性、命令及相关 API 调用集中编写于此文件,可提升视图模型整体的可读性。
(前略) namespace WebCamRecorder.DockingWindow { (中略) /// <summary> /// Extension : Docking Window (Specific Part) /// </summary> internal partial class DockingWindowContentViewModel { /// <summary> /// Camera list /// </summary> public ReactiveCollection<CameraInfo> Cameras { get; } = []; /// <summary> /// Index of the selected camera /// </summary> public ReactivePropertySlim<int> SelectedCameraIndex { get; } = new(-1); /// <summary> /// Refresh camera list command /// </summary> public ReactiveCommand RefreshCamerasCommand { get; } = new(); (中略) /// <summary> /// Camera manager /// </summary> private readonly CameraManager _cameraManager = new(); /// <summary> /// Previewer /// </summary> private readonly Previewer _previewer = new(); (中略) /// <summary> /// Refresh camera list /// </summary> private void OnRefreshCameras() { SelectedCameraIndex.Value = -1; Cameras.Clear(); var cameraInfoCollection = _cameraManager.ListCameras(); if (cameraInfoCollection != null) { foreach (var cameraInfo in cameraInfoCollection.CameraInfos) { Cameras.Add(cameraInfo); } cameraInfoCollection.Dispose(); } } /// <summary> /// Change camera /// </summary> /// <param name="index">The index of the selected camera</param> /// <returns>Task</returns> private async Task OnSelectedCameraChanged( int index ) { _cameraManager.Stop(); if (index >= 0) { await _cameraManager.Start(Cameras[index]); } } /// <summary> /// Set image control for previewer /// </summary> /// <param name="previewImage">Image control for previewing</param> public void SetPreviewImage( Image previewImage ) { _previewer.PreviewImage = previewImage; } /// <summary> /// Constructor /// </summary> public DockingWindowContentViewModel() { _cameraManager.FrameProcessors.Add(_previewer); RefreshCamerasCommand.Subscribe(OnRefreshCameras).AddTo(_disposables); SelectedCameraIndex.Subscribe(async (index) => { await OnSelectedCameraChanged(index); }) .AddTo(_disposables); } (后略)
- DockingWindow 文件夹中还包含 DockingWindowContentViewModel.cs 文件,将在这两个文件中描述 DockingWindowContentViewModel 类。
编辑 DockingWindow 文件夹下的 DockingWindowContent.xaml.cs 文件。
将用于预览的 Image 控件的引用传递给视图模型。
(前略) if (DataContext is DockingWindowContentViewModel viewModel) { viewModel.SetPreviewImage(PreviewImage); } (后略)
打开并编辑 Captions.xlsx 文件。
- 如前所述,需根据 RC+ 显示语言对需本地化的字符串进行管理,相关内容需记录在此文件中。
ID 为标题编号。请确保在此文件中编号不重复。
description 为注释。可自由填写。
symbol 为 Extension 源代码 (.xaml、.cs) 中引用的名称。编辑 Captions.xlsx 文件并构建项目后,会生成将 symbol 与 ID 关联的常量定义文件 Captions.cs。请勿直接编辑该文件。
// <auto-generated> namespace WebCamRecorder { using System.Reflection; internal class Constants { internal class Caption { (中略) public const int ExtensionName = 0; public const int MainMenu = 1; public const int WindowTitle = 400; public const int CaptionCamera = 401; } } }English、Japanese 等各列需填写对应语言的显示字符串。
- 如前所述,需根据 RC+ 显示语言对需本地化的字符串进行管理,相关内容需记录在此文件中。
进行构建和调试。
- 连接 PC 的网络摄像头,显示 WebCamRecorder 窗口,选择摄像头并显示图像即为成功。
■ 中级篇
在初级篇中,除生成的解决方案所用部分外,并未使用 Extensions API。
在中级篇中,将尝试使用提供下述功能的 Extensions API。
- 获取已打开项目的项目文件夹路径 (项目 API)。
- 获取 SPEL+ 的任务列表 (程序执行 API)。
结合丰富的 .NET 库和 Windows API,并使用必要的 Extensions API,可创建与 RC+ 和 SPEL+ 程序紧密联动的专属应用作为 Extension 使用。
那么,继续操作。
若 RC+ 已启动,请先关闭,启动 Visual Studio 并打开初级篇创建的解决方案。
添加 Recorder.cs 文件。
Recorder 类与 Previewer 类一样,实现了 IFrameProcessor。Recorder 会根据 CameraManager 传递的图像数据生成 H.264 格式的视频文件。
视频文件约每 5 秒在 Recorder 实例指定的文件夹下新建,命名为
Video_N.mp4(N 为 000~999,达到 999 后重新从 000 开始),为避免占用过多存储,仅保留最新的 2 个文件。- 本 Extension 在开始新录制时,会删除文件夹内所有视频。
此外,为实现类似行车记录仪的功能,在指示停止录制后,仍会继续录制 2 秒。(即最后保存的视频最长约为 7 秒。)
录制模式包括与 SPEL+ 程序启动、结束联动的自动模式 (Auto),以及可在任意时机开始、停止的手动模式 (Manual)。
由于摄像头图像预览无需显式启动 (选择摄像头即开始),IFrameProcessor 仅提供停止指令的 Stop 方法。因此,Recorder 需通过 Mode 属性持有上述录制模式,并根据 Mode 设置启动录制。此外,Mode 更改时会触发 PropertyChanged 事件,便于程序捕捉录制实际停止的时机。
(前略) namespace WebCamRecorder { (中略) /// <summary> /// Recorder /// </summary> public class Recorder : IFrameProcessor, INotifyPropertyChanged { /// <summary> /// Recording mode definitions /// </summary> public enum RecordingMode { Stop, Auto, Manual, } /// <inheritdoc /> public event PropertyChangedEventHandler? PropertyChanged; /// <summary> /// Recording mode /// </summary> public RecordingMode Mode { get { return _mode; } set { if (_sinkWriter == null) { _shouldStop = false; _mode = value; RaisePropertyChanged(); } } } /// <summary> /// Folder for video files /// </summary> public string VideoFolder { get { return _videoFolder; } set { if (_sinkWriter == null) { try { Directory.CreateDirectory(value); _videoFolder = value; } catch (Exception) { // EMPTY } } } } (中略) /// <inheritdoc /> public void Process( byte[] frame, long duration ) { if (_sinkWriter == null) { if (_mode == RecordingMode.Stop) { return; } _sinkWriter = CreateSinkWriter(GetNextSegmentFile()); _recordTime = 0; _segmentSpan = _initialSegmentSpan; } if (_sinkWriter != null && _sample != null) { SetFlippedFrame(frame); _sample.SetSampleTime(_recordTime); _sample.SetSampleDuration(duration); _sinkWriter.WriteSample(_streamIndex, _sample); _recordTime += duration; if (_recordTime > _segmentSpan) { _sinkWriter.Finalize(); Marshal.ReleaseComObject(_sinkWriter); _sinkWriter = null; if (_shouldStop) { Mode = RecordingMode.Stop; } } } } /// <inheritdoc /> public void Stop() { const long _minAdditionalTime = 20_000_000; if (_segmentSpan - _recordTime < _minAdditionalTime) { _segmentSpan = _recordTime + _minAdditionalTime; } _shouldStop = true; } (后略)
编辑 DockingWindow 文件夹下的 DockingWindowContent.xaml 文件。
在界面上,添加录制中指示器显示,并增加按钮以便手动 (Manual 模式) 启动和停止录制。
视图模型后续将添加以下属性和命令。
- 表示录制中的 IsRecording(
ReactivePropertySlim<bool>) - 表示可启动录制的 CanStartRecording(
ReactivePropertySlim<bool>) 及启动录制命令 StartRecordingCommand(ReactiveCommand) - 表示可停止录制的 CanStopRecording(
ReactivePropertySlim<bool>) 及停止录制命令 StopRecordingCommand(ReactiveCommand) - 本 Extension 中,自动模式录制时不可手动启动或停止录制,反之手动模式录制时自动模式录制无效。
- 但无论哪种模式,关闭停靠窗口均会停止录制。
(前略) </ComboBox> <Border CornerRadius="10" Width="60" Height="20" Margin="20,0,0,0" VerticalAlignment="Center"> <TextBlock Text="REC" HorizontalAlignment="Center" VerticalAlignment="Center"> <TextBlock.Style> <Style TargetType="TextBlock"> <Style.Triggers> <DataTrigger Binding="{Binding IsRecording.Value}" Value="True"> <Setter Property="Foreground" Value="White" /> </DataTrigger> <DataTrigger Binding="{Binding IsRecording.Value}" Value="False"> <Setter Property="Foreground" Value="Black" /> </DataTrigger> </Style.Triggers> </Style> </TextBlock.Style> </TextBlock> <Border.Style> <Style TargetType="Border"> <Style.Triggers> <DataTrigger Binding="{Binding IsRecording.Value}" Value="True"> <Setter Property="Background" Value="Red" /> </DataTrigger> <DataTrigger Binding="{Binding IsRecording.Value}" Value="False"> <Setter Property="Background" Value="LightGray" /> </DataTrigger> </Style.Triggers> </Style> </Border.Style> </Border> <Button Command="{Binding StartRecordingCommand}" IsEnabled="{Binding CanStartRecording.Value}" Content="{Binding Captions[LabelStart].Value}" Width="80" VerticalAlignment="Center" Margin="10,0,0,0" /> <Button Command="{Binding StopRecordingCommand}" IsEnabled="{Binding CanStopRecording.Value}" Content="{Binding Captions[LabelStop].Value}" Width="80" VerticalAlignment="Center" Margin="10,0,0,0" />- 表示录制中的 IsRecording(
编辑 DockingWindow 文件夹下的 DockingWindowContentViewModelAddition.cs 文件。
添加在视图 (.xaml) 绑定的属性和命令。
Recorder 类实例也添加至 CameraManager。
请关注 WindowCreated 方法。此处使用 Extensions API 的项目 API,获取已打开项目的项目文件夹(路径名)。
- API 对象通过 Main.GetAPI 方法获取。
- 项目 API 对象的 ProjectFolder 属性为已打开项目的项目文件夹路径名。若未打开项目,则为 null。
- 未打开项目时,将使用 Windows 登录用户的“视频”文件夹代替项目文件夹。
- 录制文件将保存在项目文件夹或登录用户的“视频”文件夹下新建的 WebCamRecorder 子文件夹中,并设置给 Recorder。
(前略) /// <summary> /// Recording in progress or not /// </summary> public ReactivePropertySlim<bool> IsRecording { get; } = new(false); /// <summary> /// Can start recording or not /// </summary> public ReactivePropertySlim<bool> CanStartRecording { get; } = new(false); /// <summary> /// Start recording command /// </summary> public ReactiveCommand StartRecordingCommand { get; } /// <summary> /// Can stop recording or not /// </summary> public ReactivePropertySlim<bool> CanStopRecording { get; } = new(false); /// <summary> /// Stop recording command /// </summary> public ReactiveCommand StopRecordingCommand { get; } (中略) /// <summary> /// Recorder /// </summary> private readonly Recorder _recorder = new(); (中略) /// <summary> /// Change camera /// </summary> /// <param name="index">The index of the selected camera</param> /// <returns>Task</returns> private async Task OnSelectedCameraChanged( int index ) { EnableOrDisableRecordingCommands(); _cameraManager.Stop(); if (index >= 0) { await _cameraManager.Start(Cameras[index]); } } (中略) /// <summary> /// Update recording command possibilities /// </summary> private void EnableOrDisableRecordingCommands() { CanStartRecording.Value = (SelectedCameraIndex.Value >= 0 && _recorder.Mode == Recorder.RecordingMode.Stop); CanStopRecording.Value = (_recorder.Mode == Recorder.RecordingMode.Manual); } /// <summary> /// Start recording /// </summary> private void OnStartRecording( bool isAuto ) { if (_recorder.Mode == Recorder.RecordingMode.Stop) { try { var files = Directory.EnumerateFiles( _recorder.VideoFolder, $"*{Recorder.VideoFileExtension}" ); foreach (var file in files) { File.Delete(file); } } catch (Exception) { // IGNORE } _recorder.Mode = isAuto ? Recorder.RecordingMode.Auto : Recorder.RecordingMode.Manual; EnableOrDisableRecordingCommands(); } } /// <summary> /// Stop recording /// </summary> private void OnStopRecording() { _recorder.Stop(); CanStopRecording.Value = false; } /// <summary> /// Constructor /// </summary> public DockingWindowContentViewModel() { _cameraManager.FrameProcessors.Add(_previewer); _cameraManager.FrameProcessors.Add(_recorder); (中略) _recorder.PropertyChanged += (_, _) => { IsRecording.Value = (_recorder.Mode != Recorder.RecordingMode.Stop); EnableOrDisableRecordingCommands(); }; (中略) } /// <inheritdoc /> public Task WindowCreated() { string videoFolder; var projectAPI = Main.GetAPI<IRCXProjectAPI>(); if (projectAPI != null && projectAPI.ProjectFolder != null) { videoFolder = projectAPI.ProjectFolder; } else { videoFolder = Environment.GetFolderPath(Environment.SpecialFolder.MyVideos); } _recorder.VideoFolder = Path.Combine(videoFolder, "WebCamRecorder"); (后略)
进行构建和调试。
- 打开停靠窗口,选择摄像头,尝试录制的开始与停止。

- 在指定文件夹※中保存视频文件,并可播放即为成功。
※若已打开项目,则为项目文件夹下的“WebCamRecorder”文件夹
未打开项目时,则为 Windows 登录用户的“视频”文件夹
编辑 DockingWindow 文件夹下的 DockingWindowContentViewModelAddition.cs 文件。
添加自动模式录制功能。为此,需要在 Extension 中捕捉 SPEL+ 程序的执行开始和执行结束的时机。
- SPEL+ 程序支持多任务处理。在本 Extension 中,将 SPEL+ 程序的开始与结束视为普通任务的开始与结束。
- 任务列表可通过程序执行 API 对象的 Tasks 属性获取。
- Tasks 是
IEnumerable<IRCXTask>类型的集合,IRCXTask 实例拥有表示任务状态的 State 属性和表示任务类型的 Kind 属性。 - 当 SPEL+ 的任务发生任何变更时,Tasks 属性的 PropertyChanged 事件会被触发。
- Tasks 是
请在 WindowCreated 方法中添加以下代码,用于监控正在执行的普通任务是否存在,并更新
ReactivePropertySlim<bool>类型的 _isProgramRunning。(前略) /// <summary> /// Program execution API object /// </summary> private IRCXProgramExecutionAPI? _programExecutionAPI; /// <summary> /// Program running state /// </summary> private readonly ReactivePropertySlim<bool> _isProgramRunning = new(false, ReactivePropertyMode.DistinctUntilChanged); (中略) /// <inheritdoc /> public Task WindowCreated() { (中略) _programExecutionAPI = Main.GetAPI<IRCXProgramExecutionAPI>(); _programExecutionAPI?.ObserveProperty(x => x.Tasks).Subscribe((tasks) => { _isProgramRunning.Value = tasks .Any( x => ( x.Kind == IRCXProgramExecutionAPI.IRCXTask.RCXTaskKind.Normal && x.State == IRCXProgramExecutionAPI.IRCXTask.RCXTaskState.Run ) ); }) .AddTo(_disposables); (后略)此外,在构造函数中添加代码,通过检测上述 _isProgramRunning 属性的变更,调用录制的开始和结束。
(前略) /// <summary> /// Constructor /// </summary> public DockingWindowContentViewModel() { (中略) _isProgramRunning.Subscribe((isRunning) => { if (SelectedCameraIndex.Value >= 0) { if (isRunning) { OnStartRecording(isAuto: true); } else { OnStopRecording(); } EnableOrDisableRecordingCommands(); } }) .AddTo(_disposables); } (后略)
进行构建和调试。
- 打开 Extension 的窗口,选择相机,并确认预览图像已显示。
- 打开 Run 窗口并执行程序。
- 若程序开始时录制启动,结束后约 2 秒录制停止,并且视频文件已保存到指定文件夹,则操作成功。