α6100 を macOS で Webカム化(HDMI キャプチャなし、クロマキー合成あり)して Google Hangouts でビデオチャットする方法

まえがき

【紹介している仕組みが ver 1.0.0 になったので一部更新】

前回のエントリビデオチャットの品質向上として映像の質について言及しておいてその内容を書いていませんでした。今回はそれについて書きたいと思います。

ここで紹介する方法はアルファ版の OSS ソフトウェアを使います。使用にあたって機材が故障してもすべて自己責任でお願いします。

基本的にソフトウェアエンジニア向けの内容です。Git, Homebrew, ビルド etc というキーワードを聞いてピンとこない方はやめておいた方がいいと思います。このあたりがわからない方で「パソコン側の負荷を下げてつつ、ミラーレス一眼をウェブカムにしてクロマキー合成したい」という方は ATEM Mini の在庫が復活したころに札束で勝負することをおすすめします。*1(やったことがないけど多分できます)

目的

画質の向上です。

普段のミーティングなら表情などのビジュアル要素も重要なインプットとして使っているはずです。ビデオチャットになるとその情報の質がガクっと落ちてしまいます。そうなると効率が落ち、疲労感が出る(失った情報を別の要素で埋めようとして疲れる)のでどうにかしたいと思いました。

(実際には相手の画質が良くないと自分は結局疲れるんですが、まずは自分からと言うことで)

Before な環境

自宅には書斎があります。ただし、物干し部屋と兼用です。

窓を背にするようにL字型デスクを置いてあります。当然、洗濯物は窓側に干しています。つまりそのままビデオチャットをすると後ろにある干している洗濯物が丸見えになってまずいです。L字型の机の短い方に MacBook Pro を置くとカメラは壁向きになるので余計なものは映りません。ただそこに MacBook Pro を置くためには外部モニターとの接続をはずさないとケーブルの長さ的に足りません。普段はそちら側に iPad などを置いているのでそれをどかさないといけなくなりとても面倒な状況でした。

やりたいこと

  1. 後ろにある洗濯物など生活感が出るものは映さないようにしたい
  2. どうせなら zoom の virtual background みたいに何かを合成したい
  3. そして画質を上げる

やり方を考える

背景処理

そこそこ小綺麗なものが置いてあれば良いカメラとレンズで良い感じぼかしてやれますがそうもいかないので遮蔽物を置きます。WFH がいろんなところで本格的に実施され始めたころに @fladict さんが Twitter でつぶやかれていたグリーンバックの背景布 + スタンドを紹介されていたのでそれを買いました。

グリーンバックだけだとクロマキー合成ができなかったときに違和感があると思ったのでシンプルに黒幕も同時に買いました。後々この黒を買っていて良かったのは、グリーンだけだと若干記事が透けます。冒頭で説明したとおりデスクに向かった時に背後は大きな窓があるため日差しが入り込むので透けやすいのですが、この黒と2枚重ねにすることで透けを防止できました。

画質

昨年夏から飼い始めた猫の ぐりこさん を撮影するためにα6100を持っています。あとは、家にいっぱい転がっている iPhoneiPhone のカメラは下手なコンデジよりも性能が良いって言われることもあるので悪くないと選択だと思います。

今回は、α6100 でトライしてみました。

対策

@goando が使われている SIGMA fp は USB Video Class(UVC) に対応しているので USB 接続するだけで macOS がカメラをウェブカムとして認識してくれます。僕が持ってる α6100 (おそらくは他の SONY α 系も同様の模様)は USB で macOS と接続はできますがそれだけではウェブカムとしては認識してくれません。Elgato Cam Link 4KATEM Mini などの製品は、この製品に input としてカメラからの HDMI 出力を渡してあげると製品からの USB 接続は UVC として macOS が認識してくれるためそれだけで Web カムとして使えます。

ウェブカムとして認識してくれると何がうれしいかと言うと、ほとんどのカメラを使ったソフトウエアで汎用的にその映像が使えるところです。

などなど。実際のビデオチャットサービスに縛られないのは凄くありがたい仕組みですよね。

残念ながら現在 HDMI キャプチャデバイスと呼ばれるようなこの手の製品は軒並み品切れしています。僕も買おうかどうしようか迷っていた頃には在庫があったのですが決心をした頃には時既に遅しということで持っていません。ですので今回紹介する方法は HDMI キャプチャデバイスなしで行います。

今回の僕の目標にクロマキー合成が含まれているのでそれらを踏まえて以下のようなソフトウエアを使っていきます。

僕の環境はこちら

Camera Live

Camera Live

このソフトウエアは、Syphon と呼ばれる映像をソフトウエア間で渡すための仕組みを使って USB 接続した α6100 の映像を他のソフトウエアに渡してくれるものです。Syphon は VDJ 界隈ではポピュラーなものらしいです。Camera Live 自体は Syphon server として動作するので Camera Live が配信する映像を受け取る Client が必要になります。

OBS

OBS(Open Broadcaster Software) と呼ばれるソフトウエアを使います。このソフトウエアは twitch や YouTube などに配信するためのソフトウエアです。カメラ映像やテキスト、静止画などの様々な映像ソースや音声をミックスすることができます。

