プロ生ちゃんクソT時計



これは プロ生ちゃん Advent Calendar 2015 20日めの記事です。

qiita.com

プロ生ちゃんクソT時計を作りました。

プロ生ちゃんクソT時計とは、プロ生ちゃんクソT選手権に投稿されたプロ生ちゃんクソT画像を定期的に切り替えて表示する時計のことです。下は、スクリーンショットです。

f:id:takemaruhirai:20151216231101p:plain

こちらからダウンロード可能です。

マスコットアプリ文化祭2015 にも作品登録させて頂いていおります。

mascot-apps-contest.azurewebsites.net

【前置き】そもそも「プロ生ちゃんクソT」とは何か

プロ生ちゃんにクソTを着せるムーブメントのことです。

nさんの以下のような投稿をきっかけに、2015年9月頃に、突発的に盛り上がりました。

ハッシュタグ#プロ生ちゃんクソT選手権」で検索すると、雰囲気を味わっていただけます。

詳しくは、こちらの記事を見てください。

pronama.azurewebsites.net

ジェネレーターもありますので、お手軽に作れます。今からでも参加、遅くありませんよ!!

クソTプロ生ちゃんジェネレーター

【前置き】そして「プロ生ちゃんクソT時計」とは何か

#プロ生ちゃんクソT選手権」を眺めながら、オノッチさんがぽつりと呟きました。

たぶん、そのタイトルからイメージされるのって、かつてブームになった「美人時計」のような感じの何か、なのですよね。

美人時計

bijin-tokei(美人時計) 公式ウェブサイト

さすがに、そんな豪華なものは手間もコストもかかるうえに技術的にも大変で、簡単には作れそうにないので皆、躊躇したのかもしれませんしそもそも見ていなかったかスルーしていたのかもしれません。

待てど暮らせど、誰かが空気を読んで「プロ生ちゃんクソT時計」なるものを作る気配がありません。

ですが、私はなんとなく頭の片隅にひっかかっていました。

【前置き】なぜ、自分でつくろうと思ったか

気になっていた、というのもひとつの理由ではあります。

ですが、実はもうひとつのきっかけがありました。それが、プロ生ちゃんのこのツイートです。

で、これに対して「React興味ある」と軽い気持ちで呟いてしまったのですね。そしたらプロ生ちゃんから!

これは着手しないパターン と言われてしまったではありませんか。

もう、これは遅くとも年内には React に着手するぞ、と心に誓ったのでした。

そこに、誰も手を付けない「プロ生ちゃんクソT時計」案件が転がっている・・・

そうだ、これを React 入門の材料にしてみてはいかがだろう、などと考えだすのは時間の問題でした。

【技術的,全体】アプリケーションの構成

要件と仕様

さて、やっと技術的な話です。クソT時計とは何でしょうか?(技術的に)

まず、要件、というと大げさですが。何を達成すればよいのか。それは一言で「プロ生ちゃんクソT選手権に投稿されたプロ生ちゃんクソT画像を定期的に切り替えて表示する時計」であることです。

満たすための仕様として、今回のアプリケーションでは以下のようなことを行っています。

バックエンド側(サーバーサイド)
  1. #プロ生ちゃんクソT選手権 に投稿された画像とIDなどの情報を定期的に取得する
  2. 取得した画像/ID情報をDBに保存する
  3. クライアント側からリクエストがあった場合、DBに保存したID情報のリストを返却する
  4. クライアント側からリクエストがあった場合、DBに保存したクソT画像を、サイズの最適化・背景の透明化処理をしたうえで返却する
フロントエンド側(クライアントアプリケーション)
  1. 時計を表示し、時刻に合わせて針を表示する
  2. サーバー側にクソTツイートのIDリストを要求し、メモリ上に保存しておく
  3. 定期的に、リストからランダムにひとつを選び、それに応じた画像をサーバー側に要求する
  4. サーバー側から返却された画像を表示する

全体の構成

アプリケーションの構成は以下のようになっています。

f:id:takemaruhirai:20151214082347p:plain

素材

【技術的,バックエンド】Twitter 画像を MongoDB に保存する

PythonTwitter からツイートの内容を取得し、画像を MongoDB に保存する方法は、過去の記事に書いていますので、興味のある方はそちらを参照してください。

