網路攝影機錄影器

本節將示範如何建立一個利用連接至 PC 的周邊設備之一——網路攝影機的 Extension。

首先,於 RC+ 的停駐視窗中,實現網路攝影機影像預覽的功能(初級篇)。

接著,新增影像錄製功能(中級篇)。錄影將隨著 SPEL+ 程式的啟動開始,並於結束時停止。考量程式可能長時間運行,將每 5 秒建立一個新檔案進行錄影,並僅保留最新的 2 個檔案。

這是在系統中新增類似車用行車記錄器的功能。於設備啟動過程等場合,若能監控機器人作業,當程式意外停止時,可透過錄製的影像事後目視確認發生了什麼狀況。

若 PC 已連接網路,也可搭配錄影資料,發送異常通知等應用。

那麼,讓我們開始吧。

■ 初級篇

  1. 請依照「開始使用」的步驟,建立 Extension 的新專案。

    • 名稱請設定為 WebCamRecorder。
    • 初始功能請勾選Main menu and tool bar item以及Docking 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 標準函式庫,但可透過下述 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

      • 此為於本 Extension 中,記述代表攝影機的 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

      • 此為記述 CameraInfoCollection 類別的檔案,用以表示攝影機清單,並取得指定攝影機的媒體來源(於 Windows Media Foundation 中,為資料處理的入口物件)。
      (前略)
      
      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 中,Auto 模式錄影時,禁止手動開始/停止錄影;反之於 Manual 模式錄影時,Auto 模式錄影將不啟用。
        • 但無論哪種情況,關閉停駐視窗即會停止錄影。
      (前略)
      
      </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 檔案。

    • 新增 Auto 模式錄影功能。為此,必須在 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 秒錄影會停止,若影片檔案已儲存至指定資料夾即表示成功。