Corredor

ウェブ、プログラミングの勉強メモ。

Twitter に投稿された画像・動画をダウンロードする CLI ツール「twsv」を作った

Twitter に投稿された画像や動画をダウンロードする際、

としていた。

コレをもっと手軽に、複数のツイートからのダウンロードをバッチ処理できないかと思い、自分で同様のツールを作ってみることにした。その名も twsv。「TWitter SaVer」の略だ。

先に作ったツールの紹介

twsv は Node.js 製の CLI ツール。npm でインストールできる。パッケージ名としては @neos21/twsv

$ npm install -g @neos21/twsv

裏では Twitter API を使っているので、各自 Twitter の Developer 登録を行い、API キーを発行しておくこと。環境変数で以下のように設定しておく。

# Twitter クレデンシャル情報を設定する
export TWITTER_CONSUMER_KEY='Your Consumer Key'
export TWITTER_CONSUMER_SECRET='Your Consumer Secret'
export TWITTER_ACCESS_TOKEN_KEY='Your Access Token Key'
export TWITTER_ACCESS_TOKEN_SECRET='Your Access Token Secret'

グローバルインストールしたら twsv コマンドが使えるようになっているので、以下のように Twitter の URL を引数で指定してやる。

# 指定ユーザのタイムラインより直近200件のツイートを取得し、それらに紐付く画像・動画を取得する
$ twsv https://twitter.com/USERNAME

# 指定のユーザのいいね一覧より直近200件のツイートを取得し、それらに紐付く画像・動画を取得する
$ twsv https://twitter.com/USERNAME/likes

# 指定のツイートから画像・動画を取得する
$ twsv https://twitter.com/USERNAME/status/0000000000000000000

デフォルトの保存先は、コマンドを実行した時のカレントディレクトリに twsv-downloads/ ディレクトリを作り、その下にファイルを保存する。

保存先ディレクトリを変更する場合は、環境変数か第2引数で指定できる。両方指定されている場合は第2引数が優先される。

# 環境変数で指定して実行
$ export TWSV_SAVE_DIRECTORY='/home/downloads'
$ twsv https://twitter.com/USERNAME/status/0000000000000000000

# 第2引数で指定して実行
$ twsv https://twitter.com/USERNAME/status/0000000000000000000 '/home/downloads'

動画については、画質 (ビットレート) が一番良いモノを選んで取得している。

以降ツールを作るまでの苦労話

Syncer のツールはブラウザで操作し、「URL 貼り付け」→「動画 URL を選んで DL」というステップを踏んでいた。複数のツイートから動画を拾いたい場合は毎回この操作でダルかった。

そこで、ツールは CLI ツールにし、ツイートの URL をかき集めたらバッチ処理で一気に DL できるようにすることにした。

当初は Twitter API を使わないで済む方法がないかと思い、Web ページをスクレイピングして取得できないか調べてみた。

画像については、ページ内の img 要素を取得し、https://pbs.twimg.com/media/ を含む URL を引っ張ってくればダウンロードできた。

動画については、video 要素を調べてみたが、blob: から始まる URL になっていて、うまくダウンロードできなかった。Syncer では https://video.twimg.com/ext_tw_video/【色々…】.mp4 といった URL が拾えていたのだが、ウェブページ上からはこの URL は拾えなさそうだったので断念した。

仕方なく Twitter API を使ってツイートオブジェクトを取得し、画像や動画の URL を拾い上げることにした。

Twitter API を叩くのは、公式の twitter npm パッケージを使った。いいね一覧とユーザタイムラインを拾えそうだったので、ツイート単体の DL だけでなく、そういうツイート一覧から拾えるだけ画像や動画を拾えるようにも対応させてみた。

ツイートオブジェクトの中は愚直に見ていった。画像も動画も、ツイートオブジェクトの .extended_entries.media という配列プロパティの中に入っている。画像の場合は、配列の中の各要素の .media_url が目的の URL。

動画の場合は .video_info.variants プロパティが配列になっていて、ビットレートごとに目的の URL が入っている。そこで、ビットレートを比較して、ビットレートが一番大きい動画の URL を拾うことにした。

動画のダウンロードには request-promise を使ったが、同時接続数が多いと上手くダウンロードできなくなってしまったので、同時接続数を制限する仕組みを入れた。以下の記事のコードほぼそのまま。

保存先ディレクトリの存在確認、作成、保存処理などは fspath あたりの Node.js 組み込みのパッケージを使っている。

最終的に、外部依存パッケージは twitterrequestrequest-promise の3つで、コードは単一の JS ファイルに454行でまとまった。

以上