ここでは、Twitter Search API を叩く部分だけ少し簡略化して書いておきます。

def search(params):
    session  = OAuth1Session("ConsumerKey", "ConsumerSecret", "AccessToken", "AccessTokenSecret")
    response = session.get("https://api.twitter.com/1.1/search/tweets.json", params = params)
    if response.status_code == 200:
        data = json.loads(response.text)
        return data["statuses"]
    return None

params = {
    "q" :  "%23プロ生ちゃんクソT選手権+exclude:retweets+filter:images",
    "count" : 100,
    "result_type" : "recent"
}

for tweet in search(params):
    # DB に Tweet ID が存在するかを確認し、
    # なければ、ツイートの内容をDB に格納する

これを使って、「#プロ生ちゃんクソT選手権 の投稿を取得し、MongoDBに保存する」動作を cron で10分ごとに実行する bot 化してあります。

【技術的,バックエンド】リクエストされた画像をレスポンスとして返す

フロントエンドのアプリケーションからHTTPリクエストが来た場合、バックエンドでは以下のようなレスポンスを返すようにしています。

  1. DBに保存されたツイートの全リスト(IDを含んでいる)
  2. IDに対応した、DBに保存された当該のツイート(画像以外)
  3. IDに対応した画像

1と2は MongoDBのデータを加工して、JSONとして返せばいいだけなので難しくないのですが、画像を返す処理がなかなか大変でした。一番の難点は、プロ生ちゃん部分をはみ出して文字や絵が描かれている ケースで、この部分を画像に残しつつ、背景を透過にする ことをしています。

↓こういう画像を、プロ生ちゃんと外の絵 以外 を透明にしなくてはいけない。

f:id:takemaruhirai:20151214063415p:plain:h200

で、どうしたかといいますと、プロ生ちゃんをくり抜いた形のマスク画像を作り、

f:id:takemaruhirai:20151214064258p:plain:h200

画像処理エンジン Pillow で、自作のマスク処理 を行いました。単純にマスク処理をすると、プロ生ちゃん以外全部透明になってしまうのですが、これを回避するために次のようなことをしています。

  • マスクがかかっていない部分は、そのまま透過処理をする
  • マスクがかかっている部分は、
    • 元画像の色が #FFFFFF (すべてがFになってる)ならば、透過処理をする
    • 元画像の色がそれ以外ならば、透過処理をしない

という、ちょっとややこしい処理を経ることで、目的の画像が得られます。

この部分のコードです。

from PIL import Image

# base が元画像, mask がマスク画像(それぞれ Pillow の Imageオブジェクト)
def kusot_mask(base, mask):
    pixels_in  = base.load()
    w ,h       = base.size
    pixels_msk = mask.load()
    output     = Image.new("RGBA", (w,h), (255,255,255,0))
    pixels_out = output.load()
    for x, y in product(range(w), range(h)):
        r, g, b, a = pixels_in[x,y]
        if pixels_msk[x,y] != 255 and r > 250 and g > 250 and b > 250: #pixels_in[x,y] == (255,255,255,255)
            pixels_out[x,y] = (r, g, b, pixels_msk[x,y])
        else:
            pixels_out[x,y] = pixels_in[x,y]
    return output

MongoDBから画像を取り出してレスポンスとして返す処理自体については、過去の記事がありますのでよろしければ参照してください。

【技術的,フロントエンド】React + Electron + Babel

デスクトップアプリケーションを作成しつつ、Webと技術を共有できるということで、Electronアプリを作ることにしました。アプリケーションの中身自体は、前述のとおり、React を使って書いていくのですが、どうせならば、と ES6 でコーディングすることにしました。ES6 のすべての機能をブラウザが対応しているとは限りませんので、Babel を用いて ES5 に変換します。

必要なものは、

package.json の dependencies はこんなかんじ。

{
  "devDependencies": {
    "babelify": "^6.1.3",
    "browserify": "^11.0.1",
    "electron-packager": "^5.1.1",
    "electron-prebuilt": "^0.34.3",
    "gulp": "^3.9.0",
    "gulp-pleeease": "^2.0.1",
    "gulp-sass": "^2.1.0",
    "rcedit": "^0.3.0",
    "vinyl-source-stream": "^1.1.0"
  },
  "dependencies": {
    "moment": "^2.10.6",
    "react": "^0.14.1",
    "react-dom": "^0.14.1",
    "superagent": "^1.4.0"
  },
}

