网络摄像头录像器

在此,我们将尝试创建一个利用作为可连接至 PC 的外部设备之一的网络摄像头的 Extension。

首先,在RC+的停靠窗口中,实现网络摄像头图像预览功能 (初级篇)。

接下来,添加图像录制功能 (中级篇)。随着 SPEL+ 程序的启动开始录制,程序结束时停止录制。考虑到程序可能长时间运行,每隔 5 秒创建一个新文件进行录制,并只保留最新的 2 个文件。

这旨在为系统添加类似于汽车行车记录仪的功能。在设备启动过程中等场景下,通过监控机器人作业,一旦程序意外停止,可以通过录制的视频后续目视确认发生了什么情况。

如果 PC 已连接至网络,也可以实现如发送异常通知等与录制数据结合的应用。

那么,现在开始吧。

■ 初级篇

  1. 按照 [开始使用] 的步骤,创建新的 Extension 项目。

    • 项目名称设为 WebCamRecorder。
    • 初始功能,勾选Main menu and tool bar itemDocking window
    • 在 ARM64 版 Windows 上,请将配置设置为 x64。
  2. 在 Visual Studio 上进行构建和调试,确认功能正常运行。

    • 在 RC+ 主菜单的扩展标签下,菜单项中会新增 WebCamRecorder(xx) (xx 为显示语言名),选择菜单项后若能显示停靠窗口则表示操作成功。
  3. 模板确认已完成,暂时关闭 RC+。

  4. 在 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> 行。
  5. 选择“工具”>“NuGet 包管理器”>“管理解决方案的 NuGet 包”,打开界面。

    • 在“引用”标签下,搜索 Microsoft.Windows.CsWin32 包,安装最新版稳定版 (本书验证版本为 v0.3.264)。
      • Microsoft.Windows.CsWin32 是用于简化 C# 调用 Windows API 的库。详情见https://github.com/microsoft/CsWin32 。
  6. 在 Visual Studio 的项目中,添加 NativeMethods.txt 和 NativeMethods.json 文件。

    • 这些文件是使用 Microsoft.Windows.CsWin32 调用 Windows API 所必需。

    • NativeMethods.txt

      MFStartup
      MFShutdown
      MFCreateAttributes
      MFEnumDeviceSources
      MFCreateSourceReaderFromMediaSource
      MFCreateMediaType
      MFCreateSinkWriterFromURL
      MFCreateSample
      MFCreateAlignedMemoryBuffer
      
      (中略)
      
      CoInitializeEx
      CoTaskMemFree
      
      COINIT
      
    • NativeMethods.json

      {
          "$schema": "https://aka.ms/CsWin32.schema.json",
          "public": true
      }
      
  7. 向项目中添加以下文件。

    • 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
                      );
                  });
              }
      
              (后略)
      
  8. 编辑 DockingWindow 文件夹下的 DockingWindowContent.xaml 文件。

    • 考虑到图像可能大于窗口,配置 ScrollViewer,并将 DockPanel 移至其内部。

    • 删除原有的 TextBlock 和 Grid。

    • 添加包含 Label 和 ComboBox 的 StackPanel。

      • 在本 Extension 中,显示 ComboBox 下拉菜单时 (若已执行),会停止拍摄处理,并获取已连接摄像头的列表。从下拉菜单选择摄像头后,开始拍摄处理。这些处理稍后会描述,预期将以下属性和命令添加至视图模型,并绑定至相应位置。
        • 表示摄像头列表的 Cameras(ReactiveCollection<CameraInfo>)
        • 表示所选摄像头在列表中的索引 SelectedCameraIndex(ReactivePropertySlim<int>)
        • 用于(重新)获取摄像头列表的命令 RefreshCamerasCommand(ReactiveCommand)
      • 请关注 Label 的 Content。绑定至 Captions[CaptionCamera].Value。在 Extension 中,需根据 RC+ 的显示语言对需本地化的字符串进行管理,相关内容需记录在 Captions.xlsx 文件中。详细内容后述,通过在 Captions.xlsx 的 symbol 列定义名称 (此处为 CaptionCamera),以同样方式绑定,实现 RC+ 显示语言下的本地化。
    • 添加名为 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>
      
  9. 编辑 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);
                }
        
                (后略)
        
  10. 编辑 DockingWindow 文件夹下的 DockingWindowContent.xaml.cs 文件。

    • 将用于预览的 Image 控件的引用传递给视图模型。

      (前略)
      
      if (DataContext is DockingWindowContentViewModel viewModel)
      {
          viewModel.SetPreviewImage(PreviewImage);
      }
      
      (后略)
      
  11. 打开并编辑 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 等各列需填写对应语言的显示字符串。

  12. 进行构建和调试。

    • 连接 PC 的网络摄像头,显示 WebCamRecorder 窗口,选择摄像头并显示图像即为成功。

■ 中级篇

在初级篇中,除生成的解决方案所用部分外,并未使用 Extensions API。

在中级篇中,将尝试使用提供下述功能的 Extensions API。

  • 获取已打开项目的项目文件夹路径 (项目 API)。
  • 获取 SPEL+ 的任务列表 (程序执行 API)。

结合丰富的 .NET 库和 Windows API,并使用必要的 Extensions API,可创建与 RC+ 和 SPEL+ 程序紧密联动的专属应用作为 Extension 使用。

那么,继续操作。

  1. 若 RC+ 已启动,请先关闭,启动 Visual Studio 并打开初级篇创建的解决方案。

  2. 添加 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;
              }
      
              (后略)
      
  3. 编辑 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" />
      
  4. 编辑 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");
      
          (后略)
      
  5. 进行构建和调试。

    • 打开停靠窗口,选择摄像头,尝试录制的开始与停止。
    • 在指定文件夹※中保存视频文件,并可播放即为成功。
      ※若已打开项目,则为项目文件夹下的“WebCamRecorder”文件夹
       未打开项目时,则为 Windows 登录用户的“视频”文件夹
  6. 编辑 DockingWindow 文件夹下的 DockingWindowContentViewModelAddition.cs 文件。

    • 添加自动模式录制功能。为此,需要在 Extension 中捕捉 SPEL+ 程序的执行开始和执行结束的时机。

      • SPEL+ 程序支持多任务处理。在本 Extension 中,将 SPEL+ 程序的开始与结束视为普通任务的开始与结束。
      • 任务列表可通过程序执行 API 对象的 Tasks 属性获取。
        • Tasks 是 IEnumerable<IRCXTask> 类型的集合,IRCXTask 实例拥有表示任务状态的 State 属性和表示任务类型的 Kind 属性。
        • 当 SPEL+ 的任务发生任何变更时,Tasks 属性的 PropertyChanged 事件会被触发。
    • 请在 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);
      }
      
      (后略)
      
  7. 进行构建和调试。

    • 打开 Extension 的窗口,选择相机,并确认预览图像已显示。
    • 打开 Run 窗口并执行程序。
      • 若程序开始时录制启动,结束后约 2 秒录制停止,并且视频文件已保存到指定文件夹,则操作成功。