はじめに
CANVASを使ったお絵描きサイトで、描いている様子を他のユーザーから見えるようしたい。
今回、僕が個人で運営している「オンライン絵しりとり」というサイトで導入してみたので、その実装案を紹介します。
あくまで実装のアイディアのシェアなので、ソースコードを細かく解説するものではありません。
オンライン絵しりとり
絵しりとりとは:
言葉や文字ではなく、絵を使ってしりとりをする遊びです。
まず一人目が何か絵を描きますが、何を描いたのかは宣言しません。
二人目は、例えばその絵を見て「リンゴ」の絵だと思った場合、「ゴ」から始まる「ゴリラ」の絵を描き、次の人に繋げていく、といった感じです。
この遊びをWeb上でできるのが『オンライン絵しりとり』です。
これまでは、次の絵は描き終わって投稿されて初めて画面に表示されていました。
一応新しい投稿があったときは、apiのポーリングによって自動で次の絵が読み込まれますが、
基本的には掲示板のような非リアルタイムなやりとりになることを想定していました。
しかし、意外にもリアルタイムに複数名が参加して遊ぶケースが多いようなので、
今回これを、回答者が描いている様子を他のユーザーが見えるようにしようと思いました。
要件
- 筆の動きが見えるような、動画配信レベルのリアルタイム性は求めない
- 一筆描くごとに更新される程度のリアルタイム性で十分
- スマホユーザーが多いので通信量には気をつけたい
- 1日くらいで完成させたい
- 既にあるポーリングの仕組み乗っけられないか?
ボツ案
いくつかの案を考えたうち、ボツになったものです。
あくまで僕の今回の目的に合わなかったという意味でのボツです。
ボツ案1 - WebSocketで画像データを送受信
最初に思い浮かんだのは、WebSocketを使った案。
配信者は1ストローク描くごとに1回、CANVASのデータをBase64形式で全員に送信。
受信側はそれをimgタグに更新していく。
容量を減らすために画質は諦める。
筆を動かすたびに、毎フレーム画像データを送受信するのは現実的じゃないので「1ストロークごと」としたが、それだともうWebSocketのリアルタイム性あまり活かせてないような…?
ボツ案2 - WebSocketで筆の動きを送受信
ネットで他の人のアイディアを調べて見つけたもの。
同じくWebSocketを使うが、画像データではなく、「筆を動かした時の座標」や「筆の色」などを送信する方法。
そのデータを元に、受信側も同じ描画関数を用いてCANVASに描いていきます。
こっちは、画質の問題はないし、筆の動きが見えるくらいリアルタイム。
ボツにした理由は、配信者と視聴者のCANVASで矛盾が起きるのが嫌だった。その救済を考えるのも含め。
(例: 途中から接続した人はそのときからの描画しか反映されない)
あとそもそもだけど、既にポーリングがあるところさらにWebSocket入れるのはやっぱりダルいな…。
ボツ案3 - WebRTCで云々
ちゃんと調べなかったけど大変そうなのでボツ。
土日の土で完成させて日でQiitaを書きたいんだ。
採用案 - HTTP通信+imgタグ
タイトルにある通り、HTTP通信で頑張る方法。
ざっくり流れ:
- 配信側がCANVASの内容を定期的にサーバーにPOSTする
- サーバーはその画像を保存する
- 視聴側は定期的にサーバーから最新の画像URLをGETし、普通にimgタグで表示する
配信側
一筆描くごとにCANVASの画像データをPOSTします。
サーバーは、それをpublicなディレクトリに保存します。
この画像は、過去分は不要なので、同じファイル名にひたすら上書していきます。
// CANVASが変更されたときに画像をAPIに送信
const onUpdatedCanvas = () => {
const image = canvas.toDataURL('image/jpeg', 0.3)
const formData = new FormData()
formData.append('image', image)
axios.post('/api/update_live_image', formData)
}
// 共有画像更新用API
fs.writeFile('live.jpg', image)
視聴側
画像URLは一定なので、imgタグにlive.jpg
をセットするだけなのですが、
当然これだけでは、画面には最初にリクエストした時点のlive.jpg
が表示され続けるだけです。
これをURLクエリパラメータを変更することで、別URL扱いにして、再ダウンロードさせていきます。
ポーリングするAPI内で、サーバーはlive.jpg
の更新日時を返してやります。
// 視聴者がポーリングするAPI
const filename = 'live.jpg'
const stats = fs.statSync(filename)
responseJson({
..,
live_image: `${filename}?time=${stats.mtime}`
})
// 視聴者のポーリング処理
axios.get('/api/live_image').then(response => {
imgElement.src = response.data.live_image
setTimeout(() => {
// ポーリング処理
}, POLLING_INTERVAL)
})
<!-- この12345の部分が最短4秒ごとに変わっていく感じ -->
<img src="live.jpg?time=12345">
imgタグはsrc
が変更されれば自動で再リクエスト&再描画されますので、これだけで上記imgタグは動画のように振る舞います。
お気づきかもしれませんが、これはAPI無しでも、4秒に1回クライアント側で勝手にタイムスタンプを更新しても実現できます。
(その場合、配信側が筆を休めているときにも画像を再ダウンロードしてしまうことに目をつぶる必要があります)
今回はどうせ既存のポーリングがあるので、そこに上記のように更新日時を含めました。
画質・通信量
今回はjpgでクオリティ30%にしました。
toDataURL('image/jpeg', 0.3)
解像度は200x150です。
オンライン絵しりとりに投稿されるイラストでいうと、この条件なら大抵の場合2〜3KBになります。
4秒に1度なら、1時間視聴を続けても2〜3MBで済みそうです。
(pngだと性質上、減色してもファイルサイズが軽いのは描き始めのシンプルな画像のときだけで、描き込むほどにサイズが上がっていくため、live用の画像は内容でファイルサイズが変動しにくいjpgにしました。投稿後pngで保存されます。)
ちなみに使用感ですが、今回の用途では、画質、更新頻度ともに全く問題なさそうです。
おわり
ということで、根本的な技術としては、「ある人が画像をアップロードする→他の人が画像にアクセスできる」、というごく普通のことしかやっていません。
オンライン絵しりとりは5年以上前にはじめたサイトで、今回やった実装は、使用技術だけで言えば当時の自分にもできたはずですが、きっと変に難しく想像して思いつなかったんだろうな、と思います。