10
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

WinRTのWindowsGraphicsCaptureAPIでキャプチャしたウィンドウをDirectShowで自作した仮想カメラに映そう

Last updated at Posted at 2021-05-22

N.Mです。

以前、DirectShowで仮想カメラを作成し、Qiitaに記事を投稿しました。
(DirectShowについては、この以前の記事をご覧ください。)

その時はアプリケーションで表示する映像をZoomなどのカメラに表示するために、GDI関数であるBitBltでピクセル情報を取得しました。

今回は古いGDI関数ではなく、WinRTに含まれるWindows.Graphics.Captureという比較的新しいAPIを利用して、アプリケーションの映像をキャプチャし、DirectShowの仮想カメラフィルタに送ってみました。

BitBltでの問題

ChromeやDiscordなどのアプリケーションでは、ハードウェアアクセラレーションというレンダリングを高速化するための機能があります。これを使用してウィンドウを表示している場合は、BitBltでピクセル情報を取得することができません。

ハードウェアアクセラレーションをONにするとGPU上で描画の処理を行い、その結果をウィンドウに貼り付けますが、BitBltだとGPU上で処理された映像を取得することができず、貼り付ける前の真っ黒な映像が取得されます。

WinRTのWindows.Graphics.Captureでは、GPU上で描画処理をした映像でもDirectXのテクスチャという形で取得することができ、以下のようにハードウェアアクセラレーションをONにしたChromeの映像も仮想カメラに映すことができます。

imgInZoom.PNG

サンプルコード

Githubリポジトリにあげているので、参考にしてください。

コンパイル時の注意点

今回、DirectShowというかなり古いAPIとWinRTという新しめのAPIを組み合わせようとしているため、コンパイルの段階で注意すべき点が3つあります。

  • 新しめのWindowsSDK (10.0.19041.0)を使用する必要があります。Visual Studio 2017を使用している場合は、ここからインストールする必要があります。

  • 言語標準をC++17に従う必要があります。Visual Studioでコンパイルする場合は、プロジェクトのプロパティでC/C++ → 言語 → C++言語標準ISO C++17標準に設定する必要があります。(WinRTではco_awaitなど新しいC++の標準に則ったプログラミングもされているため。)

  • ヘッダファイルでWinRTに関係するヘッダファイルをインクルードする前に#undef DisplayTypeを記述する必要があります。(DirectShowで定義されているDisplayTypeというマクロがWinRTのヘッダファイルで競合しているため。)

WinRTからDirectShowに映像を渡すまでの流れ

WinRTからDirectShowに映像を渡すまでは、以下のような流れになります。

  1. Direct3DDeviceとCPUで読み出し可能なバッファテクスチャの設定

  2. キャプチャする対象となるウィンドウ情報をもつGraphicsCaptureItemの取得

  3. 1のDirect3DDeviceと2のGraphicsCaptureItemから、Direct3D11CaptureFramePoolを作成

  4. 3で作成したDirect3D11CaptureFramePoolのイベントFrameArrivedで、キャプチャした画像を1で設定したCPUで読み出し可能なバッファテクスチャにコピー

  5. CPUで読み出し可能なバッファテクスチャからピクセル情報を取得

  6. DirectShowのFillBuffer関数内で、引数であるpSampleに5で取得したピクセル情報を渡す。

この後、1つ1つ詳細を解説していきます。

Step1. Direct3DDeviceとCPUで読み出し可能なバッファテクスチャの設定

以下のcreateDirect3DDeviceをソースフィルタのピンクラスであるCSourceStreamのコンストラクタで呼び出して、Direct3DDeviceの設定をします。設定したDirect3DDeviceを利用して、CPUで読み出し可能なテクスチャの作成も行います。

CPUで読み出し可能なテクスチャはウィンドウサイズの変更に対応できるように、大きめに作成しておきます。

参考