割と愚直にコーディングしまくっただけだが、自分が欲しいモノが作れた。Twitter API を使っているので、API コールのレート制限が確認できるとなお良しか。コンソール出力はもう少し改善の余地ありそうだが、特に気にせず。w

LINE, Instagram, Facebook, Twitter やりたいことが全部わかる本 この一冊で今すぐはじめられる

LINE, Instagram, Facebook, Twitter やりたいことが全部わかる本 この一冊で今すぐはじめられる

Twitter Perfect GuideBook 改訂版

Twitter Perfect GuideBook 改訂版

git pull 時に --set-upstream-to とか言われるのを回避するコマンドを作る

git pull した時に --set-upstream-to しろみたいなコメントが出て、git pull 出来ない時がある。

$ git pull

There is no tracking information for the current branch.
Please specify which branch you want to merge with.
See git-pull(1) for details

    git pull <remote> <branch>

If you wish to set tracking information for this branch you can do so with:

    git branch --set-upstream-to=origin/<branch> branch_name

恐らく以下の記事で触れた、git push 時の --set-upstream オプションを省くための作業を行ったことで、この問題が起こるようになったっぽい。

neos21.hatenablog.com

ローカルで新規ブランチを作り、初めて git push した直後に git pull すると、「どこのリモートブランチと紐付いてるのか分からん」と言われてしまうっぽい。

対処法としては、指示されているとおりの git branch --set-upstream-to コマンドを叩けば良いのだが、いちいちカレントブランチ名を入力しないといけないのが面倒臭い。

ということで、gplf (git pull force) というコマンドを作ってみた。git push --force があるんだから、git pull --force (とにかく Pull させろ) というコマンドがあってもいいじゃろ、ということで命名。w

#!/bin/bash

# git pull 時に --set-upstream-to しろというエラーが出た時に自動処理させる

# カレントブランチ名
current_branch_name= git rev-parse --abbrev-ref @

# リモートブランチを指定して git pull する
git branch --set-upstream-to="origin/${current_branch_name}" "${current_branch_name}"
git pull

このスクリプトを gplf という名前で保存し、PATh が通っているところに配置、$ chmod +x ./gplf と実行権限を付与すれば準備 OK。

僕は git pullgpl とエイリアス登録しているので、gpl と入力して前述のエラーが出たら、gplf と打ち直してこのコマンドを実行させる、というワケ。

コレでもう --set-upstream-to エラーに対して手間をかけなくて済むぞい。

わかばちゃんと学ぶ Git使い方入門〈GitHub、Bitbucket、SourceTree〉

わかばちゃんと学ぶ Git使い方入門〈GitHub、Bitbucket、SourceTree〉

負荷試験のために Locust を使ってみる

以前、負荷テストに JMeter を使ったことがあった。GUI で設定・監視でき、使用感自体はそこまで悪くなかった。

neos21.hatenablog.com

今回、また負荷テストをやることになり、コマンドラインでサクッと設定できるようなモノはないのかなーと思って調べてみたところ、Locust というツールを見つけたので紹介する。

インストール

Locust は Python 製のツールなので、pip を使ってインストールする。MacOS と CentOS で試してみたのでそれぞれやり方を書いておく。

MacOS Mojave の場合

MacOS の場合は、先に Homebrew を使って python3 と、Locust が使う libev という依存ツールをインストールしておく必要がある。

$ brew install python3 libenv

Python3 の PATH 設定とかは説明を省略。以下のコマンドで Locust をインストールする。

$ python3 -m pip install locustio

インストールできたら、以下のように locust コマンドが使えるようになっているか確認しよう。

$ locust --version
[2019-07-03 14:36:58,742] mac-mbp.local/INFO/stdout: Locust 0.11.0

Linux CentOS 7 の場合

CentOS の場合は Yum で Python および pip を用意する。

$ sudo yum install python-pip
$ sudo pip install pip --upgrade

そしたら Locust をインストール。

$ sudo pip install locustio

…すると、requests というモジュール関連のエラーが出てしまった。調べたら以下のように --ignore-installed オプションで回避できるみたいなのでやり直す。

$ sudo pip install locustio --ignore-installed requests

コレで $ locust コマンドが使えるようになった。

タスクを定義する

Locust が使えるようになったら、実行したいタスクを定義する。「どこにアクセスして何をする」といった処理を「タスク」として定義するのだが、Python で実装する。

ファイル名は locustfile.py という名前がデフォルトだ。試しに以下のように実装する。

# -*- coding: utf-8 -*-

# Python2 の場合は以下2行をアンコメントして入れておく
# from __future__ import absolute_import
# from __future__ import unicode_literals

