豪鬼メモ

一瞬千撃

スライドショー機能

鋭意製作中の写真アルバム閲覧Webサービスにスライドショー機能をつけた。一口にスライドショーと言っても、なかなか深い話題なので、メモがてらに経緯をまとめておく。
f:id:fridaynight:20181015224854p:plain

デモ

まずは、こちらのデモサイトにアクセスされたい。前回述べた通り、パスワードがかかっているサイトなのだが、password=abc などとしてパスワードをURLに含めるとログイン画面を省略できる(もちろん通常はそんなことをしてはいけない)。さらに、URLフラグメントとして #slides をつけると、接続後に即座にスライドショーが起動される。
f:id:fridaynight:20181015224624p:plain

例によって、キーボードの左右の矢印キーで前後の写真に移動することができる。上下の矢印キーで最初や最後の写真に飛ぶこともできる。モバイル端末で操作する場合、左右移動はスワイプで行うこともできる。画面下方の左端には半透明だが戻るボタンがあり、同じく右端には進むボタンもあるので、それらを押してもページめくりができる。スライドショーを終えたい場合には、画面右上のバツボタンを押すか、キーボードの「Esc」または「q」を押せばよい。個々の写真は画面内に全体が収まるように縮小表示されたり、画面よりやたら小さい場合には拡大表示されたりするが、元のサイズで見たい場合には画像をクリックするか、キーボードの「x」を押せば良い。表示用画像でなくて元画像を見たい場合には「z」を押せば良い。動画の場合に「z」を押すと再生される。
f:id:fridaynight:20181015225810p:plain

個々の写真をめくっていく処理はJavaScriptのみで実装されていて、サーバーへの通信は最初に画像データのURL一覧を取得するための1回のみなので、遅延がほとんど体感できないはずだ。画像データの取得は非同期に行われるので、スライドショーを起動して最初の写真が表示されるまでの遅延も小さい。私の環境では、Lightroomのライブラリビューで写真をめくっていく処理よりも早い。十分にネットワークが早ければ、1秒で5枚くらいめくっても遅延を感じないくらいだ。100枚を30秒で見直すくらいなら余裕。

スライドショーは前回述べた写真の削除機能とも連携している。キーボードのスペースバーを押すか、画面左上にあるチェックマークの付近を押すと、その写真が選択される。写真をめくりながら削除対象を選択した上で、画面上に出てくるActionsタブから「Remove Resources」を選択すると、それらの写真が一括で削除される。画面下方に出てくるサムネイルの一覧に、どの写真が選択されているかが反映されるのも便利だ。「s」ボタンを押すか下にスワイプするとサムネイルを隠せる。
f:id:fridaynight:20181015225516p:plain

上記のデモ画面は、最新の写真100枚を対象としたスライドショーを表示するものだった。スライドショーの対象となる写真は通常の閲覧画面から選択することもできる。写真を右クリック(スマホの場合は長押し)で選択していって対象に加えることができるし、日毎のコレクション(写真以外の部分)を右クリック(スマホの場合は長押し)で選択すると、その日の写真全てが対象として追加される。それから、画面右上のメニューから、「Slide Show」を選択すると、スライドショーが始まる。コレクションの右肩の「左右矢印」ボタンを押すと、そのコレクション内の写真を対象としたスライドショーが一発で表示される。現在のビューで表示されている写真全てを対象としたスライドショーが見たい場合には、トップのコレクションの「左右矢印」ボタンを押すか、何も選択しないでキーボードの「s」を押せばよい。何か選択した状態で「s」を押すと、選択したものを対象としたスライドショーが始まる。
f:id:fridaynight:20181015225354p:plain

以上の機能をまとめると、最近の写真の閲覧及び管理する最も効率的な方法は次のようなものだ。まず、ブックマークからlist_imageのURLを選択して自動ログインする。そうすると最近の写真が日付を問わずに100枚表示され、おそらく該当の日付をすぐ特定できるであろうから、その日付のコレクションに飛ぶ。そして「s」でスライドショーを起動し、一通りの写真をパラパラめくって閲覧する。その際に駄作で捨てちゃってもいいかなと思ったものがあればスペースバーで選択しておき、最後に「Remove Resources」で一括削除する。「q」で元の画面に戻り、矢印キーで次の日または前の日に移動して同じことをする。ちょっと効率は落ちるがモバイル上でも同じことができるので、通勤電車の中とかでやってもいい。閲覧と管理がシームレスに統合されているってところがポイントだ。