//コンストラクタあたりでこの関数を呼び出します。
void NMVCamPin::createDirect3DDevice() {
    UINT createDeviceFlags = D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CPU_ACCESS_READ;
#ifdef _DEBUG
	  createDeviceFlags |= D3D11_CREATE_DEVICE_DEBUG;
#endif
    //m_dxDevice...IDirect3DDevice型
    //今回取得する目的のオブジェクト。m_dxDeviceはStep3, 4で使用します。
	  if (m_dxDevice != nullptr) {
		    m_dxDevice.Close();
	  }
	  com_ptr<ID3D11Device> d3dDevice = nullptr;
	  check_hresult(D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE,
		nullptr, createDeviceFlags, nullptr, 0, D3D11_SDK_VERSION,
		d3dDevice.put(), nullptr, nullptr));
	  com_ptr<IDXGIDevice> dxgiDevice = d3dDevice.as<IDXGIDevice>();
	  com_ptr<::IInspectable> device = nullptr;
	  check_hresult(::CreateDirect3D11DeviceFromDXGIDevice(dxgiDevice.get(), device.put()));
    m_dxDevice = device.as<winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice>();

    //m_deviceCtx...com_ptr<ID3D11DeviceContext>型
    //m_deviceCtxはStep4, 5で使用する。
	  d3dDevice->GetImmediateContext(m_deviceCtx.put());

	  //CPUから読みだすためのバッファテクスチャ
    //m_bufferTextureDesc...D3D11_TEXTURE2D_DESC型
    //リサイズのために大きめのサイズでテクスチャを作っておきます。
    //(サンプルでは仮想カメラが1280px×720pxに対し、バッファテクスチャは3840px×2160pxで設定しております。)
	  m_bufferTextureDesc.Width = MAX_CAP_WIDTH;
	  m_bufferTextureDesc.Height = MAX_CAP_HEIGHT;
	  m_bufferTextureDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM;
	  m_bufferTextureDesc.ArraySize = 1;
	  m_bufferTextureDesc.BindFlags = 0;
	  m_bufferTextureDesc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
	  m_bufferTextureDesc.MipLevels = 1;
	  m_bufferTextureDesc.MiscFlags = 0;
	  m_bufferTextureDesc.SampleDesc.Count = 1;
	  m_bufferTextureDesc.SampleDesc.Quality = 0;
	  m_bufferTextureDesc.Usage = D3D11_USAGE_STAGING;

    //m_bufferTexture...ID3D11Texture2D型
	  d3dDevice->CreateTexture2D(&m_bufferTextureDesc, 0, &m_bufferTexture);
}

Step2. GraphicsCaptureItemの取得

キャプチャ対象となるウィンドウの情報をもつGraphicsCaptureItemを取得する必要がありますが、WinRTに含まれるGraphicsCapturePickerを使用する方法と、ウィンドウハンドル(HWND)を用いて作成する方法の2通りあります。

前者は長くなるのでTipsに後述します。前者の方法だとユーザがどのウィンドウをキャプチャするか指定することができるため、汎用性は高いですが、場合によってピッカーUIが開かないなどの問題があります。(サンプルコードの方では前者のGraphicsCapturePickerを使用する方法でやっております。)

後者については以下の記事が参考になります。特定のウィンドウをキャプチャする場合は後者のほうが手っ取り早いです。

参考

ほぼ後者の記事の引用になってしまいますが、特定ウィンドウ名のGraphicsCaptureItemを取得するコードです。
(サンプルでは前者を採用しているため、このコードはリポジトリには含まれておりません。)


struct WindowCell {
	  HWND window;
	  WCHAR name[256];
};

BOOL CALLBACK EnumWndProc(HWND hWnd, LPARAM lParam) {
    //1つのウィンドウについて、指定した名前のウィンドウか確認し、そうならば、lParam(WindowCellのポインタを渡す)に
    //そのウィンドウハンドルを入れて返すコールバック関数。
	  WCHAR buff[256] = L"";
	  SendMessageTimeoutW(hWnd, WM_GETTEXT, sizeof(buff), (LPARAM)buff, SMTO_BLOCK, 100, NULL);
	  if (wcscmp(buff, ((WindowCell *)lParam)->name) == 0) {
		    ((WindowCell *)lParam)->window = hWnd;
		    return FALSE;
	  }
	  return TRUE;
}