from locust import HttpLocust, TaskSet, task

# クラス名は任意の名前
class UserTaskSet(TaskSet):
  # 数字は実行比率を表す。例えば @task(2) は @task(1) の倍の数だけ実行される
  @task(1)
  def index(self):
    # トップページにアクセスするだけ
    self.client.get("/")

# コレもクラス名は任意
class WebsiteUser(HttpLocust):
  task_set = UserTaskSet
  # タスク実行の最短待ち時間 (ミリ秒)
  min_wait = 100
  # タスク実行の最大待ち時間 (ミリ秒)
  max_wait = 1000
  # それぞれのタスクの実行間隔は min_wait と max_wait の間のランダム値になる

こんな感じ。

接続するホスト名はまだ指定していないが、どこかのホストのルート / に GET リクエストを投げるだけの簡単なスクリプトだ。

テスト対象のサーバを用意する

テスト対象のサーバを用意しておこう。後で URL 指定するので、既にどこぞに立ててあるサイトを IP アドレスやドメインで指定しても良いし、localhost でも良い。

今回は http://localhost:3000/ でアクセスできるサーバを立ててあるテイとする。

Locust を起動する

locustfile.py ファイルを用意したら、そのファイルがあるディレクトリで以下のようにコマンドを実行する。

# 末尾にスラッシュを付けないこと (付けるとスラッシュが2つ付いてしまう)
$ locust -H http://localhost:3000

[2019-07-22 13:34:10,170] NeosMacBook.local/INFO/locust.main: Starting web monitor at *:8089
[2019-07-22 13:34:10,171] NeosMacBook.local/INFO/locust.main: Starting Locust 0.11.0

http://localhost:3000 にリクエストを投げるテストを始めるよー、ということで -H (--host) オプションを指定している。

コマンドを実行すると、ターミナルは起動したままになる。テストの開始や設定は、Locust が起動した簡易サーバで行う。デフォルトでは

  • http://localhost:8089/

にブラウザでアクセスすると、Locust の画面が登場する。

f:id:neos21:20190806164642p:plain

テキストボックスが2つ表示されるが、意味は以下のとおり。

  • Number of users to simulate : 最大でいくつの同時アクセス数にするか (コレを「ユーザ数」と表現)
  • Hatch rate (users spawned/second) : 1秒間で何ユーザ増やしていくか (上のユーザ数に達するまでの期間となる)

今回は、最大で同時に10アクセスさせるようにし、1秒に1ユーザずつ増やしていこうと思うので、上の欄に 10・下の欄に 1 と入力し、「Start swarming」ボタンを押すと、テストが始まる。

Locust の Web UI はとても見やすいので、特に迷うこともないと思う。累計リクエスト数、エラーレスポンスの数、レスポンスまでの時間などの情報が表示される。気が済んだら右上の方にある「Stop」ボタンを押せば処理が止まる。

停止後、「New test」ボタンを押下すると再度先程のテキストボックスが表示されるので、今度はさらにリクエスト数を多くしたりして、テストが再開できる。

テストを終えたくなったら、起動しっぱなしのターミナルに戻って Ctrl + C を押下すれば良い。

CUI のみでテストを行う

MacOS でテストする場合は、このようにターミナルとブラウザを行き来すればテストできるが、CUI のみの CentOS などでテストする場合は、GUI ブラウザが開けない。そこで、locust コマンドのオプション引数を使ってテストを設定してやる。

$ locust -H http://localhost:3000 --no-web -c 10 -r 1
  • --no-web : CUI モードにする場合は必須
  • -c (--clients) : 「Number of users to simulate」に同じ
  • -r (--hatch-rate) : 「Hatch rate」に同じ

このように、テキストボックスに入力していた情報を -c-r オプションで渡してやると、その場でテストが開始する。中止する場合は Ctrl + C で、最後にまとまったレポートがコンソール出力される。

大量リクエストで HTTPConnectionPool 関連のエラーが出る場合は…

少ないユーザ数でテストしている場合は正常なのに、ユーザ数を多くすると HTTPConnectionPool 関連のエラーで Fail となる場合は、Locust を実行する環境の「ファイルディスクリプタ」の上限に達していることが原因。

  • 例えばこんなエラーが出る時。
ConnectionError(MaxRetryError("HTTPConnectionPool(host='localhost', port=3000): Max retries exceeded with url: / (Caused by NewConnectionError('<urllib3.connection.HTTPConnection object at 0x....>: Failed to establish a new connection: [Errno 8] nodename nor servname provided, or not known'))"))