OBS は Syphon Client を映像入力として扱えます。つまり α6100 の映像を Camera Live を通じて OBS 上で表示ができるようになります。

また、OBS にはクロマキー合成を行う仕組みもはじめから入っています。

obs-mac-virtualcam プラグイン

obs-mac-virtualcam

このソフトウエアは OBS のプラグインです。OBS はデフォルトで各種ライブストリーミングサービスにそのままライブ配信できる機能があります。ローカルに動画ファイルとして残すこともできます。ただ、ビデオチャットサービスにはそのまま配信はできません。ビデオチャットサービスで使うためには Virtual Camera(仮想カメラ)として OBS で扱っている映像を出力する必要があります。obs-mac-virtualcam プラグインはそれを可能にしてくれるプラグインです。

Virtual Camera(仮想カメラ)というのは僕も最近知ったものですが、有名どころだと Snap Camera が Virtual Camera として振る舞っています。Snap Camera は FaceTime Camera などのカメラ映像を加工し、加工した結果を Virtual Camera として提供することでビデオチャットサービスにバーチャル背景や美肌効果が加わった映像を利用できるようにしています。

準備

Camera Live をダウンロード

Releases · v002/v002-Camera-Live

こちらのリリースページから新しいものをダウンロードします。現時点では 13(Alpha) が1番新しいのでこれをダウンロードします。13(Alpha) をクリックすると Assets のところに Camera.Live.zip があるのでこれをクリックしてダウンロードします。

ダウンロードして解凍したらアプリケーションフォルダに移動させましょう。

OBS & obs-mac-virtualcam のビルド のインストール

obs-mac-virtualcam プラグインが順調に開発を重ね公式版になりました。以前は自分で OBS も含めてビルドしないといけなかったためエンジニアじゃない人にはハードルが高かったですが簡単な手順でインストールできるようになりました。

  1. 最新の OBS をダウンロードしてインストールしてください
  2. 最新の obs-mac-virtualcam プラグイン の pkg ファイル(拡張子が pkg に鳴っているファイル)をダウンロードしてください
  3. obs-mac-virtualcam プラグインの pkg ファイルをダブルクリックしてプラグインをインストールしてください
  4. もし OBS を起動している状態であれば OBS を一度終了し、再度起動させてください
  5. Web カムとして認識させたいソフトウエアがあればそのソフトウエアも再起動させてください

個人的にはプラグインのインストールが終わったら macOS 自体を再起動させるのがいろいろと手っ取り早くトラブルも少なくなるのでおすすめです。

普通であれば OBS を公式サイトからダウンロードして云々というステップなんですが、obs-mac-virtualcam プラグインが開発途中のため自分でリポジトリをクローンして手順にそってビルドしないといけません。

こちらのビルドガイド を上から順に実行するだけです。普段から macOS 上で開発をしているような人であれば Xcode や Homebrew などもインストールされていることも多いと思うのただひたすらビルドガイド通りにターミナルでコマンドを叩いていきます。

実際にやってみる

1. まずは α6100 の設定を開いて「USB接続」を「PC リモート」に切り替えてください。

2. 次に α6100 のモードを動画撮影に切り替えます。

3. α6100 と MacBook Pro を USB ケーブルで接続します。

4. Camera Live を起動します。 この段階では Camera Live の真ん中上部の表示は No Camera になっていると思います。

カメラライブアプリを起動した直後の画像

5. α6100 の電源を入れます。 Camera Live に USB PTP Class Camera と表示され*2、真ん中上部の表示が Active になっていれば大丈夫です。

カメラライブアプリにカメラを接続してアクティブな状態のウインドウ

6. 次に OBS を起動させます。

7. 起動すると自動構成ウィザードが表示されますので "はい" をクリックしてください。

OBS の初回起動画面の画像

8. 「録画のために最適化し、配信はしない」を選択した状態で "次へ" をクリックします。 *3

9. 基本(キャンバス)解像度を 1280×720 に変更してください。(最近他の解像度もサポートされたようですが当時のサポート解像度の 1280×720 が無難です) FPS はデフォルト値でいいと思います。"次へ" をクリックしてください。

テストが走るのでしばらく待ちます。

テストが終わったら "設定を適用" をクリックします。

OBS の初期設定が終わったら早速カメラの映像を OBS 上に表示させましょう。

11. 下側にカラムが5個表示されていますが、左から2番目のソースカラムにあるプラスボタンをクリックしてください。

12. いくつかある項目の中からサイフォンクライアントを選択してください。名前を入力するためのシートが表示されますがここでは「サイフォンクライアント」としておいてください。

13. サイフォンクライアントのプロパティ設定ウインドウが表示されます。ソースをクリックして [Camera Live] USB PTP Class Camera を選択します。 この段階でプロパティウインドウにカメラからの映像が表示されていればOKです。もし映っていないようならばカメラとの接続か Camera Live の状態を確認しください。特に USB ケーブルはものによっては充電用のものがあります。必ずデータ転送ができるものにしてください。

14. サイフォンクライアントのプロパティ設定ウインドウではあともう一つ設定します。「透過を許可」のチェックを有効にしておいてください。このチェックボックスの設定を忘れているとクロマキー合成がうまくいきません。 チェックボックスも有効にしたらプロパティウインドウを OK を押して閉じてください。