GraphicsCaptureItem CreateCaptureItemForWindow(HWND hwnd)
{
    namespace abi = ABI::Windows::Graphics::Capture;

    WindowCell wc;
   	wc.window = NULL;
    //キャプチャ対象とするウィンドウ名をwc.nameにコピー
	  wcscpy(wc.name, "TARGET_WINDOW_NAME");
    //デスクトップ上にあるウィンドウを走査し、特定ウィンドウ名のウィンドウハンドルを取得します。
	  EnumWindows(EnumWndProc, (LPARAM)&wc);

    //以降のコードは参考記事からほぼ引用
    auto factory = get_activation_factory<GraphicsCaptureItem>();
    auto interop = factory.as<::IGraphicsCaptureItemInterop>();
    GraphicsCaptureItem item{ nullptr };
    if (wc.window != NULL) {
        check_hresult(interop->CreateForWindow(wc.window, guid_of<abi::IGraphicsCaptureItem>(),       
        reinterpret_cast<void**>(put_abi(item))));
    }
    return item;
}

Step3. Direct3D11CaptureFramePoolの作成

Direct3DDeviceとGraphicsCaptureItemを取得することができれば、以下のようなコードでキャプチャを開始することができます。

この時にGraphicsCaptureSession.IsCursorCaptureEnabled(bool)を使用して、マウスカーソルもキャプチャするかの設定も可能です。


//m_graphicsCaptureItem...GraphicsCaptureItem型
//ここに取得したGraphicsCaptureItemを入れます。
m_graphicsCaptureItem = targetCaptureItem;

//m_capWinSize...SizeInt32型
//現在のウィンドウのサイズを格納し、ウィンドウのサイズが変更されたかを検出するためのデータ
m_capWinSize = m_graphicsCaptureItem.Size();

//m_capWinSizeInTexture...D3D11_BOX型
//こちらも現在のウィンドウサイズを格納しますが、Step4のテクスチャのコピーの際に使用します。
m_capWinSizeInTexture.right = m_capWinSize.Width;
m_capWinSizeInTexture.bottom = m_capWinSize.Height;

//m_framePool...Direct3D11CaptureFramePool型
m_framePool = Direct3D11CaptureFramePool::CreateFreeThreaded(m_dxDevice, DirectXPixelFormat::B8G8R8A8UIntNormalized, 2, m_capWinSize);

//m_frameArrived...event_revoker<IDirect3D11CaptureFramePool>型
//1フレームキャプチャできた時に呼ばれるイベントFrameArrivedを設定します。詳細はStep4
m_frameArrived = m_framePool.FrameArrived(auto_revoke, { this, &NMVCamPin::onFrameArrived });

//m_captureSession...GraphicsCaptureSession型
//このオブジェクトを介してキャプチャを開始できます。
m_captureSession = m_framePool.CreateCaptureSession(m_graphicsCaptureItem);

//IsCursorCaptureEnabledでマウスカーソルもキャプチャするか指定できます。
//trueでカーソルも入り、falseでカーソルは消えます。
m_captureSession.IsCursorCaptureEnabled(false);
m_captureSession.StartCapture();

Step4. Direct3D11CaptureFramePool.FrameArrivedDirect3D11CaptureFrameの取得

Step3でm_frameArrivedにイベントを設定していましたが、そのイベント時に呼び出す処理が以下の通りです。

onFrameArrivedの引数であるsenderTryGetNextFrameでキャプチャしたフレームを取得できます。これをID3D11Texture2Dのテクスチャの形に変換し、CPUで読み出し可能なバッファテクスチャにコピーします。

参考

ID3D11DeviceContext::CopySubresourceRegion詳細
でCPUで読み出し可能なテクスチャの一部にコピーするようにしております。ここを参考サイトのようにID3D11DeviceContext::CopyResourceを使用してコピーしようとすると、コピー元とコピー先でサイズが一致する必要があります。結果としてウィンドウのリサイズに対応しようとするといちいちCPUで読み出し可能なテクスチャをそのウィンドウサイズに合わせて作り直す必要が発生し、処理が重くなってしまいます。