また、エントリーポイントとして index.js を指定しています。

{
  "main": "index.js",
}

script は Electron に合わせて以下のようになっています。

{
  "scripts": {
    "start": "node_modules/.bin/electron --enable-transparent-visuals . ",
    "pack" : "electron-packager . pronama-kuso-t  --out=exe --arch=x64 --platform=win32 --version=0.35.0 --overwrite --ignore=exe --prune --ignore=node_modules/.bin  --icon=img/icon-win.ico"
  },
}

※ この --icon=img/icon-win.ico に苦労したのですが、その話はこちら

gulpfile.js の build の部分はこうです。Babel は React の jsx ファイルを js に変換しつつ、ES6/7 のコードを ES5 に変換してくれちゃうので凄いのです。

gulp.task('build', function () {
    browserify({
        entries: 'index.jsx',
        extensions: ['.jsx'],
        debug: true
    })
        .transform(babelify)
        .bundle()
        .pipe(source('bundle.js'))
        .pipe(gulp.dest('dist/js'));
});

ルートディレクトリの構成はこんなかんじです。

  • src - React コンポーネントソースコード(ES6/7, jsx)
  • dist - 変換されたファイルの配置先(ES5, js)
  • img - 画像ファイル
  • exe - Electron によってビルドされたアプリケーションの配置先

(index.js と index.jsx と index.html ができちゃってますが、気にしない。)

実行の流れですが、

  • package.json に指定してあるとおり、まず Electron 用の index.js が呼ばれます。
  • Electron が UI として index.html を実行します。
  • index.html から open.js が呼ばれて、Electron のウインドウの位置を保存/回復したりしています。
  • index.html から React のindex.jsx が呼ばれます。
  • index.jsx から、React の子コンポーネントが呼ばれます。

以下にこのあたりのソースを書いておきます。

index.js (Electron)

var app  = require('app');
var Menu = require('menu');
var Tray = require('tray');
var BrowserWindow = require('browser-window');
var currentURL = 'file://' + __dirname + '/index.html';

require('crash-reporter').start();

var mainWindow = null;

app.on('window-all-closed', function() {
    if (process.platform != 'darwin') {
        app.quit();
    }
});

app.on('ready', function() {

    Menu.setApplicationMenu(menu);

    var appIcon = new Tray(__dirname + '/img/icon.png');
    appIcon.setToolTip('プロ生ちゃんクソT時計');

    mainWindow = new BrowserWindow({
        width : 320,
        height: 380,
        transparent: true,
        frame      : false,
        resizable  : false,
        'always-on-top': true,
        "skip-taskbar": true,
        "show": false
    });
    mainWindow.loadUrl(currentURL);
    mainWindow.on('closed', function() {
        mainWindow = null;
    });
});

open.js (Electron)

var remote = require('remote');
var win    = remote.getCurrentWindow();
if (localStorage.getItem("windowPosition")) {
    var pos = JSON.parse(localStorage.getItem("windowPosition"));
    win.setPosition(pos[0], pos[1]);
}
win.on("close", function() {
    localStorage.setItem("windowPosition", JSON.stringify(win.getPosition()));
});
win.show();

index.html (React)

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8" />
    <title>プロ生ちゃんクソT時計</title>
    <link rel="stylesheet" type="text/css" href="dist/css/bundle.min.css">
</head>
<body>
    <div id="content"></div>
    <script src="open.js" type="application/javascript"></script>
    <script src="dist/js/bundle.js" type="text/javascript"></script>
</body>
</html>

index.jsx (React)

import React       from 'react';
import ReactDOM    from 'react-dom';
import Application from './src/jsx/app';

ReactDOM.render(
    <Application />,
    document.getElementById("content")
);

【技術的,フロントエンド】プロ生ちゃんの画像を切り替える

アプリケーションは、いまのところ4つのコンポーネントで構成されています。

export default class Application extends React.Component {
    render() {
        return (
            <div>
                <TransparentPanel />
                <KusoTLogo />
                <PronamaChan />
                <AnalogClock />
            </div>
        )
    }
}

