親友の結婚式二次会のためにAngularJSでリアルタイムスライドショーを開発した話

大学時代の親友が結婚式を挙げました。

本当に友達が多い人だから、結婚式に呼んでもらえたことに感謝しかないわけだけど、そんな中、二次会幹事の依頼をされたのが昨年の秋頃でした。

今回は結婚式ムービーではなく、二次会幹事という役割の中で、ちょっとした提案の流れでシステムを開発することになったので、それについて"それっぽく"書いてみようと思います。そこはかとない思い出も込めて。

二次会の要件/企画提案

簡単に言えば、「新郎新婦がどんな結婚式二次会にしたいと思っているか。」ということ。

とはいえ、このあたりはプライベートなことをなので概ね割愛させていただいて、今回どうしてシステム開発をするに至ったのか、ということに焦点をあてたいと思う。

参加型企画

要望を簡単にまとめると、これに尽きる。何より、二次会という場、つまり、新郎新婦とその友人たちによる、挙式・披露宴よりももう少し砕けた場であることは間違いない。というわけで、参加者が能動的に(?)参加できる二次会にしようというのがテーマでした。

参考: フォトシャワー

いつだったか、テレビで取り上げられていたのが、「PHOTO SHOWER」という企画・演出。

スマートフォンで撮影した写真をサイト上でシュッとすると、それがリアルタイムにスクリーンに映し出される、というなんとも近未来感溢れる演出です。

実際にこのサービスがテレビで取り上げられていたかは記憶が定かではないのだけど、おそらくこれだったと思う。個人的には衝撃を受けたし、技術屋の端くれとしてもビビっときた。とりあえず公式サイトにYoutubeの紹介動画があったので貼っておきます。

フォトコンテスト企画

今回の二次会でどういったことをするのか、の本題にあたるわけだけど、上記の「PHOTO SHOWER」からヒントを得て、フォトコンテストを提案してみました。

無論、そこで「PHOTO SHOWER」を利用するというのではなく、二次会参加者の皆様から募った写真を使って、コンテストができたら楽しいんじゃない?という思いつき。

フォトコンテスト用システムの開発

当初は、ひとつのシステムまではいかずとも、フォトコンテストをするための環境整備くらいに捉えていました。写真を集めて、賞選定して、映し出す!くらいの。

システム概要図

これは開発完了後に若干手直ししたので、設計図というよりは概要図なのだけど、まあ今回の全容。

SystemOverview

参加者から写真を集める仕組み

今回序盤で悩んだ部分。写真を集める方法はいくつかパッと思いつきはする。メールだったり、LINEだったり、Twitterだったり、物理メディアデバイスだったり...。ただ、どれも手間がかかったり、アカウントなどの制限がつきもの。

そこで今回採用したのは「Dropboxのファイルリクエスト」機能。

手間やアカウントに係る制限を取っ払うためには、Dropboxのヘルプページに記載の通り、『Dropbox アカウントを所有していない相手にもファイルをリクエストすることができます。』というのが重要でした。

投稿時にメールアドレスの入力が必須というのが多少引っかかりポイントにはなるかなと思ったけど、実際には特にハードルにはならなかった模様です。(友人の結婚式での企画だしね)

リアルタイムでスライドショーに反映する仕組み

今回のシステムのメインはここの開発だったと思うのだけど、Dropboxを通してローカルPCまで落ちてきた写真をどのようにスライドショーにするか。

そもそも、フォトコンテストをするだけならリアルタイムでスクリーンに映し出す必要はなかったと言えばなかったのだけど、せっかくなら、ということで検討開始。

自分の中では紆余曲折あったのだけど、最終的にはAngularJSでの実装に踏み切りました。直近の仕事で触っていたこともあったし、何よりローカルで動かすことを前提としているので、面倒な環境構築やその他諸々が不要であることなどが理由として挙げられます。

以下、AngularJSで実装したことをまとめるため、一章区切ります。