void NMVCamPin::onFrameArrived(Direct3D11CaptureFramePool const &sender,
	  winrt::Windows::Foundation::IInspectable const &args)
{
	  auto frame = sender.TryGetNextFrame();

	  SizeInt32 itemSize = frame.ContentSize();
	  if (itemSize.Width <= 0) {
		    itemSize.Width = 1;
	  }
	  if (itemSize.Height <= 0) {
		    itemSize.Height = 1;
	  }
	  if (itemSize.Width != m_capWinSize.Width || itemSize.Height != m_capWinSize.Height) {
       //ウィンドウサイズが変更された場合の処理。
        //Direct3D11CaptureFramePoolのRecreateメソッドでキャプチャ中も簡単にサイズ変更ができます。
	 	    m_capWinSize.Width = itemSize.Width;
		    m_capWinSize.Height = itemSize.Height;
		    m_capWinSizeInTexture.right = m_capWinSize.Width;
		    m_capWinSizeInTexture.bottom = m_capWinSize.Height;
		    m_framePool.Recreate(m_dxDevice, DirectXPixelFormat::B8G8R8A8UIntNormalized, 2, m_capWinSize);
	  }

    //frameからID3D11Texture2Dに変換します。
	  com_ptr<ID3D11Texture2D> texture2D = getDXGIInterfaceFromObject<::ID3D11Texture2D>(frame.Surface());

	  //CPU読み込み可能なバッファテクスチャにGPU上でデータコピー
	  m_deviceCtx->CopySubresourceRegion(m_bufferTexture, 0, 0, 0, 0,
		    texture2D.get(), 0, &m_capWinSizeInTexture);

    //Step5の処理
    convertFrameToBits();
}

Step5. CPUで読み出し可能なバッファテクスチャからピクセル情報の取得

CPUで読み出し可能なテクスチャにコピーできれば、D3D11_MAPPED_SUBRESOURCEにマップすることで、キャプチャされた画像の画素にアクセスできます。この画素情報をunsigned char型の配列に格納していきます。

void NMVCamPin::convertFrameToBits() {
    //m_frameBitsはunsigned charのポインタで、コンストラクタで(仮想カメラの横ピクセル数×仮想カメラの縦ピクセル数×3)の要素数の配列として
    //動的生成しております。1画素につきRGBの計24ビット=3バイトであるため×3となっております。

	  D3D11_MAPPED_SUBRESOURCE mapd;
	  HRESULT hr;
	  hr = m_deviceCtx->Map(m_bufferTexture, 0, D3D11_MAP_READ, 0, &mapd);
	  const unsigned char *source = static_cast<const unsigned char *>(mapd.pData);
	  int texBitPos = 0;

	  //取得したピクセル情報からビットマップを作る処理

    //説明を簡単にするためのコードであるため、以下のコードだとキャプチャされた画像がそのままのサイズで上下反転した状態で出ます。
    //仮想カメラ内に収める場合については、サンプルリポジトリのNMVCamPin::changePixelPos()やNMVCamPin::convertFrameToBits()をご覧ください。
	  int pixelPosition = 0;
	  int bitPosition = 0;
	  for (int y = 0; y < WINDOW_HEIGHT; y++) {
		    for (int x = 0; x < WINDOW_WIDTH; x++) {
            texBitPos = mapd.RowPitch * y + 4 * x;
            for (int cIdx = 0; cIdx < 3; cIdx++) {
      				  m_frameBits[bitPosition + cIdx] = source[texBitPos + cIdx];
      			}
			      pixelPosition++;
			      bitPosition += 3;
		     }
	   }
	   m_deviceCtx->Unmap(m_bufferTexture, 0);
}

Step6. DirectShowのFillBuffer関数での、ピクセル情報の送信

あとは継承しているソースフィルタのピンクラスCSourceStreamFillBufferメソッドで、以下のようにStep5で取得した画素情報を送ることで、仮想カメラにキャプチャした映像が表示されます。

FillBufferメソッドの最後にsleep_forメソッドを入れ、適度に他のスレッドにCPU時間を渡すと、仮想カメラ起動中もZoomの応答が良くなりました。(なかった場合は、Zoomのボタンがなかなか反応しませんでした。)