ConnectionError(MaxRetryError("HTTPConnectionPool(host='localhost', port=3000): Max retries exceeded with url: / (Caused by NewConnectionError('<urllib3.connection.HTTPConnection object at 0x....>: Failed to establish a new connection: [Errno 24] Too many open files'))"))

ConnectionError(ProtocolError('Connection aborted.', ConnectionResetError(54, 'Connection reset by peer')))

本来はどれだけファイルが開けるか、という上限設定なのだが、ソケット通信でも使われるので、この項目が影響している。

MacOS の場合、上限値は以下のように確認できる。

$ ulimit -a
core file size          (blocks, -c) 0
data seg size           (kbytes, -d) unlimited
file size               (blocks, -f) unlimited
max locked memory       (kbytes, -l) unlimited
max memory size         (kbytes, -m) unlimited
open files                      (-n) 256        # ← ココ
pipe size            (512 bytes, -p) 1
stack size              (kbytes, -s) 8192
cpu time               (seconds, -t) unlimited
max user processes              (-u) 1418
virtual memory          (kbytes, -v) unlimited

現在の上限値は 256 なので、以下のように増やしてやろう。

$ ulimit -S -n 2048

再度 $ ulimit -a で確認すれば、open files の値が上昇していることが分かるだろう。

この効果はそのターミナル (セッション) のみなので、Locust を実行するターミナルタブで設定してやること。

POST 送信する時は

先程の locustfile.py の例は、単純な GET 通信だったが、POST リクエストを投げたい場合はどうするかというと、こうする。

class UserTaskSet(TaskSet):
  @task(1)
  def index(self):
    params = {
      "data": [
        { "userId": "u001", "name": "Jane Doe" },
        { "userId": "u002", "name": "John Smith" }
      ]
    }
    res = self.client.post("/register", json=params)

こんな風に POST 送信する JSON を組み立てて、self.client.get() ではなく self.client.post() で投げれば良い。

以上

Locust の使い方を簡単ではあったが紹介した。

BASIC 認証を通り抜けて次の画面である操作をして…というような複雑なタスクも定義できるので、使い方次第でかなり細かな負荷テストができるだろう。

[rakuten:booxstore:12025967:detail]

Amazon Web Services負荷試験入門―クラウドの性能の引き出し方がわかる (Software Design plusシリーズ)

Amazon Web Services負荷試験入門―クラウドの性能の引き出し方がわかる (Software Design plusシリーズ)

初めての自動テスト ―Webシステムのための自動テスト基礎

初めての自動テスト ―Webシステムのための自動テスト基礎

iOS 13 AVCaptureMultiCamSession を使った複数カメラでの同時ビデオ録画 iPhone アプリを作った

iOS 13 から登場した AVCaptureMultiCamSession という API を使うと、1台の iPhone に搭載されている複数のカメラデバイスを同時に使用できる。例えば、バックの標準カメラで被写体を写しながら、フロントカメラで撮影者自身も同時に撮影したり、といった感じだ。

今回はこの AVCaptureMultiCamSession を使って、複数のカメラで同時に動画を撮影する iOS アプリを作ったので紹介する。

アプリの様子

アプリが動作しているスクリーンショットは以下。

f:id:neos21:20190927205442p:plain

左上が超広角カメラ、右上が標準カメラ、右下がフロントのインカメラを表示している。下部の「Start」ボタンを押下すると、プレビューが表示されているカメラのそれぞれで動画の録画が開始される。

f:id:neos21:20190927205420p:plain

コチラは標準カメラ (右上) の代わりに、望遠カメラ (左下) を起動したパターン。後述するが、4カメラを同時に録画することはできなかったので、このような挙動になっている。

動画を撮影し始めると「Start」ボタンが「Stop」ボタンになり、「Stop」ボタンを押すと、動画ファイルが「写真」アプリ (カメラロール) に書き出される仕組み。例えば3カメラで同時に撮影していれば、3つの動画ファイルがカメラロールに追加される。

コードは GitHub で公開中

コードの全量は GitHub にアップしてあるので、各自で導入して試してみてほしい。

github.com

動作検証環境

  • 検証端末 : iPhone 11 Pro Max
  • 検証 OS : iOS 13.0・iOS 13.1
  • 現状同時に撮影できる最大デバイス数:3台
  • 撮影される動画ファイルの仕様 : 1920x1080px・29.58fps (バックカメラ・フロントカメラともに同じ)
  • 開発環境:Xcode 11.0 (11A420a)

検証に使ったのは、2019-09-21 に購入した iPhone 11 Pro Max。

neos21.hateblo.jp

プリインストールの iOS 13.0 と、2019-09-25 に配信された iOS 13.1 とで動作確認した。OS のマイナーバージョンアップによる挙動の違いはなかった。