15. 出力サイズに対してカメラの映像を映し出すレイヤーのサイズが合っていませんが一旦スルーして、クロマキー合成を早速行います。*4

16. ソースカラムに追加したサイフォンクライアントレイヤーを右クリックして「フィルタ」をクリックしてください。 フィルタ設定ウインドウが表示されます。

17. 左ペインの下にある+ボタンをクリックしてください。

18. いくつか表示される選択肢の中から「クロマキー」を選択してください。フィルタの名前はデフォルトの「クロマキー」のままで結構です。もうこの段階で背景のグリーンが透過されていると思います。(事前にグリーンバックは用意しておいてくださいね) 細かい設定値がありますが、個人的にはデフォルト値のままでも十分だったのでそのままにして「閉じる」をクリックしてください。

19. 背景画像のレイヤーを追加します。

先ほどと同様にソースカラムの+ボタンをクリックして「画像」を選択してください。レイヤーの名前はここでは「背景画像」としておきます。 こちらも先ほどと同様に画像レイヤーのプロパティ設定ウインドウが表示されます。 ここでは背景画像として使用する任意の画像を設定して OK をクリックしてください。

20. そうすると設定した画像*5が前面に映ってしまいます。カメラ映像を手前に持ってきたいのでソースカラムに並んでいるレイヤーをドラッグ&ドロップして背景画像を並びの一番下に移動させます。

21. あとは、表示されているコンテンツの位置を微調整します。

ここまでできたらもう少し。

映像を Virtual Camera 化します。

22. OBS のツールメニューにある Start Virtual Camera をクリックします。 もし、メニューバーがクリックできなければ適当な他のアプリにフォーカスを切り替えてからもう一度試してみてください。

23. Virtual Camera 化できているかを試すために QuickTime Player を起動してみてください。

【追記】: 僕の環境では QuickTime Player では OBS Virtual Camera は認識されても実際のカメラ映像が出力されない問題がありました。原因はまだ分かっていませんが、Hangouts などでのビデオチャットは問題なく動作しています。【追記終わり】

QuickTime Player を起動したらショートカットキー ⌘+⌥+N を押しください。カメラからの映像が QuickTime Player 上に表示されると思います。録画ボタンの横になるシェブロンをクリックして入力ソースのなかに OBS Virtual Camera を選んでください。これで OBS 上のプレビューと同じ映像が QuickTime Player 上に表示されていれば成功です。 もし、入力ソースのなかに OBS Virtual Camera が表示されない場合は QuickTime Player を再起動してみてください。

24. Google Hangouts を使ってみます。 Hangouts を開いてビデオハングアウトをクリックします。実際の通話相手は別に必要ありません。招待するためのウインドウとかも適当に閉じてください。OBS の映像が出てこない場合は Hangouts のカメラ設定を変更しないといけません。右上のギアアイコンをクリックして「ビデオ」のところから OBS Virtual Camera を選択してください。OBS Virtual Camera がない場合はブラウザの再起動をしてください。

f:id:griffin-stewie:20200428214430j:plain

tips

  • OBS の映像を Virtual Camera として認識されない場合はビデオチャットサービスを再起動する(ブラウザベースのサービスならブラウザの再起動)
    • OBS の Start Virtual Camera 実行以降に起動したアプリケーションじゃないと Virtual Camera を認識してくれない模様
  • クロマキー合成をする時には背景画像は背景の幕に近めの色が良い
    • 赤っぽい背景画像にすると境界線が緑っぽく見えたり、グリーンバック自体についているしわや影のある部分が綺麗に合成されない

この方法のメリット・デメリット

メリット

  • ミラーレス一眼カメラを使っているので圧倒的に画質がいい
  • しっかりとクロマキー合成ができて、部屋の背景を気にしなくてもいい
  • 現在入手困難な HDMI キャプチャデバイスがなくてもできる
  • ソフトウエアが無料

デメリット

  • 知識が必要
    • エンジニアじゃない人にはちょっと難しい
  • 動作が不安定
    • 特に Virtual Camera を認識させるためには先に OBS の Virtual Camera を動かしておかないといけない
  • CPU 負荷が高くなる
    • ごくまれにヒドいときは日本語入力がままならないですw

まとめ

今回紹介した方法に行き着くまでにもっと手軽な方法がないかなどいろいろと試してみましたが、結果的にこの方法がバランスが良かった感じです。

個人的なニーズをフルに満たすためには ATEM Mini を買うしかないと思っています。

興味のある人はチャレンジしてみてください。他にも良い方法があれば教えてください。

参考リンク

*1:僕も本当はこれが欲しい

*2:カメラの機種によって変わるようです

*3:「配信のために...」の方を選んでしまうとウィザードの最後に配信先の情報を必ず入力しなければならなくなる

*4:後ろにクロマキー合成用のグリーンバックが見えてます

*5:うちの猫ぐりこさん

リモートワーク環境の改善

コロナ禍においてリモートワークを新たにはじめた人は多いのではないでしょうか。COVID-19 感染拡大を防ぐために僕も今はリモートワークをしています。

リモートワークになると普段のやりとりはチャットツールが主軸になりますが、やはり人と声で会話するとなるとおのずとビデオチャット(Web会議)を使うことになります。ここで改めてビデオチャットで重要になる要素を整理してみます。

  • 映像
  • 音声
  • 通信速度