HRESULT NMVCamPin::FillBuffer(IMediaSample *pSample) {
	  HRESULT hr=E_FAIL;
	  CheckPointer(pSample,E_POINTER);
	  // ダウンストリームフィルタが
	  // フォーマットを動的に変えていないかチェック
	  ASSERT(m_mt.formattype == FORMAT_VideoInfo);
	  ASSERT(m_mt.cbFormat >= sizeof(VIDEOINFOHEADER));

	  LPBYTE pSampleData=NULL;
	  const long size=pSample->GetSize();
    //ここで画素情報を送るべきメモリへのポインタpSampleDataを取得しています。
	  pSample->GetPointer(&pSampleData);

    //中略

    //pSampleDataへ画素情報をコピー
    //WINDOW_WIDTH...仮想カメラの横の画素数
    //WINDOW_HEIGHT...仮想カメラの縦の画素数
    memcpy(pSampleData, m_frameBits, WINDOW_WIDTH * WINDOW_HEIGHT * 3);

    //中略
    //CPU使用率を抑えて、ZoomなどのUIの反応をしやすくするために適度にSleepします。
	   std::this_thread::sleep_for(std::chrono::milliseconds(10));
}

Tips

終了時やウィンドウ切替え時の後始末

ウィンドウを切り替える時やキャプチャを終了するときは、一度以下のようなコードで後始末をする必要があります。

参考


void NMVCamPin::stopCapture() {
	  if (m_framePool != nullptr) {
        //FrameArrivedイベントの取り消し
		    m_frameArrived.revoke();

        //GraphicsCaptureSessionの後始末
		    m_captureSession = nullptr;

        //Direct3D11CaptureFramePoolの後始末
		    m_framePool.Close();
		    m_framePool = nullptr;

        //前に使用していたGraphicsCaptureItemの後始末
		    m_graphicsCaptureItem = nullptr;
	  }
}

ただ、この後始末はDirect3D11CaptureFramePoolGraphicsCaptureSessionを生成したスレッドと同じスレッドで行う必要があります。別のスレッドで後始末をしてしまうとwrong_threadという例外が発生し、Visual Studioでは「別スレッド用にマーシャリングされたインタフェースを呼び出しました」というメッセージが表示され、止まってしまいます。

キャプチャするウィンドウをいつでも切り替えられるように、Step3をCSourceStreamFillBufferメソッドで行っていた場合は、後始末をCSourceStreamクラスのデストラクタで呼ぶと上記のwrong_threadの例外が発生します。CSourceStreamは別スレッドでFillBufferを実行しているようで、デストラクタが実行されるスレッドとは異なるようです。

CSourceStreamクラスには、カメラ切替えなどで終了し、FillBufferを実行していたスレッドが破棄される際にOnThreadDestroyという仮想メソッドが呼ばれるため、このメソッドをオーバーライドし、ここで後始末をすれば、例外は発生しません。


HRESULT	NMVCamPin::OnThreadDestroy() {
	  stopCapture();
	  return NOERROR;
}

GraphicsCapturePickerの利用(要調査)

GraphicsCapturePickerを起動する際に、別スレッドで起動をするようにしました。
(サンプルコードでは、FillBufferメソッド内でDirectInputによるキー入力を受け付けることで、起動しております。)


//m_isSelectingWindow...bool型
//ピッカーでウィンドウを選択中かどうかを管理します。
m_isSelectingWindow = true;
m_capturePickerThread = new std::thread([](NMVCamPin *inst) {inst->openCaptureWindowPicker(); }, this);

生成した別スレッドでは、以下のような処理をしております。(参考
UWPアプリではない場合は、ピッカーをウィンドウと関連付ける必要があるため、ウィンドウを生成し、ピッカーの選択肢から生成ウィンドウを外すためにSetWindowDisplayAffinityで設定をしています。

また、ピッカーを開く前に、init_apartment();をする必要がありますが、CSourceStreamのメソッドでやると、仮想カメラを開くアプリケーションによっては例外が発生してしまいます。
init_apartment();はWinRTのCOMの初期化をしているようです。仮想カメラを開く頃にはアプリケーション側で初期化が済んでおり、この時の初期化の設定(STAかMTA)がアプリケーションで行う初期化での設定とずれていると例外が発生します。)

そのため、別スレッドでピッカーの起動処理を行っているわけです。