私が気に入っているのは、以前にも述べたスニペット機能とスライドショーの組み合わせだ。一覧画面でメニューから「Collection Snippets」を選択すると、各日付のコレクションから最大3枚の写真が表示される。「e」を押してもいい。各日付で最大3枚だから、毎日3枚以上撮っていたとしても、過去100日くらいの写真が表示されることになる。その状態でスライドショーを開始すると、過去100日の写真を一気に振り返ることができる。パラパラめくっていれば1分で見られる。100日もあると子供達がどんどん成長していく様がよくわかる。100日を1分で振り返れるのだから、1年を3分ちょいで振り返れることになる。スライドショーの途中で気になった写真があれば、そのタイトルをクリックすると、その写真にフォーカスした通常画面に戻れる。そこで上を押すかコレクション名を選択すると、その日の全ての写真を見ることができる。スライドショーを中断させたくない場合は、写真のタイトルを右クリックして「別タブで開く」しておけばいい。そうして気になった写真を拾ってから周辺を探索することで、過去の情報への到達性が格段に向上する。

例えば「2年くらい前に浅草の祭りに行った時の写真が見たい」という欲求があったとして、その写真に到達するにはどうすればよいか。「浅草」「祭り」などというタグ付けを律儀にしている人であれば文字列検索で到達できるだろうが、そんな几帳面な人は少数派だろう。そんな場合はスニペット機能で3年前まで飛んで、スライドショーの高速めくりでまず浅草の祭りに行った日の写真だと思われるものを別タブに開いておく。一通り候補を揚げてから、ここの写真の日付のコレクションに飛んで、その日のスライドショーを見ていけばいい。人工知能によるタグ付けがなくても、ページめくりさえ早ければGoogle Photosに勝つる。
f:id:fridaynight:20181016120245p:plain

個々の写真が効率的に選択できるようになったので、ついでにzipアーカイブ形式での一括ダウンロードも実装した。通常の一覧画面で対象の写真を選択してから、メニューの「Download Resources」を選べばよい。または、スライドショーで対象の写真を選択してから、Actionsタブのあら「Download Resources」を選んでもいい。ダウンロードするファイルの種類は「all」(元データ、表示用データ、サムネイル、メタデータの全て)、「data」(元データのみ)、「view」(表示用データのみ)から選択できる。第三者に写真を送ったり、手軽に別の場所にバックアップをとったりするのにこの機能は便利だろう。

これもスライドショーとは関係ないのだが、画像の加工もできるようになった。一覧画面からサムネイルをクリックしてその写真を閲覧する画面で、写真の下にある「convert」をクリックすると画像加工用のダイアログが出る。そこで加工の項目を設定してから「Convert」ボタンを押すかキーボードの「c」ボタンを押すと、加工後の画像が別タブで開かれる。この機能は加工というよりはむしろフォーマット変換のために必要なのでつけた。私のようにTIFFJPEG 2000で元画像を管理している場合、Chromeだと元画像がそのまま閲覧できないのだ。そういう場合にはSafariを使えばよかったりはするが、Android上だとChromeしかないし、大きい画像を誰かに渡したい場合には元画像をそのままのサイズでJPEGに変換したくなったりするものだ。ついでにトリミングや明るさの調整ができるようにもしたという経緯だ。ダウンロードしないでもサーバ側で画像加工ができるというのは結構便利だ。ちょっと暗めでパッとしない写真を何かに使いたい場合には「Contrast」を「Hard Light」にすると良い結果が得られることが多い。
f:id:fridaynight:20181018051902p:plain

実装メモ

通常の写真閲覧機能でも、同一コレクション内の兄弟の写真を先読みしておくことで、画像の読み込みのための遅延をあまり感じさせずに多数の写真をパラパラと見ていけるように配慮している。しかし、各写真毎にCGIスクリプトを呼び出して検索処理を行っているので、体感できる程度の遅延がどうしても発生してしまう。リソース毎にURLを割り当てるというWebの思想を愚直に実装するとそうなるのだが、処理効率の点ではあまりよくない。WSGIとかを使ってアプリケーションコンテナの層を効率化すればプロセス呼び出しとスクリプト解釈のオーバーヘッドは緩和できるが、リソースの検索処理が毎回走る問題はアプリケーション層で解決しないと仕方ない。そこで、複数のURLを指定して一気に写真データを読み出しておいてから個々の写真を表示する機能が欲しくなる。それを実現したのがスライドショーである。