通信速度は映像や音声を継続的に送受信するため重要です。安定して高速な通信ができればビデオチャットの質(体験)が良くなることが期待できます。逆に貧弱な回線では音声がブチブチと途切れたりや映像が止まったりすることが発生する可能性があります。

音声は聞く方と話す方がありますが特に重要なのは話す方、マイクです。ビデオチャットをしていると音声が悪い人と良い人がいますが重要なはマイクだと思います。

最後に映像です。僕は仕事でもプライベートでも MacBook Pro を使用しています。ビデオチャットをする相手も多少モデル違いはありますが9割方は Mac です。Mac には FaceTime Camera が内蔵されています。最新の MacBook Pro 16インチでも現代においては若干ショボい 720p FaceTime HDカメラ が搭載されています。お手軽に使える利便性はありますが画質は言わずもがな悪いです。特に暗い場所ではザラつきがヒドいです。

ビデオチャットはこの3要素の質が悪いと疲労度が格段に上がるように思います。途切れ途切れの音声では脳内でなんと話したのかを補完しようとしたり、余計な質問をしたりします。プレゼンしているのを見ている最中に映像が止まってしまいプレゼン資料がめくられないまま会話がどんどん進んだりするとわかりやすさが半減してまた余計に疲れます。

これら3要素を可能な限り向上させることで、リモートワーク時のビデオチャットの質が上がりストレスを軽減できると思い自分なりに改善していきました。

通信速度の改善

通信速度の改善にはプロバイダーやバックボーンなどの要素、有線 LAN か無線 LAN などの接続形式、使用しているコンピュータ自身の処理速度など複雑です。プロバイダーなどは住環境に左右され改善しようにも簡単にはできない部分で難しいです。僕の環境は SOFTBANK 光を使っています。NURO 光よりは遅そうですが気になるような速度でもないです。一時期は当時の最新 iPhone でも 10Mbps 時もありましたが、IPv6 にするだけで大幅に改善されました。

今のマンションではルーターは家の中心にあるリビングルームにあります。僕の作業スペースである書斎はドアで仕切られているのと距離が遠いこともあり無線 LAN を使っていました。

通信速度は時間帯などによっても左右されますがやはり無線 LAN は遅いのでは?という思いがありました。ものは試しとリビングに MacBook Pro を持っていき有線で接続すると概ね 1.5倍から2倍ほど速度が上がりました。

今まで分かっていたものの、書斎で MacBook Pro を触ってる時間は多くもないし特に困っていなかったのでスルーしていましたが、リモートワークで平日の日中ずっと使うことやビデオチャットの品質向上を目指すため一念発起し有線 LAN 化を行いました。

リビングにあるルーターから書斎までは15mほどケーブルが必要でした。飼っている猫や見栄え、掃除などの生活の邪魔にならないことを考えて配線しようとするとモールで綺麗にケーブルを隠す必要がありました。角の処理を綺麗にしようと思うと曲がったモールが必要なのですが、ここで初めてデズミ、イリズミなどの言葉を知りました。

LAN ケーブル敷設にはなんだかんだで8〜9時間くらいかかってしまいました。

我が家は賃貸なのですが、賃貸でも綺麗に原状回復できるようなモールの貼り方を行いました。(実際に綺麗に戻るかどうかはまだ知らない...)

賃貸でモール敷設で紹介されていた PP クラフトフィルムという厚さ0.2mmほどの透明なフィルムをすべてのモールの下地に使いました。というのもモールにあらかじめついている両面テープは結構強力らしく、壁紙に直接貼ると剥がす時に壁紙もろとも剥がれる場合があるそうです。両面テープなしのモールが購入できなかったので、モールはフィルムに貼り付け、フィルムを壁や天井に張り付けます。フィルムは壁紙にはホッチキスで留めます。ホッチキスの針は細いのであまり目立たないです。僕の場合、天井はホッチキスで留めました。なのでますます目立ちにくいはずです。天井以外の壁はすべてこちらの両面テープで止めました。

剥がした後に水拭きすると接着面も綺麗に剥がせるそうです。厚みがある分ボコボコした壁紙でもしっかり貼れました。

有線LAN化の恩恵は期待通りで、この記事の執筆時点で

倍まではいきませんが苦労した甲斐がありました。有線になることでこれまで無線LANでまれに発生していた突然の切断もなくなり快適です。

音声の改善

MacBook Pro 内蔵のマイクは音質もさほど良くないです。指向性の弱いマイクのため周りの音を良くも悪くも拾いすぎて騒々しかったりします。さらに現行 MacBook Pro の前のモデルはバタフライキーボードでタイピング音が結構します。その音もガッツリ拾ってしまうためミーティング中にメモを取っているとなかなか耳障りです。

内蔵マイクがイマイチなら Air Pods Pro があるじゃないかとなるのですが、こちらは装着感と音を聞くという点では好みなのですがマイクが貧弱です。カスカスの音になります。

とはいえそこまでしっかりお金をかける気もなく、調査に時間をかける気もなかったので無難に会社で広く使われていたこちらの商品を買いました。

