2016年12月23日金曜日

Service Workerを使ってWEBP画像をシームレスに読み込む

次世代のWEB画像形式WEBPが発表されてから結構時間が経っているのに、未だにFireFoxとEdgeでは未サポートという悲しい現実があります.

そこで, 今回この問題の解決策を探ったところ, 条件付きではあるもののなんとかなる!ところまで確認できました. ので, まとめてみることとします.

12/25 スクリプトを更新
12/28 データ(Blob)URIスキームに対処できない点に気づいてしまった…


使ったもの

  • Service Worker
  • WEBP画像のデコーダJavaScriptライブラリ
  • 透過BMP画像
どれも解説に時間が掛かるトピックスなので、要点だけ

ストラテジ

  • Service WorkerはWEBページの背後で動作するスクリプトを指し, WEBページ側のリクエストをトリガーとして任意のレスポンスを返すことが出来る.
    なので, WEBP画像のリクエストが飛んできたら、レスポンスを乗っ取ってブラウザが表示可能な画像形式に変換してやることでWEBページは(中身の実体を知ることなく)WEBP画像を表示できるはず.
    ただ、乗っ取りを許すにはそれなりにセキュリティが確保されている必要があるので、WEBサイトのオリジンが「HTTPS://」から始まらないと動作しない.
    とは言え、img要素だろうがbackground-imageだろうが、WEBページ側からは背後の動作を一切知る必要がない(一切の追加ライブラリを要さない)のはこの上ないメリットだ.
     
  • WEBPをPNG画像に変換するlibwebpjsというライブラリの使い方を調べていたら、Workerで動作することを確認.
     
  • ここで透過WEBPをどのような画像形式に変換したら都合が良いかを考えた.
    無圧縮PNGも一時考えたが, ファイル構成が面倒なので挫折. で、そう言えばBMPって透過出来たような?と調べてみると, これが仕様にある. そこでWEBブラウザで表示できるか試してみると、普通に表示される!
    しかもBMPって構造が単純(ほぼcanvasのImageDataを縦方向にひっくり返しただけ)だから、ファイル生成も楽ちん. どうせメモリ内での利用に限るのだから、無圧縮でも問題なし. むしろ展開に掛かるコストが省かれる分高速に動作するはず. 
ということで、一番のネックになりそうなのがService Workerなのだが、can i useによれば懸案となりそうなEdgeでも次の15で(フラグ付きとは言え)サポートされそうな気配.
つまり、FireFoxとEdgeでもHTTPS環境下であればWEBP画像を使えるって寸法.




追記:ただこの方法にも穴があって、データURIスキームによるWEBP画像埋め込みには対処できない. なので, WEBP画像をどのように使うかについて予め決めておく必要がある.

実際のコード

上を見るとさも面倒なコードとなりそうだが、内容的にはさほど大したことはしていない. むしろサンプルコードの不足から、上手く動くまでが苦労した.

WEBページ側


<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="content-type" content="text/html; charset=UTF-8"/>
    <title></title>
    <link rel="stylesheet" href=""/>
    <style type="text/css">
body{
    background-image:url(test.webp);
}
img,canvas{
    background-color:rgba(100%,100%,0%,0.5);
}
    </style>
    <script type="text/javascript">
"use strict";
out:{
    const sw = navigator.serviceWorker;
    if(!sw){break out;}
    const img = new Image();
    img.onerror = () => {
        sw.register(
            "/webapps/javascript/webp/webp.service.js", 
            {scope: "/webapps/javascript/webp/"}
        );
        sw.getRegistration()
            .then(registration => registration.update());
    }
    img.src = `data:image/webp;base64,
UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAgA0JaQAA3AA/vuUAAA=`;
}

window.onload = () => {
    const ctx = canvas.getContext("2d");
    const image = new Image();
    image.onload = () => {
        canvas.width = image.naturalWidth;
        canvas.height = image.naturalHeight;
        ctx.drawImage(image, 0, 0);
    };
    image.src = "test.webp";
};
    </script>
  </head>
  <body>
        <a href="test.webp"><img src="test.webp"/></a>
        <img src="taki.gif"/>
        <canvas id="canvas"></canvas>
        <p>WEBP画像が表示されない場合はリロードして下さい.</p>
  </body>
</html>

Service Worker側(webp.service.js)



"use strict";
{
    self.window = self;
    importScripts("libwebp-0.2.0.min.js");
    self.addEventListener("install", e => console.log("installed"));
    self.addEventListener("fetch", e => {
        const request = e.request;
        const url = request.url;
        if(!url.match(/\.webp$/)){return;}
        e.respondWith(
            fetch(request)
            .then(response => response.arrayBuffer())
            .then(buffer => {
                const bin = new Uint8Array(buffer);
                const bmp = imageDataToBMP(decodeWebp(bin));
                return new Response(bmp, {
                    status: "200",
                    headers: {"Content-Type": "image/bmp"}
                });
            })
        );
    });
    function decodeWebp(bin){
        const decoder = new WebPDecoder();
        const config = decoder.WebPDecoderConfig;
        const buffer = config.j;
        buffer.J = 4;
        const stream = config.input;
        if(!decoder.WebPInitDecoderConfig(config) 
            || decoder.WebPGetFeatures(bin, bin.length, stream) != 0
            || decoder.WebPDecode(bin, bin.length, config) != 0){
            throw "not support type.";
        };
        const bitmap = buffer.c.RGBA.ma;
        const [w, h] = [buffer.width, buffer.height];
        const id = new ImageData(w, h);
        const data = new Uint8Array(id.data.buffer);
        for(let i = 0, len = w*h; i<len; i++){
            const pos = i * 4;
            data[pos        ] = bitmap[pos + 1];
            data[pos + 1] = bitmap[pos + 2];
            data[pos + 2] = bitmap[pos + 3];
            data[pos + 3] = bitmap[pos        ];
        }
        return id;
    };
    function imageDataToBMP(id){
        const w = id.width;
        const h = id.height;
        const data = id.data;
        const header = `
            42 4d 00 00 00 00 00 00 00 00 86 00 00 00 6c 00
            00 00 00 00 00 00 00 00 00 00 01 00 20 00 03 00
            00 00 00 00 00 00 ff 00 00 00 ff 00 00 00 00 00
            00 00 00 00 00 00 ff 00 00 00 00 ff 00 00 00 00
            ff 00 00 00 00 ff 42 47 52 73 00 00 00 00 00 00
            00 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00
            00 00 00 00 00 00 00 00 00 00 00 00 00 00 01 00
            00 00 01 00 00 00 01 00 00 00 ff 00 00 00 00 ff 
            00 00 00 00 ff 00
        `    .match(/[0-9a-f]{2}/g).map(str => parseInt(str, 16));
        const hlen = header.length;
        const blen = hlen + data.length;
        const bin = new Uint8Array(blen);
        bin.set(header);
        bin[0x0a] = hlen;
        bin[0x02] = blen >>  0; bin[0x03] = blen >>  8; bin[0x04] = blen >> 16; bin[0x05] = blen >> 24;
        bin[0x12] =    w >>  0; bin[0x13] =    w >>  8; bin[0x14] =    w >> 16; bin[0x15] =    w >> 24;
        bin[0x16] =   -h >>  0; bin[0x17] =   -h >>  8; bin[0x18] =   -h >> 16; bin[0x19] =   -h >> 24;
        bin.set(data, hlen);
        return bin;
    };
}


惜しむらくは、動作サンプルを公開する環境が無いこと. なので、各自適当にWEBサーバーを立てて試してみて下さい.

0 件のコメント:

コメントを投稿