動画を撮影できるアプリとして開発したが、撮影できている動画ファイルはいずれも、フル HD サイズ (1920x1080px) で 29.58fps (= 30fps) だった。コレはフレームレートや画質設定を一切せずに撮影できているスペックなので、設定したらもしかしたら 60fps 撮影とかもできるのかもしれない。

4カメ同時撮影はできなかった

ご存知のとおり、iPhone 11 Pro Max には

  1. バック・超広角レンズ
  2. バック・標準 (広角) レンズ
  3. バック・望遠レンズ
  4. フロントレンズ

の、合計4つのレンズが付いている。

iPhone 11 の製品発表イベントでデモンストレーションされていた、「FiLMic Pro」アプリによる4カメラ同時撮影を再現してみたくてアプリを作ったのだが、今回試した限りでは実現できなかった。

なぜ4つ同時がダメだったかというと、4つ目のカメラを使おうとするとエラーが出てしまうためで、内部的な詳細を追っていくと、どうも現段階では AVCaptureMultiCamSession に4つの AVCaptureDeviceInput を追加することはできないようだ。

AVCaptureMultiCamSession の仕組み

そもそも AVCaptureMultiCamSession に関する文献が今の段階ではまるでなく、探り探りの中で実装した。見つかった資料は以下ぐらい。

AVCaptureMultiCamSession の使い方は以下のような流れになる。

  1. AVCaptureMultiCamSession を生成する
  2. 目的のカメラレンズ = AVCaptureDevice を探す
  3. そのレンズからの映像を取得するための AVCaptureDeviceInput を取得する
  4. 取得した Input を AVCaptureMultiCamSession に追加する (addInputWithNoConnections())
  5. その映像の出力先となる AVCaptureOutput (のサブクラスである AVCaptureMovieFileOutput など) を用意する
  6. 用意した Output を AVCaptureMultiCamSession に追加する (addOutputWithNoConnections())
  7. AVCaptureDeviceInput から Port を取得し、AVCaptureOutput との紐付け = AVCaptureConnection を作る
  8. 作った Connection を AVCaptureMultiCamSession に追加する (addConnection())
  9. AVCaptureMultiCamSession を開始 (startRunning()) し、プレビュー表示を開始。録画開始に備える

準備するモノが多く、ローレベルな実装をコツコツしないといけないのだ。

今回は「各カメラのプレビュー表示があって、音声付きの動画をそれぞれ書き出す」というアプリとして作ったので、以下のようなリソースを用意する必要があった。

f:id:neos21:20190927205505p:plain

Input と Output は AVCaptureMultiCamSession に追加しておき、Input と Output の紐付けは AVCaptureConnection という定義体を作ることで実現できる。上の図の矢印は一方通行になっているが、実装的には前述のとおり、Input と Output を用意してから AVCaptureConnection を作ることになる。

4つのカメラを AVCaptureMultiCamSession に追加するとどうなるか

AVCaptureDeviceInputAVCaptureMultiCamSession に追加する際は、以下のようなコードを書くことになる。コレを、使用したいカメラの台数分だけ、異なる AVCaptureDeviceInput を指定して実行することになる。

self.avCaptureMultiCamSession.addInputWithNoConnections(avCaptureDeviceInput)

例えば、超広角カメラを追加 → 標準カメラを追加 → 望遠カメラを追加、というところまでは正常に処理が進んだ後、4つ目のフロントカメラを追加しようとして上のようなコードを実行すると、そこでエラーが発生し、以下のようなエラーメッセージがコンソールに出力される。

*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*** -[AVCaptureMultiCamSession addInputWithNoConnections:] These devices may not be used simultaneously. Use -[AVCaptureDeviceDiscoverySession supportedMultiCamDeviceSets]'

NSInvalidArgumentException、何やら「引数がダメ」という扱いになっている。

それではと、実装の順番を変えて、フロントカメラを追加 → 超広角カメラを追加 → 標準カメラを追加、という順にすると、この3つまでは正しく追加できるが、その次に望遠カメラを追加しようとすると、同様のエラーが発生する。つまり、「フロントカメラを追加」する処理のコード自体には引数誤りはないということなのだ。

もう少し読んでみると、These devices may not be used simultaneously. とある。これらのデバイスは同時に使えないよ、と明言されてしまった。

同時に使えるカメラの組合せを調べる:supportedMultiCamDeviceSets

じゃあどういう組合せなら同時に使えるの?というと、Use -[AVCaptureDeviceDiscoverySession supportedMultiCamDeviceSets] と書いてある。同時に使える組合せを知るための supportedMultiCamDeviceSets というプロパティがあるようだ。