マイクにノイズキャンセリングがついているので音質が格段によくなります。ヘッドセットにマイクがついているのでタイピング音も以前ほど耳障りにはなりにくくなっています。Ear PodsBose QC20 のようなイヤフォンについているマイクは顔から離れている場所にあるので小声で話すと全然音がクリアに入りませんが、このヘッドセットなら多少声が小さくても大丈夫です。

個人的に難点なのは付け心地があまり良くないところです。ミーティングが連続してしまうと長時間付けることになりますが、そうなると挟むように固定されているので耳が痛いです。その点は Air Pods Pro や Bose QC20 のようなインイヤーヘッドフォンが快適ですね。

ゲーミング系のヘッドセットだと高機能で長時間付けていても耳が痛くなりにくそうで良さそうです。

もしくは、RODE VideoMicro みたいなマイクか NT-USB Mini を置いて音は Air Pods Pro あたりでも良いかもしれないと思っているところです。

【国内正規品】RODE ロード NT-USB Mini USBマイク NTUSBMINI

【国内正規品】RODE ロード NT-USB Mini USBマイク NTUSBMINI

  • 発売日: 2020/04/17
  • メディア: エレクトロニクス

映像の改善

こちらはいろいろと調べながら試しているところです。長くなったのでこちらの別エントリで書きました。

α6100 を macOS で Webカム化(HDMI キャプチャなし、クロマキー合成あり)して Google Hangouts でビデオチャットする方法 - griffin-stewieのブログ

おまけ

モニター環境もリニューアルしました。会社では LG の 27UL850-W を使っていて同じものを自宅用にも買おうとしていました。

同僚から DELL から同等クラスの新しく出ることを教えてもらいちょうど LG が在庫切れになっていのもあり以下の DELL U2720QM を買いました。

このディスプレイは LG のものよりも好みの画質で買って良かったです。

まとめ

結構な手間とお金がかかりましたがおかげで会社と同等か場合によってはそれ以上の快適さを手に入れることができました。リモートワークをしているみなさんも今回僕が実践した改善の中から1つで取り組んでみると QOL が爆上がりするかもしれません。ぜひお試しください。

Swift ArgumentParser に version 番号出力がビルトインサポートされる

Swift ArgumentParser に version 番号出力がビルトインサポートされる

以前、Swift ArgumentParser でバージョン番号を出力する方法 - griffin-stewieのブログ というエントリに当時の Swift ArgumentParser で --version フラグでバージョン番号を出力する方法を紹介しました。その時のエントリでも ArgumentParser 側でサポートされたらいいなということを書いていましたが、1週間前の 3/31 にその機能が実装されました。