void NMVCamPin::openCaptureWindowPicker() {
	  init_apartment();

    //ピッカーに関連付けるウィンドウの生成します。
	  m_attatchedWindow = CreateWindowW(L"STATIC", L"NMUniversalVCam", SS_WHITERECT,
		    0, 0, 300, 1, NULL, NULL, GetModuleHandleW(NULL), NULL);
	  ShowWindow(m_attatchedWindow, SW_SHOW);
	  UpdateWindow(m_attatchedWindow);

	  //ピッカーを開くために作ったウィンドウをピッカーの選択肢から除外します。
	  SetWindowDisplayAffinity(m_attatchedWindow, WDA_EXCLUDEFROMCAPTURE);
	  updateAttatchedWindow();

    //ウィンドウとピッカーとの関連づけます。
	  GraphicsCapturePicker picker;
	  auto interop = picker.as<::IInitializeWithWindow>();
	  m_pickerResult = interop->Initialize(m_attatchedWindow);

    //ピッカーからの結果を受け取るためのオブジェクトをm_graphicsCaptureAsyncResultに格納します。
	  m_graphicsCaptureAsyncResult = picker.PickSingleItemAsync();

	  while (m_isSelectingWindow) {
        //結果を受け取るまで生成したウィンドウへのメッセージを処理します。
        //FillBufferのスレッドで結果を受け取ると、m_isSelectingWindowがfalseになります。
		    updateAttatchedWindow();
		    std::this_thread::sleep_for(std::chrono::milliseconds(10));
	  }
	  updateAttatchedWindow();
}

void NMVCamPin::updateAttatchedWindow() {
	  MSG msg;
    //GetMessageだとメッセージを受け取るまでここでブロックしてしまうので、
    //PeekMessageを使用します。
	  if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE) > 0) {
		    TranslateMessage(&msg);
		    DispatchMessage(&msg);
	  }
}

仮想カメラピンクラスのFillBufferメソッド側では、以下のようにしてピッカーからの結果を受け取ります。

GraphicsCapturePickerに関する参考記事等ではm_graphicsCaptureAsyncResultに対しco_awaitを使用することで、待機しているようですが、co_awaitを使用すると、使用した関数(今回だとFillBufferメソッド)の返り値もIAsyncActionにする必要があります。

参考

FillBufferメソッドはオーバーライドしたメソッドで、返り値を変更できないため、手動で待機処理を再現しております。


if (m_isSelectingWindow) {
		if (m_graphicsCaptureAsyncResult) {
        //ピッカーから結果が来ると、statusがCompletedになります。
        //FillBufferで毎回このstatusを確認することで、ピッカーから結果が返ってきたかどうかを確認します。
			  winrt::Windows::Foundation::AsyncStatus status = m_graphicsCaptureAsyncResult.Status();
			  if (status == winrt::Windows::Foundation::AsyncStatus::Completed) {
				    GraphicsCaptureItem tmpTarget = m_graphicsCaptureAsyncResult.GetResults();

            //changeWindowで、生成ウィンドウの破棄とStep3のDirect3D11CaptureFramePool生成処理を行っております。
				    changeWindow(tmpTarget);
				    m_isSelectingWindow = false;

            //ピッカー生成のために起動した別スレッドの終了
				    m_capturePickerThread->join();
				    delete m_capturePickerThread;
				    m_capturePickerThread = nullptr;
			  }
		}
}

ただ、ここまでの方法だと一部のアプリケーションではうまくピッカーが開きませんでした。別スレッドで生成するウィンドウは開きますが、GraphicsCapturePickerのピッカーが立ち上がらず、すぐに生成したウィンドウが閉じてしまいます。

ZoomやFireFox, OpenCVでカメラ管理をする自作アプリではピッカーが開きましたが、DiscordやChrome, Slackでは開きませんでした。この部分は時間があるときに調査しようと思います。(ご存知の方がいらっしゃれば、コメント等で教えていただけると幸いです。)

まとめ

前回の仮想カメラの記事から発展して、新しめであるWinRTのAPIを古いDirectShowでも利用できるという話でした。これを利用すれば、Zoomなどにある元々の画面共有に加えて自分のカメラも画面共有として利用することもできるでしょう。

10
12
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?