それぞれの説明は、以下のようになっています。

  • TransparentPanel ・・・ 透明な背景
  • KusoTLogo ・・・ タイトルロゴ
  • PronamaChan ・・・ クソTプロ生ちゃん
  • AnalogClock ・・・ アナログ時計

肝心のプロ生ちゃんを表示しているコンポーネント PronamaChan では以下のようなことをしています。IDのリストの取得は初回に1回のみ行われ、1分毎に発生するバックエンドとの通信は画像の取得のみとなっているのがポイントです。

pronama-chan.jsx

import React from 'react';
import moment from "moment";
import request from 'superagent';
import BaseComponent from './base-component';

export default class PronamaChan extends BaseComponent {
    init(){
        // このコンポーネントのステートです。
        this.state = {
            timer : null, twid  : "639820515272605697"
        };
        // 非同期に呼び出されるメソッドで this を有効にするための処理です
        // 実際の動作は BaseComponent(後述)に書かれています
        this._bind('tick','fetchKusoTData', 'openLink');
        // ツイートのリストを保存しておくための変数
        this.stock = null; 
    }
    // コンポーネント開始時に1秒毎のタイマーを起動、tick を実行します
    componentDidMount(){
        let timer = setInterval(this.tick, 1000);
        this.setState({ timer : timer });
    }
    // コンポーネント終了時にタイマーを破棄します
    componentWillUnmount(){
        clearInterval(this.state.timer);
        this.setState({ timer : undefined });
    }
    // 1秒毎に実行されます
    tick(){
        let m = moment();
        // 時刻が xx:59 だったら(1分毎に)、ツイートのリストを取得する処理(fetchKusoTData)を実行します
        if (m.seconds() == 59){
            // ツイートのリストが 変数stock に保存済みの場合、バックエンドへのリクエストは行わない
            if (!!this.stock){
                this.fetchKusoTData(0, { body : this.stock });
            }
            // ツイートのリストが未保存(つまり初回)の場合
            else {
                request
                    .get('http://api.felis-catus.net/pronama/kuso_t') // バックエンドからツイートのリストを取得
                    .end(this.fetchKusoTData);
            }
        }
    }
    // ツイートデータを取得する
    fetchKusoTData(err, res){
        if (err) throw err;
        let data = res.body;
        this.stock = data;
        let choice = data[ Math.floor( Math.random() * data.length ) ]; // ツイートのリストからランダムにひとつ選ぶ
        this.setState({ twid  : choice.twid + '' }); // 選ばれたツイートの ID をステートに保存
    }
    // ステートにある ID からURLを構成し、バックエンドに画像を要求
    get imageURL(){
        return "http://api.felis-catus.net/pronama/kuso_t/" + this.state.twid + "/image";
    }

    render(){
        return (
            <div className="pronama-chan">
                <img src={ this.imageURL } />
            </div>
        )
    }
}

base-component.jsx

export default class BaseComponent extends React.Component {
    constructor(props){
        super(props);
        this.init();
    }
    _bind(...methods) {
        methods.forEach( (method) => this[method] = this[method].bind(this) );
    }
}

【技術的,フロントエンド】React で 時計の SVG をアニメーションさせる

つぎは、時計です。

analog-clock.jsx(抜粋)

import React  from 'react';
import moment from "moment";
import BaseComponent from './base-component';

export default class AnalogClock extends BaseComponent {
    init(){
        let m = moment();
        this.state = {
            h     : m.hours(),
            m     : m.minutes(),
            s     : m.seconds(),
            timer : null
        };
        this._bind('tick');
    }

    // コンポーネント開始時に1秒毎のタイマーを起動、tick を実行します
    componentDidMount(){
        let timer = setInterval(this.tick, 1000);
        this.setState({ timer : timer });
    }
    // コンポーネント終了時にタイマーを破棄します
    componentWillUnmount(){
        clearInterval(this.state.timer);
        this.setState({ timer : undefined });
    }
    // 1秒毎に実行されます。ステートに現在時刻の時・分・秒をセットします
    tick(){
        let m = moment();
        this.setState({ h : m.hours(), m : m.minutes(), s : m.seconds() });
    }
    // 時計の針の角度を計算します。val は現在の時or分or秒、maxは最大値(時=24,分=60,秒=60)
    // 元の時計SVGの針の向きが12時方向ではないばあい、start_degree を指定します(3時方向=90)
    toDegree(val, max, start_degree){
        let degree = (val * 360 / max) - start_degree;
        if (degree < 0)
            degree = 360 + degree;
        else if (degree > 360)
            degree = degree - 360;
        return degree;
    }