Add built-in support for --version flag (#102) · apple/swift-argument-parser@31799bc

まだ、リリースはされていませんが、どのように変わったのかを紹介します。

これまでの実装方法

import ArgumentParser

struct MyEcho: ParsableCommand {

    static var configuration = CommandConfiguration(commandName: "myEcho")

    struct Version: ParsableArguments {
        @Flag()
        var version: Bool

        func validate() throws {
            if version { throw CleanExit.message("1.0.0") }
        }
    }

    @OptionGroup()
    var version: Version

    @Argument()
    var text: String

    func run() throws {
        print(text)
    }
}

MyEcho.main()

上記サンプルコードは前回のエントリで紹介した、必須引数とバージョンフラグの両立をするためのコード例です。--version フラグでバージョン番号を出力させるために

  • Version struct の定義と func validate() throws の実装
  • @OptionGroupversion プロパティを実装

というそれなりの行数を必要としていました。

built-in support された ArgumentParser を使った場合

import ArgumentParser

struct MyEcho: ParsableCommand {

    static var configuration = CommandConfiguration(commandName: "myEcho", version: "1.0.0")

    @Argument()
    var text: String

    func run() throws {
        print(text)
    }
}

MyEcho.main()

CommandConfiguration のイニシャライザにバージョンの文字列を渡すだけです。これだけで 1.0.0 と出力されるようになります。

ちゃんとヘルプにも --version フラグがの説明が表示されます。

% myEcho --help
USAGE: myEcho <text>

ARGUMENTS:
  <text>

OPTIONS:
  --version               Show the version.
  -h, --help              Show help information.

まとめ

  • 以前のような --version フラグをサポートするだけのためにワークアラウンドは不要になった
  • -v のようなショートオプションは未サポート
    • どうしても --version 以外の指定方法をサポートしたい場合は以前の方法で実装すればよい
  • 簡単

蛇足

この機能が実装される Pull Request で唐突に Nate さんから「どう思う?」って聞かれてビビりました。

僕からは

  • ヘルプに --version フラグの説明ないよ
  • --version 以外の名前のカスタマイズはサポートしないの?

というリアクションをおくったらヘルプメッセージは対応してくれました。カスタマイズに関しては、コード補完機能を実装したあとに検討するようです。

Swift ArgumentParser におけるプロパティの定義順の重要性

今回はタイトル通り ArgumentParser を使っている時のプロパティの定義順の重要性について書きます。v0.0.2 で実際に経験していた範囲ですが、まだ ArgumentParser のソースコードを読んだ内容ではないこと、今後のバージョンで変わりうることをご了承ください。

前回のエントリのつづき

import ArgumentParser

struct MyEcho: ParsableCommand {

    static var configuration = CommandConfiguration(commandName: "myEcho")

    struct Version: ParsableArguments {
        @Flag()
        var version: Bool

        func validate() throws {
            if version { throw CleanExit.message("1.0.0") }
        }
    }

    @OptionGroup()
    var version: Version

    @Argument()
    var text: String

    func run() throws {
        print(text)
    }
}

MyEcho.main()

上記サンプルコードは前回のエントリで紹介した、必須引数とバージョンフラグの両立をするためのコード例です。詳しい内容は前回のエントリを参照ください。

実はこの上記のコードでも前回解決した問題を再び発生させることが出来ます。

問題再燃サンプル

import ArgumentParser

struct MyEcho: ParsableCommand {

    static var configuration = CommandConfiguration(commandName: "myEcho")

    struct Version: ParsableArguments {
        @Flag()
        var version: Bool

        func validate() throws {
            if version { throw CleanExit.message("1.0.0") }
        }
    }

    @Argument()
    var text: String

    @OptionGroup()
    var version: Version

    func run() throws {
        print(text)
    }
}

MyEcho.main()

何が変わったかわかりますか?

text プロパティと version プロパティの定義順を変更して text プロパティを先に持ってきました。これだけで

% myEcho --version
Error: Missing expected argument '<text>'
Usage: myEcho <text> [--version]

というエラーになります。

結論(経験上)

プロパティの定義順に処理が進むので先に解決して欲しいものは先に書くべし。

もしくは @Argument() は必ず最後に書くべしとも言えます。

@OptionGroup() の Property Wrapper の init 時に ParsableArgumentsfunc validate() throws が実行されます。上からプロパティを埋めていくように ArgumentParser は動作しているようなので、引数を処理している最中に struct Version のイニシャライズの段階でバリデーション処理でこける(バージョン番号を出力して正常扱いで終了)ために、@Argument() var text: String に到達しない。これが、期待する動作を手に入れていたころのコードでした。

プロパティの定義順を text を先にすると、ArgumentParser が引数の処理をしているときに text プロパティに代入するべき値が存在しないため version プロパティの処理をする前に必須項目が欠如しているとしてエラーになります。

Help や Usage にも影響あり

% myEcho --version
Error: Missing expected argument '<text>'
Usage: myEcho <text> [--version]

このエラー時の Usage の出力をみると引数 text の後ろに version フラグが書かれています。

ためしに前回のエントリの完成形コードを引数もフラグもなしで実行すると

Error: Missing expected argument '<text>'
Usage: myEcho [--version] <text>

このように version のあとに text が来ています。

プロパティの定義順によって出力される説明の順番にも影響を与えています。

さらに僕がハマった罠

僕が実際に書いていたコードで、外から引数として与えられはしない固定値としてプロパティに保持したいものがありました。今手元で再現できないのですが、たしか Decodable に対応してないというコンパイルエラーが出ていました。ParsableCommandParsableArguments に準拠しています。さらに ParsableArgumentsDecodable に準拠しています。よって、 ParsableCommandDecodable である必要があるのです。ただし、別に Decodable である必要性がなかったプロパティだったので CodingKey を定義して除外できるようにしました。

するとさっきまで動いていたバージョン番号の出力が引数がないと怒られるようになりました。

ずっと謎だったのですが、最終的に分かったことは CodingKey に列挙している case がプロパティの定義順通りになっていなかったからでした。

このせいで結構な時間が溶けていました。

まとめ

プロパティの定義順は

  • パース処理の順番
  • Usage や Help の出力の順番
  • CodingKey を定義する場合はプロパティの定義順に揃える

ということでした。

常に順番を意識してコードを書きましょう!

Swift ArgumentParser でバージョン番号を出力する方法

コマンドラインツールを作っているとそのツールのバージョン番号を出力したい事があります。ここでは引数で渡した文字列をそのまま標準出力に出力する myEcho というコマンドを作るというのを例にしてみます。

完成イメージはこんな感じです。

% myEcho Hello
Hello
% myEcho --version
1.0.0

引数の文字をそのまま Print し、--version フラグが立っているときはバージョン番号を出力するだけのコマンドです。

コード例その1

import ArgumentParser

struct MyEcho: ParsableCommand {

    static var configuration = CommandConfiguration(commandName: "myEcho")

    @Flag()
    var version: Bool

    @Argument()
    var text: String

    func run() throws {
        if version {
            print("1.0.0")
            return
        }

        print(text)
    }
}

MyEcho.main()

素直に今回の仕様を ArgumentParser を使って実装すると上記のような感じで書けると思います。

ヘルプを出力してみるとこんな感じ

% myEcho --help
USAGE: myEcho [--version] <text>

ARGUMENTS:
  <text>

OPTIONS:
  --version
  -h, --help              Show help information.

いけてそうです。

引数を渡してみます。

% myEcho Hello
Hello

動いてますね。バージョン番号を出力させてみます。

% myEcho --version
Error: Missing expected argument '<text>'
Usage: myEcho [--version] <text>

エラーが出ました。必須引数がないってことで怒られています。とはいえよくあるコマンドラインツールはそのツールが引数を必須としていても --version フラグが有効な場合は必須として扱いません。

直します。

コード例その2

import ArgumentParser

struct MyEcho: ParsableCommand {

    static var configuration = CommandConfiguration(commandName: "myEcho")

    @Flag()
    var version: Bool

    @Argument()
    var text: String?

    func run() throws {
        if version {
            print("1.0.0")
            return
        }

        guard let text = text else {
            throw ValidationError("need argument")
        }

        print(text)
    }
}

MyEcho.main()

@Argument() にしている text プロパティをオプショナルにして、実行時に version フラグが立っていなければ text プロパティをチェックしてエラー処理したうえで出力します。

実際動かしてみると echo 部分も --version も期待通りに動きます。引数なしで実行すると以下のような感じに怒られます。

% myEcho
Error: need argument
Usage: myEcho [--version] [<text>]

補足すると ValidateionError は ArgumentParser 側で v0.0.1 のころから定義されているエラーです。このエラーを throw するとエラーコードを履きながらエラーメッセージを表示しつつ Usage を出力してくれます。便利ですね。

とはいえ個人的には

  • func run() throws 実行時には引数関連のエラー処理は終わった状態であってほしい
  • Unwrap を後続の処理の中であまりやりたくない

という思いもありモヤモヤします。

このモヤモヤを解決するために issue で良い方法がないか聞いてみた回答が次のコードです。

完成形

import ArgumentParser

struct MyEcho: ParsableCommand {

    static var configuration = CommandConfiguration(commandName: "myEcho")

    struct Version: ParsableArguments {
        @Flag()
        var version: Bool

        func validate() throws {
            if version { throw CleanExit.message("1.0.0") }
        }
    }

    @OptionGroup()
    var version: Version

    @Argument()
    var text: String

    func run() throws {
        print(text)
    }
}

MyEcho.main()

変更点は地味に多いですが以下の通り

  • version: Bool を Version 型 に変更
  • struct Version 内で
    • Bool フラグを持たせる
    • ParsableArguments プロトコルfunc validate() throws を実装
    • func validate() throws 内でフラグが立っていればバージョン番号を出力
      • 出力時には正常終了するために ArgumentParser が定義する CleanExit 型のエラーをスローする
  • text プロパティはオプショナルをやめる
  • func run() throws ではただ text プロパティを出力するのみ

これが現状のスッキリ終わらせるための Workaround です。

func validate() throws が run よりも先に実行されているので func run() throws 内では version のことは気にせずに済むようになりました。

コード量は増えましたが僕が元々感じていたモヤモヤはすべて解消されています。また、バージョン番号を出力する処理が Version 型にまとまっています。今回は MyEcho.Version とネストさせて定義していますが、ネストさせる必要性は特にないのでので単体のファイルとして定義していれば使い回しもしやすいです。

まとめ

この記事を読んで気付いた方がいるかも知れませんが、ArgumentParser ではバージョン番号出力はデフォルトで提供されていません。Issue 内でも提供して欲しいと他の方がリクエストしていました。将来的には提供されるようになるかも知れませんね。

Apple のオープンソースライブラリ ArgumentParser

Swift.org - Announcing ArgumentParser

2月末に Swift.org でアナウンスされた ArgumentParser をいろいろと触ってみたので紹介的なものを書いてみようと思います。

基本的にはわかりやすい英語とサンプルコードで書かれている 本家 Documentation が一次情報なのでそちらを読んでください。リポジトリには 動作するサンプルコード もあるのでそちらを動かすのもおすすめです。

この記事の執筆時点では ver 0.0.2 がリリースされていますが僕が試したのは v0.0.1 なのでご了承ください。

はじめに

3rd Party 製の Command Line Tool 向けの Argument Parser だったりフレームワークのようなものは以前からありました。Apple 自身も Swift Package Manager 内部で使われていた ArgumentParser (名前が同じでややこしい)は存在しており、apple/swift-tools-support-core (以下 TSC)として切り出されていました。今回アナウンスされたものは TSC の ArgumentParser とも別物の単体のオープンソースライブラリとしてリリースされました。

特徴としては Swift 5.1 で追加された Property Wrapper を使ってコマンドのパラメータとプロパティの対応が宣言的に書けるようなライブラリになっています。ある種のフレームワーク的なものとしても捉えられ、仕組みに沿ってコードを書いていけば簡単にコマンドラインツールが書けるようになっています。おかげで本質的な処理部分に時間をかけられるのはうれしいところです。

Apple が作っているいくつかのオープンソースプロダクトは TSC から ArgumentParser に乗り換える方向のようです。(indexstore-dbswift-format で PR が出されていてまだマージはされていない)

今回は僕がドキュメントを読んだり少し触った範囲で気になったことを書いていきます。

僕の背景として、公開はしていませんが5個ほど TSC を使ったコマンドラインツールを個人的に書いたことがあります。他の言語だと Go と Node.js で少しコマンドラインツールを使ったことがあります。

できること

  • よくあるパターンの引数のパース処理を Property Wrapper を使って簡単に書ける
    • フラグ
      • --verbose みたいなただのフラグ・スイッチ的なオプションの指定方法
    • オプション
      • swift build --configuration debug--configuration debug のように名前付きの引数みたいなオプションの指定方法
    • 引数
      • ls ~/ みたいに何もつかない引数
  • help もデフォで最低限は生成される
    • -h --help がデフォルトで実装される
  • エラーメッセージもデフォで最低限を返してくれる
    • 型チェックとか必須パラメータの不足など
  • ParsableCommand に準拠した型でコマンドを定義する
    • func run() throws メソッド内にコマンドの処理を書く
  • 実行自体は static メソッド main を呼ぶだけで、引数のパースや ParsableCommand の run をよしなに呼んでくれる
  • オプションのショートネームも定義できる
    • Swift で一般的な Lower camel case は自動的に Kebab case に変換されたオプション名になる
      • @Flag() var printSupportedFile: Bool--print-supported-file
    • @Flag(name: .shortAndLong) var printSupportedFile: Bool とすれば自動的に頭文字を使ったショートネームが追加されて -p, --print-supported-file が使えるようになる
    • @Flag(name: [.customShort("s") .customLong("support-file")]) var printSupportedFile: Bool とすれば自動生成じゃなく自分で任意のパラメータ名にできる。この場合は -s, --support-file が使えるようになる
  • long オプションは基本的に -- だけど withSingleDash: true すれば - 形式にできるけど推奨はされていない
  • 独自の型もパースできる(してもらえる)
    • ExpressibleByArgument に準拠して init?(argument:) を実装する
    • enum も RawRepresentable に対応してるような奴は ExpressibleByArgument に準拠してると宣言するだけで済む。
    • transform 関数を実装していればパースできたりもする
  • フラグの反転も指定できる
    • @Flag(inversion: .prefixedNo) var index: Bool だと --index--no-index が定義される
    • @Flag(inversion: .prefixedEnableDisable) だと --enable-hogehoge--disable-hogehoge
  • CaseIterable と String の RawRepresentable に対応した enum を使えばフラグをまとめたりとかもできる
  • Flag オプションを Int 型に適用すると呼び出された回数が入ってくる
    • -vvvv とかしたら verbose プロパティの値は 4 になってるみたいなことができる
  • 引数のパース方法は選べるけどほとんどの場合ややこしいことになる
    • @Option(parsing: . upToNextOption) var file: [String] のケースだけはちょっと便利かもしれない。毎回 --file hoge ってしなくても連続して --file のオプションを渡せるから。
  • サブコマンドの実装ができる
    • ツリー上にパースされていく
    • コマンド名は基本的に型名が使われるが以下のように明示もできる swift static var configuration = CommandConfiguration( commandName: "stdev", abstract: "Print the standard deviation of the values.")
    • サブコマンドのサブコマンドも作れる
    • 共通のオプション(設定)は ParsableArguments に準拠した Struct とかを用意して、@OptionGroup() var options: Math.Options と書くことでまとめられる
      • ちょっとオプションの多いコマンドを作ったときにはオプションのカテゴライズをしてまとめられてコードが見やすくなるし、排他処理も書きやすくなる
  • ヘルプのカスタマイズ
    • @Flagとかの help 引数に文字列を明示的にあたえることでコメントを追加できる
    • より細かいカスタムは上記で渡していた文字列の代わりに ArgumentHelpインスタンスを渡すことで可能
    • コマンドやサブコマンドのヘルプは static var configuration = CommandConfiguration( XXXX ) でできる
    • デフォルトの -h, --help のパラメータ名はカスタム可能。Root レベルのコマンドに対する CommandConfiguration で helpName: で指定可能
    • ヘルプに出力したくない場合には @Flag(help: .hidden) とかすれば、試験的に追加した機能とかをヘルプに出力せずにすませられる
  • バリデーション
    • ParsableCommandParsableArgumentsvalidate() を実装することでバリデーションできる
    • throw すればそのメッセージが標準エラーに出力されてエラーコードで終了する
    • runメソッド内でも実行時エラーを throw できる
      • v0.0.2 では ExitCode という Error が用意されたので自前でエラーメッセージを出力して throw ExitCode.failure することで綺麗にエラーからの終了が実装できるようになった
  • マニュアルでパース
    • きっちりとコマンドラインツールとして作るのではなくスクリプトっぽく書きたいとき
      • ParsableArguments に準拠した struct にこれまど同様にオプションを書いておく
      • static method の parseOrExit() を呼べば、成功してればインスタンスが返ってくるし失敗したら終了してくれる
      • parse メソッドもあってこちらは throw されるので自分でエラーハンドリングができる。
    • コマンドやサブコマンドもマニュアルでパースできるけどややめんどくさい parseAsRoot() とかつかえばいい

できないこと(提供されてないこと)

  • コマンドラインツール的に便利なパスの処理
  • 外部のコマンドの呼び出しのサポート

これらはライブラリの名前から連想する対応範囲を超えているように思えるので別のライブラリなどを使った方が良いかもしれないです。僕は個人的にパス周りは Path.swift, パスまわりや外部のコマンド呼び出しは前述の TSC を使ったりしています。

それ以外は特別困っていません。

まとめ

コマンドラインツールはいろんな宗派があると思うので好みが分かれると思います。細かいことを言えば切りがないかも知れません。とはいえ

  • Apple 純正
  • サブコマンドも含めてコマンドラインツール作成における大枠の仕組み、パース処理が提供されている
  • 独特な実装ではなく Swift らしい素直な実装

Swift でコマンドラインツールを書く場合、これらは利点だと思うのでぜひ皆さんも使って見たらいいと思います。