ということで以下のように実装して、実際に supportedMultiCamDeviceSets の中身を見てみた。

let discoverySession = AVCaptureDevice.DiscoverySession.init(deviceTypes: [
  // カメラ単体のデバイス種別
  AVCaptureDevice.DeviceType.builtInWideAngleCamera,
  AVCaptureDevice.DeviceType.builtInUltraWideCamera,
  AVCaptureDevice.DeviceType.builtInTelephotoCamera,
  // 以下は複数カメラを切り替えながら使うデバイス種別
  AVCaptureDevice.DeviceType.builtInDualCamera,
  AVCaptureDevice.DeviceType.builtInDualWideCamera,
  AVCaptureDevice.DeviceType.builtInTripleCamera,
  // コレは TrueDepth カメラの定義
  AVCaptureDevice.DeviceType.builtInTrueDepthCamera
], mediaType: AVMediaType.video, position: AVCaptureDevice.Position.unspecified)

let deviceSets = discoverySession.supportedMultiCamDeviceSets

print("\(deviceSets)")

deviceTypes に指定するのは、最初の3つの DeviceType だけで良い。それ以降の DeviceType は、全量を調べるためにとりあえず書いてみたモノだが、結果に大した違いはなかった。

print() した結果は以下のとおり。

[
  Set([
    [Back Dual Camera][com.apple.avfoundation.avcapturedevice.built-in_video:3]
  ]),
  Set([
    [Back Dual Wide Camera][com.apple.avfoundation.avcapturedevice.built-in_video:6]
  ]),
  Set([
    [Back Triple Camera][com.apple.avfoundation.avcapturedevice.built-in_video:7]
  ]),
  Set([
    [Back Camera][com.apple.avfoundation.avcapturedevice.built-in_video:0],
    [Front Camera][com.apple.avfoundation.avcapturedevice.built-in_video:1]
  ]),
  Set([
    [Back Camera][com.apple.avfoundation.avcapturedevice.built-in_video:0],
    [Back Ultra Wide Camera][com.apple.avfoundation.avcapturedevice.built-in_video:5]
  ]),
  Set([
    [Back Camera][com.apple.avfoundation.avcapturedevice.built-in_video:0],
    [Back Telephoto Camera][com.apple.avfoundation.avcapturedevice.built-in_video:2]
  ]),
  Set([
    [Front TrueDepth Camera][com.apple.avfoundation.avcapturedevice.built-in_video:4],
    [Back Camera][com.apple.avfoundation.avcapturedevice.built-in_video:0]
  ]),
  Set([
    [Front Camera][com.apple.avfoundation.avcapturedevice.built-in_video:1],
    [Back Ultra Wide Camera][com.apple.avfoundation.avcapturedevice.built-in_video:5]
  ]),
  Set([
    [Front Camera][com.apple.avfoundation.avcapturedevice.built-in_video:1],
    [Back Telephoto Camera][com.apple.avfoundation.avcapturedevice.built-in_video:2]
  ]),
  Set([
    [Front Camera][com.apple.avfoundation.avcapturedevice.built-in_video:1],
    [Back Dual Camera][com.apple.avfoundation.avcapturedevice.built-in_video:3]
  ]),
  Set([
    [Back Dual Wide Camera][com.apple.avfoundation.avcapturedevice.built-in_video:6],
    [Front Camera][com.apple.avfoundation.avcapturedevice.built-in_video:1]
  ]),
  Set([
    [Back Ultra Wide Camera][com.apple.avfoundation.avcapturedevice.built-in_video:5],
    [Back Telephoto Camera][com.apple.avfoundation.avcapturedevice.built-in_video:2]
  ]),
  Set([
    [Front TrueDepth Camera][com.apple.avfoundation.avcapturedevice.built-in_video:4],
    [Back Ultra Wide Camera][com.apple.avfoundation.avcapturedevice.built-in_video:5]
  ]),
  Set([
    [Front TrueDepth Camera][com.apple.avfoundation.avcapturedevice.built-in_video:4],
    [Back Telephoto Camera][com.apple.avfoundation.avcapturedevice.built-in_video:2]
  ]),
  Set([
    [Front TrueDepth Camera][com.apple.avfoundation.avcapturedevice.built-in_video:4],
    [Back Dual Camera][com.apple.avfoundation.avcapturedevice.built-in_video:3]
  ]),
  Set([
    [Back Dual Wide Camera][com.apple.avfoundation.avcapturedevice.built-in_video:6],
    [Front TrueDepth Camera][com.apple.avfoundation.avcapturedevice.built-in_video:4]
  ]),
  Set([
    [Back Ultra Wide Camera][com.apple.avfoundation.avcapturedevice.built-in_video:5],
    [Back Camera][com.apple.avfoundation.avcapturedevice.built-in_video:0],
    [Front Camera][com.apple.avfoundation.avcapturedevice.built-in_video:1]
  ]),
  Set([
    [Back Telephoto Camera][com.apple.avfoundation.avcapturedevice.built-in_video:2],
    [Back Camera][com.apple.avfoundation.avcapturedevice.built-in_video:0],
    [Front Camera][com.apple.avfoundation.avcapturedevice.built-in_video:1]
  ]),
  Set([
    [Back Telephoto Camera][com.apple.avfoundation.avcapturedevice.built-in_video:2],
    [Back Camera][com.apple.avfoundation.avcapturedevice.built-in_video:0],
    [Back Ultra Wide Camera][com.apple.avfoundation.avcapturedevice.built-in_video:5]
  ]),
  Set([
    [Front TrueDepth Camera][com.apple.avfoundation.avcapturedevice.built-in_video:4],
    [Back Camera][com.apple.avfoundation.avcapturedevice.built-in_video:0],
    [Back Ultra Wide Camera][com.apple.avfoundation.avcapturedevice.built-in_video:5]
  ]),
  Set([
    [Front TrueDepth Camera][com.apple.avfoundation.avcapturedevice.built-in_video:4],
    [Back Camera][com.apple.avfoundation.avcapturedevice.built-in_video:0],
    [Back Telephoto Camera][com.apple.avfoundation.avcapturedevice.built-in_video:2]
  ]),
  Set([
    [Back Telephoto Camera][com.apple.avfoundation.avcapturedevice.built-in_video:2],
    [Front Camera][com.apple.avfoundation.avcapturedevice.built-in_video:1],
    [Back Ultra Wide Camera][com.apple.avfoundation.avcapturedevice.built-in_video:5]
  ]),
  Set([
    [Front TrueDepth Camera][com.apple.avfoundation.avcapturedevice.built-in_video:4],
    [Back Ultra Wide Camera][com.apple.avfoundation.avcapturedevice.built-in_video:5],
    [Back Telephoto Camera][com.apple.avfoundation.avcapturedevice.built-in_video:2]
  ])
]