データの取得はlist_image.pyのJSON応答機能で行う。実際のリソースすなわち写真カタログデータは階層構造で管理されているのだが、スライドショーとして表示する際にはフラットな構造に直したい。JavaScript側で複雑な処理をすると面倒なので、flatten=1 というパラメータをつけて問い合わせを行うとサーバ側でフラットな状態にしてデータを返してくれるようにしてある。ちょっと頭を悩ませたのは、順序制御だった。以前にも述べたが、日付(コレクション)は新しい順に並べるが、同一日の写真(レコード)は古い順に並べて表示するのがデフォルトである。それをフラットな構造にするなら、どういう順番にするのが驚き最小になるか。今の実装では、コレクションの順序指定は無視して、レコードの順序指定を優先して並べかえを行なっている。つまり、デフォルトでは、古い順に表示される。追体験を重視するなら時系列に沿って写真を見ていくべきだというポリシーを貫いている。しかし、これを不可解な挙動と感じる人もいることだろう。最新(新しい順)100件の画像を古い順で表示するなんてのは、奇妙だ。「ルート」コレクションを対象にした場合にのみコレクションの順序を優先するということも考えたが、そういうアドホックなルールは美しくないと感じる。それより、スライドショーは基本的には時系列順であるという体験を確立させた上で、先頭や末尾にジャンプできる機能によって利便性を上げる方が望ましい。

取得したURLの一覧はメモリ上に配列として保持しておく。そして、現在表示すべき写真のインデックスを各種入力のイベント操作で変更し、それに伴って画面の再描画を行う。画面を再描画する際に、まず現在表示すべきレコードの画像データをサーバから取得し、それをiImageオブジェクトとしてキャッシュする。キャッシュが既にある場合にはデータの取得は行わずにそれをそのまま表示する。その後、100ミリ秒毎に遅延を増大させつつ、現在の位置から3枚後までの写真データを先読みしておく。その後、現在の位置から3枚前までの写真データも先読みする。遅延を発生させないで同時に取得しても動作はするが、次に表示される蓋然性が高いデータほど先に読みたいので、自分の行動パターンを踏まえてこのような実装になっている。なぜ前から後ろまで順に全体を先読みしないのかというと、100枚とか一気に読み出したらサーバ側もクライアント側も過負荷になる可能性があるからだ。仕様上はパラメータひとつでどんなにでかいスライドショーも設定できるが、実際にその画像データを読み出すきるかどうかはユーザの操作次第になっている。現状ではキャッシュアウト処理は実装していないが、個人用のツールなので不要だろう。

サムネイルの一覧表示の画面設計も悩ましかった。写真を画面内にできるだけ大きく表示したいとなると、サムネイルの一覧を置く場所などないのだ。仕方ないので、画面最下部にフローティングさせて配置した。そうすると画像が画面最下部にまでかかる場合にはサムネイルが画像の一部を隠してしまうことになるが、仕方がない。うざい時には「s」ボタンで隠すようにしてもらう。

実装上で最も苦労したのは、画像や動画のサイズを調整するところだ。画像や動画がロードされた瞬間に縦と横のサイズを検出して、それを画面サイズに合わせてアスペクト比を保ったまま縮小する必要がある。画像の場合はloadのイベントハンドラでそれを行うが、widthやheightというパラメータは既に画面サイズに合わせて調整された値が出てくることがあるので、naturalWidthやnaturalHeightというプロパティが利用できる場合にはそれを利用する。動画は全体をプリロードしないのが普通なので、loadでなくloadedmetadataというイベントを拾い、またviewWidthやvideoHeightというプロパティを読む必要がある。

スライドショーとの直接の関係はないが、更新処理時の排他制御をfcntlモジュールのlockf関数で実装した。これはUNIXのfcntlシステムコールのlockfオプションのラッパーなのでLinuxMacでは動くがWindowsでは使えないので、その場合には単なるファイルの存在確認によるロックにフォールバックする。排他制御がなぜ重要かというと、CGIスクリプトからの更新処理を許すと同一リソースや親子関係の複数のリソースに同時に更新を行い、データの不整合が起きる可能性があるからだ。特にJavascriptからXMLHttpRequestで非同期に更新をかけると、ものすごい勢いで同時にデータを更新しようとするので、何もしないと高い確率でレースコンディションが発生してしまう。なお、list_image.pyのロックファイルとcram_image.pyのロックファイルは、同じアルバムに対して共有されるので、両者間の排他制御も適切になされる。