    render(){
        return (
            <div className="analog-clock">
                <svg version="1.0" width="100%" height="100%" viewBox="0 0 400 400" id="svg2">
                    <defs id="defs4">
                        <filter id="filter3945">
                            <feGaussianBlur stdDeviation="2.4871406"/>
                        </filter>
             (中略)
                    </defs>
                    <g transform="translate(-278.34,-412.89)" id="layer1">
                       (中略)
                        <path
                        d="m 477.97798,471.84594 -5.55309,139.24123 2.24695,-4.9e-4 -2.41394,24.2058 11.1021,-0.002 -2.41574,-24.20333 2.58796,0.001 -5.55424,-139.24198 z"
                        id="rect3080"
                        style={{"fill":"#000000","fillRule":"evenodd"}}
                        transform={ "rotate(" + this.toDegree(this.state.m + (this.state.s / 60), 60,  0) + ",479,608)" }/>

                        <path
                        d="m 578.0952,609.05293 -104.00977,-5.66807 0.008,2.4317 -18.08625,-2.55309 0.0408,12.0361 18.06893,-2.68067 0.009,2.80207 103.96898,-6.36804 z"
                        id="path3087"
                        style={{"fill":"#000000","fillRule":"evenodd"}}
                        transform={ "rotate(" + this.toDegree(this.state.h + (this.state.m / 60), 12, 90) + ",479,608)" }/>

                        <line x1="477" y1="480" x2="477" y2="650" style={{"stroke":"rgb(149,21,44)","strokeWidth":2}}
                        transform={ "rotate(" + this.toDegree(this.state.s                      , 60,  0) + ",479,608)" }/>
                      (中略)
                    </g>
                </svg>
            </div>
        )
    }
}

何をやっているかというと、SVG で描かれた時計 の 長針・短針・秒針を、React Component のステートの値(現在時刻)によって、rotate させているのです。 React では、SVGも仮想DOMとして扱うことが可能なのですね!そして、その描画を state に依ることができるのです。

SVGpath タグには transform という属性を設定することができるのですが、これに rotate(回転)という値を設定することができるのです。これをたとえば下記のように、state と絡めてやれば、state の値が即SVGの描画に反映されるという訳です。

// 座標(479,608)を起点に 長針を this.state.m(分) に従って(this.state.s(秒)も加味)、正しい角度(toDegreeで計算)で描画する
transform={ "rotate(" + this.toDegree(this.state.m + (this.state.s / 60), 60,  0) + ",479,608)" }

この方法によって、いかにもあっさりと、しかも直感的に、時計の針のアニメーションが完成してしまいました。

ちなみに、時計のSVGは、こちらからダウンロードできます。

f:id:takemaruhirai:20151214095851p:plain:h200

【あとがき】React と アニメーションについて

React を実際に触るまで、アニメーションとは相性が悪いのだろうな、という先入観がありました。実際、仮想DOMと jQuery など実際にDOMを弄る方法を用いたアニメーションとは相性が悪いのだろうといまでも思っています。

ですが、現在、アニメーションのあり方によっては、逆に React とアニメーションはとても相性が良いのではないかという気がしています。その可能性の端緒を垣間見たのが、今回のプロ生ちゃんクソT時計だったのです。今回、時計部分に用いた SVG を state に応じて描画する方法などがその例です。

かつての Flash に変わって、ウェブ上でのアニメーションは、SVGCanvasが中心となり始めています(CSSなどもありますが)。Canvashは完全にAPIで描画していくタイプなのでステートをもたせるのは難しいかもしれません。ですが、SVGはそれ自体が状態を持つ、画像のフォーマットとも言えるものです。このような存在は、React の考え方とよく馴染むのではないでしょうか。

React も SVG も、まだまだ勉強不足ではありますが、今後もなにか機会を見つけては、この組み合わせを試してみたいと思っています。

長文、読んでいただき、ありがとうございました。