コレを見ると分かるとおり、利用可能なデバイスの組合せは、最大で3カメラ分までの定義しか見つからなかった。

AVCaptureMultiCamSession#addInputWithNoConnections() を実行すると、裏では引数で指定された AVCaptureDeviceInput の組合せを、この supportedMultiCamDeviceSets と突合して確認していて、それに合わない組合せだとエラーになるのだろう。supportedMultiCamDeviceSets には4台の組合せが存在しないので、エラーになったというワケだ。

この調査記録は以下の Gist にも記載した。

ということで、 本稿執筆時点では、最大3つまでのカメラレンズを使っての同時撮影なら可能、という結果になった。

アプリの実装上は、4つのカメラレンズが使えるようになった時も動作するようにしてあるので、今後のアップデートとかで4カメ同時撮影ができるようになったりしたら、この実装のまま対応できるかと思う。

バックグラウンドに移行した時のエラーは何?

もう一つ気になったことがあった。ホーム画面に戻ったりしてアプリがバックグラウンドに移ると、Xcode のデバッグコンソールに以下のエラーが出力されていた。

Can't end BackgroundTask: no background task exists with identifier 1 (0x1), or it may have already been ended. Break in UIApplicationEndBackgroundTaskError() to debug.

動画録画中かどうかには関係なく表示されていた。

以下の記事を見ると、デバッグの仕方が書いてあったのでデバッグしてみたが、詳しいことは分からず。

In Xcode, switch to the breakpoint navigator (View > Navigators > Show Breakpoint Navigator) then push the + button in the bottom left and select Add Symbolic Breakpoint and enter “UIApplicationEndBackgroundTaskError” as the symbol.

でも、この記事のおかげでブレイクポイントを打ってデバッグするやり方を覚えたので、そのやり方だけメモしとく。

Xcode の左ペインの上側から、「Breakpoint Navigator」カラムを表示するアイコンを押下する。メニューバーの「View」→「Navigators」→「Show Breakpoint Navigator」と移動しても良い。

そしたらその左ペインの左下にある「+」ボタンを押下し、メニューから「Symbolic Breakpoint...」を選択する。

f:id:neos21:20190927212108p:plain

表示された枠の「Symbol」欄に「UIApplicationEndBackgroundTaskError」と入れれば OK。

f:id:neos21:20190927212115p:plain