Githubにコミットしました(ほぼそのままのコードを)

結論としては、Githubにコミットしたのでそちら参照、と言いたいところなのだけど、今回、時間の関係もあって、じっくりと設計して云々というわけにはいきませんでした。

なので、Githubに上げたはいいものの、そのままでは使えないし、何よりControllerもDirectiveもすべて一つのJSに書くという至極読みづらい構成になっているという笑

というわけで、ポイントだけを押さえて解説。

リアルタイムスライドショーの実装

要件としては、「Dropboxのファイルリクエストで集まってきた写真をローカルで動作しているAngularで参照し、数秒ごとにふわっと切り替えていくこと」、です。

ということでまずは、写真をAngularから参照するためにその情報(ファイル名など)をテキスト化します。もちろんJSON形式です。

Dropboxの画像情報をJSONファイルに出力するShell(抜粋)(Github

形式は以下のようなJSON。

[
    {
        "src": "氏 名 - 1458649205435.jpg",
        "title": "氏 名",
        "visible": false
    },
    {
        "src": "氏 名 - IMG_1395.JPG",
        "title": "氏 名",
        "visible": false
    },
・・・・・
]

このようなファイルを出力するためのスクリプト(抜粋)が以下です。

create_json() {
    IMAGE_CNT=`ls ${IMG_DIR} | wc -l`
    CNT=1
    echo "[" > ${OUTPUT_FILE}
    for FILE in `ls -tr ${IMG_DIR}`
    do
        echo "    {" >> ${OUTPUT_FILE}
        echo "        \"src\": \"${FILE}\"," >> ${OUTPUT_FILE}
        echo "        \"title\": \"${FILE% - *}\"," >> ${OUTPUT_FILE}
        echo "        \"visible\": false" >> ${OUTPUT_FILE}
        if [ ${CNT} -ne ${IMAGE_CNT} ];then
            echo "    }," >> ${OUTPUT_FILE}
        else
            echo "    }" >> ${OUTPUT_FILE}
        fi
        CNT=$(( CNT + 1 ))
    done
    echo "]" >> ${OUTPUT_FILE}
}

実は、この後出てくる「入賞(選定)した写真を発表する仕組み」や「ランダムで15枚を抽出発表する仕組み」でも同様なことをしているので、Shellでfunction化しています。

また、当日はこのスクリプトを無限ループで動かし続けるスクリプトを別書きして(最初はcronだったけどやめた)、終わったら1秒sleepして起動、を繰り返しました。(Github

スライドショーを映すためのDirective用のテンプレートHTML(Github

先ほどのJSONにある"visible"の値を書き換えて、表示する画像を切り替えていくイメージです。こちらが参考になりました。

<div class="slider">
    <p ng-repeat="image in images">
        <span class="fade" ng-bind="'By ' + image.title" ng-show="image.visible"></span>
        <img class="fade" ng-src="slideImg/{{image.src}}" ng-show="image.visible" />
    </p>
</div>

投稿された写真を追加しつつ、写真を切り替えていくDirective本体(Github

ポイントは二点です。

写真の切り替え

スライドショーなので、一定時間ごとに表示する写真を切り替えていく必要があります。そこは"$interval"を使って、4000msごとに切り替えます。切り替え先は"Math.random()"で乱数を発生させてやります。

写真の追加

投稿された写真をバックグラウンドで追加し続けるため、こちらも"$interval"で3000msごとに上記のJSONファイルの最下部のデータをpushしていきます。

無限ループでスクリプトを動かし続けることにより常に最新の写真を取り込んだJSONを出力し、それを"$interval"で取得し続ける、という仕組みです。

.directive('slider', ['$resource', '$interval', function($resource, $interval) {
    return {
        restrict: 'AE',
        replace: true,
        link: function(scope, elem, attrs) {
            // Initial
            scope.dataLoaded = false;
            var data = $resource('json/images.json').query();
            data.$promise.then(function() {
                scope.images = data;
                scope.dataLoaded = true;
            });

            // Add images
            scope.addImages = function() {
                var newData = $resource('json/images.json').query();
                newData.$promise.then(function() {
                    if(scope.images.length < newData.length) {
                        scope.images.push(newData[scope.images.length]);
                    }
                });
            }
            $interval(function() {
                scope.addImages();
            }, 3000);

            // Intial index number
            scope.currentIndex = 0;
            scope.randomIndex = function() {
                scope.currentIndex = Math.floor(Math.random() * (scope.images.length));
                scope.images.forEach(function(image) {
                    image.visible = false;
                });
                scope.images[scope.currentIndex].visible = true;
            };
            // Loop images
            var t = $interval(function() {
                scope.randomIndex();
            }, 4000);
            scope.onclick = function() {
                $interval.cancel(t);
            };

        },
        templateUrl: 'slider.html'
    };
}])

ちなみに、最後の方に"scope.onclick"で"$interval"をキャンセルしているけど、これはどこからも呼ばれていないので不要でした。

写真の切り替えをふわっとさせる

要するにアニメーションだけど、"angular-animate"を使いました。CSSは以下のような具合でいい感じになります。

.slider .fade.ng-hide-add,
.slider .fade.ng-hide-remove {
    -webkit-transition: all linear 0.5s;
    -moz-transition: all linear 0.5s;
    -o-transition: all linear 0.5s;
    transition: all linear 0.5s;
    display: block!important;
}
.slider .fade.ng-hide-add.ng-hide-add-active,
.slider .fade.ng-hide-remove {
    opacity: 0;
}
.slider .fade.ng-hide-add,
.slider .fade.ng-hide-remove.ng-hide-remove-active {
    opacity: 1;
}
.slider .fade.ng-enter {
    display: none!important;
}

最後にある".fade.ng-enter"の部分が意外と重要で、これがないと新たな写真がバックグラウンドで追加されるたびにAnimationが動いてしまうことになります。

当日のスライドショーの様子

スクリーン中央に投稿された写真のスライドショーが映し出され、右には「現在の投稿枚数」「投稿先のURL(QRコード)」を載せているのがわかるかと思います。

投稿枚数もリアルタイム(たぶん投稿して10秒以内)に反映され続けるので、より参加型企画を促進できたのではないか、と。(自画自賛w)

コンテストの結果発表の実装

既にJSONを出力する仕組みはあるので、あとは新郎新婦が中盤で選定した写真と、入選した写真以外の写真からランダムで15枚を抽出する仕組みもAngularで実装しました。

入賞(選定)した写真を発表する仕組み

ここでの要件としては「5つの賞にそれぞれ1位〜3位があり、各賞の2位3位を同時発表してから、各賞の1位を発表する」ことです。また、5分のリミットで新郎新婦に受賞選定をしてもらうための工夫も必要でした。

予め、"各賞×順位"の階層フォルダを作成しておき、選んでもらった写真をそこに移動するだけで、あとは自動でJSONに出力するという状態にしておきました。(たぶんこれはわかりにくい笑)

各賞発表用のJSONと、それを出力するShell(抜粋)(Github

最初のShellと同じファイルだけど、JSONの形式が異なります。各賞ごとに1位2位3位を持たせるイメージです。(以下では"BEST FRIENDS'"と"MOST FUNNY"が賞名)

{
    "BEST FRIENDS'": [
        {
            "src": "氏 名 - image.jpeg",
            "title": "氏 名"
        },
        {
            "src": "氏 名 - 20141115_155455.jpg",
            "title": "氏 名"
        },
        {
            "src": "氏 名 - 20170205_154931.jpg",
            "title": "氏 名"
        }
    ],
    "MOST FUNNY": [
        {
            "src": "氏 名 - IMG_0617.JPG",
            "title": "氏 名"
        },
        {
            "src": "氏 名 - IMG_7821.JPG",
            "title": "氏 名"
        },
        {
            "src": "氏 名 - IMG_1856.PNG",
            "title": "氏 名"
        }
    ],
・・・・・
}
create_top3_json() {
    CNT=1
    echo "{" > ${OUTPUT_FILE}
    cd ${IMG_ROOT_DIR}
    for DIR in `ls -d * | grep -v 'Random'`
    do
        echo "    \"${DIR}\": [" >> ${OUTPUT_FILE}
        for NUMBER in 1 2 3
        do
            echo "        {" >> ${OUTPUT_FILE}
            FILE=`ls ${DIR}/${NUMBER}`
            echo "            \"src\": \"${FILE}\"," >> ${OUTPUT_FILE}
            echo "            \"title\": \"${FILE% - *}\"" >> ${OUTPUT_FILE}
            if [ ${NUMBER} -ne 3 ];then
                echo "        }," >> ${OUTPUT_FILE}
            else
                echo "        }" >> ${OUTPUT_FILE}
            fi
        done
        if [ ${CNT} -ne 5 ];then
            echo "    ]," >> ${OUTPUT_FILE}
        else
            echo "    ]" >> ${OUTPUT_FILE}
        fi
        CNT=$(( CNT + 1 ))
    done
    echo "}" >> ${OUTPUT_FILE}
}

このJSONを用いて、各賞の1位、2位、3位を参照し、結果発表をしたわけですが、本稿ではソースコードは割愛します。

ランダムで15枚を抽出発表する仕組み

最後は、全投稿写真から各賞に入選した写真を除いたフォルダを作って、スライドショーと同じスクリプトでJSONファイルを生成しました。そこからランダムで抜き出すのはAngularの役目です。

流れとしてはJSONのデータを読み込み、ソート順をランダムにし、上から15までをループする、といった感じです。ここではソート順をランダムにするためのserviceを紹介。僕も理解はいまいちで、乱数発生させて重み付けを変えているのかな、くらい笑

.service('sharedService', function(){
    return {
        shuffleArray: function(array) {
            return array.map(function(a){return {weight:Math.random(), value:a}}).sort(function(a, b){return a.weight - b.weight}).map(function(a){return a.value});
        }
    }
})

実装した感想と当日の振り返り

『とりあえずソースコードきたねえ...』というのは置いておくとして、我ながらいい感じに動作するものができあがったな、と思います。

ガッチガチのコーディング

Dropboxのファイルリクエスト機能は、投稿時に氏名をいれるのですが、それが「氏 名 - ファイル名.jpg」といった具合になるので、それを分割するために、" - "で切る処理を入れていたりします。

普通に考えたら、JSONで処理するのに超重要なファイル名がユーザからの入力値なのだからある程度のValidation処理やらエラー処理は敷いて然るべきなのかもしれないけど、今回は友人の結婚式ということもあり、スクリプトコードをぶち込んでくるような人はいないと思い、エラーハンドリングはほぼしていません笑

それでも、ある程度の記号文字でのテストだったり、数十枚同時にDropboxに同期されたりしたときの負荷(?)テストなりは実施しておきました。

縦長の写真が横になってしまう問題

これはどうしようもないのかもしれないけど、スマホのポートレートモードで撮影された縦長の写真だった場合などに、その写真がスライドショーに映ると横になってしまう現象が当日発現しました。

根本原因は不明のままだけど、一度PCローカル上で保存し直すと正しく映し出されることがわかったので、デュアルディスプレイの中、手元のモニターで横になってしまう写真を手作業で保存し直してました笑

無事に完遂

他の幹事の皆様方の協力と迅速な対応のお陰もあり、予定通りのスケジュール(サプライズも挟みつつ)で、フォトコンテストもシステム不具合などなく完遂することができました。

結婚式、二次会という人生の大きな節目の場において、このような形で携われたこと、本当に嬉しく思います。末永くお幸せに。