毎秒1000リクエスト を捌く超高速CMS「adiary」
2020/04/22(水)Chrome/AndroidでVideo/Canvasタグのアスペクト比でハマる
Android の Chrome でカメラから画像取得を試みたところ、縦横比がおかしくなってしまいました。この問題の解決を試みたメモ。
縦画面限定で、画像がおかしくなる
この画像はHTML/JavaScriptを使いスマホで正方形の枠を映したものです。
videoタグに 1600x900 のサイズを指定したあとカメラ画像を取得して、同サイズのcanvasタグに画像をコピー(drawImage())しました。縦に圧縮された横長画像になっています。
これは Chrome 固有の問題ではなく、Chromeのスマホエミュレーションでは何も問題なく表示されます。実機のスマホのChrome(or Firefox)でのみ起こります。色々テストしていると以下のことがわかりました。
- スマホを横画面にすると正常になる。
- 取得画像を 1:1 に設定すると正常になる。
テストしていたときは Canvas タグのみ描写していたのですが、videoタグと同時に表示するとこんな感じになりました。
videoソースが縦長?
mediaDevices.getUserMedia() の罠
16:9の画像をこんな感じで取得しようとしていました。
navigator.mediaDevices.getUserMedia({ audio: false, video: { width: 1600, height: 900, facingMode: "environment" } }).then( (stream) => {
mediaDevices.getUserMediaの説明を隅々まで読むと書いてあるのですが、widthとheightの指定は要望であって強制ではありません。
ピッタリとマッチする解像度がカメラ(ビデオソース)に存在しない場合、ブラウザはそれに近い画像サイズを設定します。スマホにおいては、そのサイズが 900x1600 なのです……。
ブラウザにおいて縦画面・横画面という区別はありません。ブラウザの media stream は状況(縦横)に応じてサイズが変化することを想定していないわけです。ですので、スマホのブラウザは縦横設定を無視して近い画像サイズを選定します。
このvideoタグの表示領域は(白い部分以外)、論理的には 1600x900 の解像度の持ちますので、その部分を 1600x900 に引き伸ばすと横長縦圧縮画像が出来上がるというわけです。
縦画面、横画面両対応の画像を取得する
横画面専用のWebアプリなんて想定したくはないので、縦横兼用で使うにはどうしたら良いでしょうか? 正解はvideoソースを正方形にしてしまうことです。こうすれば、縦横回転しても、表示領域もサイズも変化がありません。
正方形映像の中から、必要な部分をクロップして canvas に転送すればよいわけです。
少し長くなりますが、以下がソースです。
<!DOCTYPE html> <html lang="ja"> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <meta name="viewport" content="width=device-width"> <title>Reader test</title> <style> video, canvas { max-width: min(480px, 100%); display: block; border: 1px solid red; } video { height: calc(min(100vw,480px) * 9 / 16); margin-bottom: 4px; } </style> </head> <body> <video id="video" muted playsinline></video> <canvas id="canvas"></canvas> <script> const videoWidth = 1600; const videoHeight = 900; const offset = (videoWidth - videoHeight)/2; const video = document.getElementById('video'); video.width = videoWidth; video.height = videoWidth; //video.height = videoHeight; const canvas = document.getElementById('canvas'); canvas.width = videoWidth; canvas.height = videoHeight; const ctx = canvas.getContext("2d") navigator.mediaDevices.getUserMedia({ audio: false, video: { width: videoWidth, height: videoWidth, // height: videoHeight, facingMode: "environment" } }).then( (stream) => { video.srcObject = stream; video.play(); video.onloadedmetadata = (e) => { video.play(); console.log(e); } setInterval(function(){ // ctx.drawImage(video, 0, 0, videoWidth, videoHeight); ctx.drawImage(video, 0, offset, videoWidth, videoHeight, 0, 0, videoWidth, videoHeight ); }, 100); }).catch(function(e) { alert(e); }); </script> </body> </html>
コメントアウト部が元々のソース(最初の画像を得たソース)との変化部分です。このソースで実際に画像を取得すると、以下のようになります。
無事、正しいアスペクト比の画像を得られました。*1
問題点
逆に、PCでは正方形の映像ソースを取れることは少ないようです*2。
上のように正方形を指定したのに横長のVideoソースになってしまいました……。
video: { width: {min: videoWidth, max: videoWidth}, height: {min: videoWidth, max: videoWidth}, facingMode: "environment" }
として強制的に正方形ビデオソースを要求すると、そんなの無いから!と怒られて(throw()されて)しまいます……。正方形ビデオソースは、スマホ限定の措置にして、PC等では普通に必要なピクセルサイズを指定するほうが問題は少ないようです。
もう面倒くさすぎるので、対応解像度をリストアップするAPIか、実ソースサイズを教えてくれるAPIを追加してくれないかな……。
まとめ
- スマホで縦横両対応にする場合は、正方形ビデオソースを使用し、canvas転送時にクロップすると良い。
- ただし、PCでは正方形ビデオソースなんて使えない。
この辺 Mac/iPhone はどうなんでしょう?
2019/07/05(金)JavaScriptから .png 画像を加工してCSSに適用する
png画像をJavaScript上で加工して、CSSに適用する方法。
背景
adiaryでは、UIアイコン(このサイトだとページ送りやタグ一覧に出ている▼等)を色指定に基づいて動的にロードしています。
仕組みとしては、予め128種類の色の異なる画像アイコンを用意して、指定色にもっとも近い色をロードするという感じになっていますが、いくつか問題があります。
- 画像ファイルを増やすには限界があり近似色になってしまう。
- 1KBにも満たないような小さなファイルをわざわざ別ファイルとしてロードさせるのは嫌。*1
色が固定で良いなら、base64で埋め込むだけの話なのですが……。
.ui-icon { background-image: url('data:image/png;base64,XXXX----XXX='); }
adiaryはテーマの色をユーザーが自由に変更できるという素敵な機能が付いておりまして、そういうわけにも行きません……。
正攻法
2019/07/02(火)ES2015(ES6)をminifyしたいけど、Node.jsに興味はない場合
ES2015(ES6)のJavaScriptを minify(compress) したかったときに苦戦したメモ。
JavaScript Compressor
古くから有名でお手軽なYUI Compressorをインストールして、圧縮してみたのですが、謎のエラーが出て困ってしまいました。
Exception in thread "main" java.lang.reflect.InvocationTargetException at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62) at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43) at java.lang.reflect.Method.invoke(Method.java:498) at com.yahoo.platform.yui.compressor.Bootstrap.main(Bootstrap.java:21) Caused by: java.util.MissingResourceException: Can't find bundle for base name org.mozilla.javascript.resources.Messages, locale ja_JP (以下略)
どうやら調べてみると、YUI CompressorはES6(let/const)に対応していないようです。
2019年にJavaScriptを書くのに「ES2015ではないスクリプトを書く」のは考えられないので、ES2015対応のJavaScript Compressorを探してみました。
Node.js に用はない
色々調べてみてもNode.js関連情報しか出てきません。
「Node.js」に興味はなく、純粋にJavaScriptを圧縮したいだけなのに……。
路頭に迷っていたのですが、Uglify-jsというNode.js関連ツールを使うことで、Node.jsとは関係なく単にJavaScriptを圧縮できることがわかりました。
Wabpackというツールもあるのですが、軽く調べた感じではNode.js専用ツールっぽい感じで、単なるJavaScriptの圧縮ツールとしては使いにくそうでした。
Uglify-es
残念ながらUglify-jsは「Uglifyjs 3 (Ver3)」でもES6には対応していません。超注意点です。
Uglify-jsからフォークされた「Uglify-es」というツールを使います。まずこのツールを入れるために、Node.js用のパッケージマネージャー npm をインストールする必要があります。
今回インストールしたサーバはDebainですが、普通にapt-getすると古いツールが入るため、「Debian9 (stretch) に Node.js をインストールする」を参考にVer10系の Node.js を導入しました。
curl -sL https://deb.nodesource.com/setup_10.x | bash - apt-get install -y nodejs
これで npm が使用できるようになったので、Uglify-esを導入します。
npm install uglify-es
Makefile
Uglify-js@2、Uglify-js@3、Uglify-esでみんなコマンドラインオプションが違うので、情報を探す際はご注意ください。
参考までに作成したMakefileを晒しておきます。
# # Makefile for adiary.min.js # COMPRESSOR = uglifyjs OPTIONS = -c -m --comments '/^!/' --source-map includeSources,filename OUTPUT = ../adiary.min.js MAP_FILE = ../adiary.min.map FILES = \ 01_global-variables.js \ 10_jquery-ext.js \ 20_init.js \ 30_css.js \ 90_PrefixStorage.js \ 91_jquery-storage.js all: $(OUTPUT) $(OUTPUT): $(FILES) Makefile $(COMPRESSOR) $(OPTIONS) $(FILES) -o $(OUTPUT) mv $(OUTPUT).map $(MAP_FILE)
- -c
- ソースファイルを圧縮します
- -m
- Mangleします。これをつけたほうが圧縮率が高くなります。
- --source-map includeSources,filename
- mapファイルを作成します。
- --comments '/^!/'
- 「!」マークで始まるコメント(ライセンスコメント)を残します。
terserになりました
uglify-esは更新が停止し、uglify-esをフォークした terser に乗り換えました。
インストール方法。
# apt-get install npm # npm install terser -g
使い方は uglifyjs と一緒です。
2015/05/14(木)スマホでドラッグ&ドロップのエミュレーション
jQuery UI や dynatree を使用していて、ドラッグアンドドロップ操作が必須であるにも関わらずスマホでは何もできないので、汎用的な実装をjQuery pluginで実現しました。
タッチ操作でDnDをエミュレーションする
タッチパネル系イベントは独特らしく、短くタップしたときのみ mousedown や click 等のマウスイベントが発生してくれますが、長く触っているとマウスイベントは発生しないようです。
ですので、以下のように実装しました。
- touchstart で mousedown を発火。
- touchmove で mousemove を発火し、mouseleave と mouseenter をエミュレーション。
- touchend で mouseup を発火。
mouseover, mouseoutも実装はできますが無視しました。jQuery UIが問題なく動く程度には実装しているつもりです。
エミュレーションがonの状態で短くタップすると、mousedown/mouseupイベントが2重に発生する可能性がありますが、解決策がないので保留です。
ソース
修正BSDライセンスとします。jQuery pluginですので適当に読み込ませて次のように使ってください。
$(dom).dndEmulation();
以下のソースは最新でない可能性があります。最新版は、adiaryのサイトからGitHub経由で「js/adiary.js」を参照して該当部のみ抜粋してください。該当部のみ抜粋する限り修正BSDライセンスで扱って構いません。
var TouchDnDTime = 700; $.fn.extend({ ////////////////////////////////////////////////////////////////////////////// // Copyright (C)2015 nabe@abk, New BSD License. ////////////////////////////////////////////////////////////////////////////// dndEmulation: function(){ var self = this[0]; if (!self) return; // mouseイベント作成 function make_mouse_event(name, evt, touch) { var e = $.Event(name); e.altKey = evt.altKey; e.metaKey = evt.metaKey; e.ctrlKey = evt.ctrlKey; e.shiftKey = evt.shiftKey; e.clientX = touch.clientX; e.clientY = touch.clientY; e.screenX = touch.screenX; e.screenY = touch.screenY; e.pageX = touch.pageX; e.pageY = touch.pageY; e.which = 1; return e; } // 自分自身を含めた親要素をすべて取得 function get_par_elements(dom) { var ary = []; while(dom) { ary.push( dom ); if (dom == self) break; dom = dom.parentNode; } return ary; } // クロージャ変数 var prev; var flag; // mousedownエミュレーション this.bind('touchstart', function(_evt){ var evt = _evt.originalEvent; prev = evt.target; var e = make_mouse_event('mousedown', evt, evt.touches[0]); $( prev ).trigger(e); // ある程度時間が経過しないときは処理を無効化する。 flag = false; setTimeout(function(){ flag=true; }, TouchDnDTime) }); // mouseupエミュレーション this.bind('touchend', function(_evt){ var evt = _evt.originalEvent; var e = make_mouse_event('mouseup', evt, evt.changedTouches[0]); $( evt.target ).trigger(e); }); // ドラッグエミュレーション this.bind('touchmove', function(_evt){ var evt = _evt.originalEvent; // 一定時間立たなければ、処理を開始しない if (!flag) return; var touch = evt.changedTouches[0]; var dom = document.elementFromPoint(touch.clientX, touch.clientY); var enter = get_par_elements(dom); // マウス移動イベント var e = make_mouse_event('mousemove', evt, touch); $(enter).trigger(e); // 要素移動がなければこれで終了 evt.preventDefault(); if (dom == prev) return; // 要素移動があれば leave と enter イベント生成 var leave = get_par_elements(prev); // 重複要素を除去 while(leave.length && enter.length && leave[leave.length -1] == enter[enter.length -1]) { leave.pop(); enter.pop(); } // イベント発火 var e_leave = make_mouse_event('mouseleave', evt, touch); var e_enter = make_mouse_event('mouseenter', evt, touch); $(leave).trigger( e_leave ); $(leave).trigger( e_enter ); // 新しい要素を保存 prev=dom; }); } ////////////////////////////////////////////////////////////////////////////// });
2015/05/13(水)Lightbox2 にスワイプ対応パッチを充てた
充てたというかパッチしました。作りました。jQueryのみです。
- https://github.com/nabe-abk/adiary/blob/master/js/lightbox.js
- https://github.com/nabe-abk/adiary/blob/master/js/lightbox.min.js
画像をめくるときにタッチパネルのスワイプ操作でも反応するようになっています。特にアニメーションはしません。
jQueryのバグなのか「touchstart」「touchmove」イベントを全く拾わないので、「window.addEventListener」に逃げました。もしもっとスマートな解決方法を見つけたら教えてください。
これを使用しているadiaryもよろしくお願いします。
その他の変更点
#lightbox-min-width { display: none; width: 300px; }
と設定すると、横幅が小さすぎる画像を表示するとき、指定したピクセルまでアスペクト比を保持して拡大して表示します。
極端に縦長の画像の場合はアスペクト比を保持できて、画面に収まる範囲内で拡大されます。