あとはこの状態でアプリを起動し、ホーム画面に戻るなどすると、ブレイクポイントで処理が止まり、デバッグができるようになる。

このエラーは、AVCaptureMultiCamSession に関する唯一の公式サンプルコードである、以下のプロジェクトでも同様に発生していた。

もしかしたら気にしなくて良いモノなのかもしれないが、原因も対処法も分からないままだ。

探り探り実装したのでまだまだバギー

自分の iOS アプリの開発スキルは素人レベル。過去にスローモーション動画を撮影するための実装を紹介したことがあるが、コレをベースにした個人用のアプリを作ったぐらいしか経験がない。

neos21.hatenablog.com

素の AVCaptureSesion すら仕組みを理解していなくて怪しかったので、最初は以下あたりの記事のコードを参考に、単体カメラを操るところから復習した。

Action や Outlet の接続方法も忘れていたので覚え直し。

neos21.hatenablog.com

フロントカメラを使うのは初めてだったので、プレビューを鏡写しにするところを覚えた。

そういえば、Xcode プロジェクトを新規作成したところ、SceneDelegate.swift なる知らないファイルがあった。コレは複数の UI のインスタンスを作るためのモノで、iOS13 からの新機能によるファイルらしい。今回は必要なかったのでファイルごと消し、Info.plistApplication Scene Manifest キーを消した。

プレビューを作る時、AVCaptureVideoPreviewLayer(self.session) と、第1引数に session を入れてやったら以下のエラーが出た。

<AVCaptureConnection: 0x2810a64e0> cannot be added because AVCaptureVideoPreviewLayer only accepts one connection of this media type at a time, and it is already connected'

宣言時に session を指定せず、AVCaptureVideoPreviewLayer() でインスタンスを作り、previewLayer.setSessionWithNoConnection(self.session) と書いて接続したら回避できた。この辺よー分からん。

公開したコードでは回避できてはいるが、AVCaptureMultiCamSession に追加した Input や Output などを除去する、removeInput() などを実装していたところ、その後に再度動画録画を始めようとすると以下のようなエラーが出た。

*** Terminating app due to uncaught exception 'NSRangeException', reason: 'Cannot remove an observer <AVCaptureMultiCamSession 0x28084e7a0> for the key path "enabled" from <AVCaptureConnection 0x2808066c0> because it is not registered as an observer.'

Observer よく分からん。公式サンプルコードの AVMultiCamPiP の中では、色々 Observer を操作したり、色んなタイミングでのエラーハンドリングを細かくやっていたりするのは分かるのだが、それがどうしてどういう理由で必要なのかが読み解けなかった。結局、今の実装は AVCaptureMultiCamSession ごと作り直すことで対処している。

Swift の言語仕様でいうと、以下あたりを知った。

  • defer って何やねん → その関数内で、例外が発生したりしても最後に必ずやりたい処理を書いておく。finally 的なモノ
  • if let hoge = self.hoge って何やねん → 代入することで、Optional な値である self.hogenil でないことを確認できるイディオムらしい

動画を録画し始めたあと、アプリがバックグラウンドに移行した時に、動画の撮影を終了して、フォトライブラリに書き出したいなぁと思って色々コネコネしたんだけど、挙動がバギーだ。

もう少しド直球に、AVCaptureMultiCamSession のコードを GitHub で探してみたのだが、以下の4つしか見つけられなかった。

終わり

Swift ちからがまだまだ全然足りなくて、

  • バックグラウンド移行を始めとした様々なイベントに対するエラーハンドリング
  • iPhone 11 Pro Max 以外の端末で動かした場合のレイアウト、動作 (他の端末での動作検証はできてないし、端末ごとのレイアウト調整もしてない)
  • カメラやフォトライブラリへのアクセス許可が拒否されている場合の動作
  • 公式サンプルコードでは何やら処理している、負荷量を監視してフレームレートや解像度を下げたりする安全な作り (逆に、頑張れば複数カメラで 4K とか、スローとかが撮れたりする?)

などなど、至らぬ点は重々認識しているが、どうやって解決していったら良いか分かっていないところも多い。コメントやプルリクで色々教えてもらえたら嬉しい。4カメ同時に使うテクニックが編み出されたりしたら取り入れたい。

有料の FiLMic Pro がリリースされるよりも先に、同等のアプリを無料で公開できたらいいなーと思い、急ぎ作ってコードごと公開した次第。拙いコードだし、AppStore で公開してはいないが、ひとしきり動作するアプリとしては世界初の事例になったのではないか?と思っている。

ぜひ感想、改善点などお寄せください。

github.com

[rakuten:book:19748079:detail]