アーカイブ一括ダウンロードにはRestful的なURLが設けられている。CGIスクリプトのURLの後ろにPATH_INFOとして「/__zip__」をつけるとアーカイブ配信に切り替わり、さらにその後ろにリソースIDをつけて対象を選択する。例えば「http://example.com/list_image.cgi/__zip__/2018-02-11」とすると、「/2018-02-11」以下の写真データのアーカイブが配信される。リソースを複数指定したい場合には改行区切りのリソースIDのリストをidsというパラメータとして指定する。例えば「/__zip__?ids=/2018-02-11%0a/2018-03-25」などとする。対象ファイルの種類の選択は、modeというパラメータで指定する。allなら全種類、dataなら元画像のみ、viewなら表示用画像のみになる。なお、普通は使わないだろうからUI上にはないが、「thumb」でサムネイルのみ、「meta」でメタデータのみの配信もできる。

アーカイブデータは外部コマンドとしてzipコマンドを呼んで生成されている。ここでのポイントは、サーバ上にはアーカイブファイルを作らずに、zipコマンドの出力をストリーム配信していることだ。サーバ上に一時ファイルを置くことがないので、ディスク容量を食わずにデータの配信がなされる。よって、たとえ「/__zip__/」などとしてリソース全体のアーカイブを生成したとしても、サーバ側のディスクが圧迫されることはない。なお、JPEGなどの圧縮ファイルはそれ以上圧縮できないので、zipの圧縮レベルは0(無圧縮)にしてある。

画像加工機能は外部コマンドとしてはcram_image.pyを呼ぶことで実現されている。したがって、cram_image.pyがパスにCGIスクリプトの実行ユーザのパスに入っている必要がある。つまり cram_image.py を /usr/bin の下にインストールするか、/usr/local/bin/cram_image.py からのシンボリックリンクを /usr/bin/cram_image.py に置くか、ApacheのSetEnvとかで /usrl/local/bin にパスを通すかといった設定がおそらく必要になる。画像の調整はLightroomとかで現像する際にやっておいてからアルバムに入れるのが基本ではあるが、用途ごとに異なった仕上げをしたい場合にわざわざカタログからデータを取り出して再加工するのはだるすぎる。そこで、最低限の加工機能がWebインターフェイスで使えると嬉しい。特にブログを書く際には、他人の顔が写っている写真は使えないので、トリミングがしたくなることがよくある。また、保存用の画像は忠実性を重視して仕上げているが、ブログに上げる際には見栄えを良くするためにコントラストや彩度を上げたくなることもある。

ここでの画像加工は、非破壊的である。つなわち元データには一切変更せずに、加工したコピーを取り出すという操作になる。したがって、RESTの文脈では、GET操作の範疇に入る。そこで、リソース名に「/__convert__」を接頭させたURLにアクセスするだけで返還後のデータが得られるようにした。例えば「/2018-02-11/hoge」をJPEGに加工したデータが欲しい場合「/__convert__/2018-02-11/hoge」にアクセスするだけでよい。画像フォーマットをPNGにしたい場合は」「/__convert__/2019-02-11/hoge?format=png」にアクセスすれば良い。アクセスされるたびにサーバ側でImageMagickを呼ぶので遅いのだが、個人用ツールなのでそれも許容範囲だろう。なお、スライドショーで写真を見ている際に「y」を押すと、元坐像を JPEG変換したデータを得ることができる。「u」を押すと、コントラスト調整とシャープネス調整をかけた上でJPEG変換したデータを得ることができる。

まとめ

高速なページめくりと一括削除が実装されたことで、写真管理のワークフローがかなり楽になる。現像ソフトウェアでは、良さげな写真は一応全て現像してアルバム内に登録しておいて、後で暇な時に吟味して消せばいいのだ。当家の場合、私や妻のスマートフォンで撮った写真や動画もFTP経由で同じアルバムに合流するのだが、それらも同じ方法で管理できる。

スライドショーの導入で、当家で必要な機能はだいたい実装できたので、当家での実運用に入れる。あなたが所有する犬の餌を食べろ。