SafariとWebPの断層
—— 見えない場所に答えがあった

QRコードに埋め込んだ画像に格子状のパターンが現れる。
表示側を疑い、10以上のPRを重ねた末にたどり着いたのは、
Safariが静かに差し替えていたエンコード形式だった。

この記事で言いたいこと:問題は目に見える場所にあるとは限らない。表示を疑い続けた10以上のPRの末、真因はエンコードの静かなフォールバックにあった。探す場所を変える勇気が、10の手段より価値がある。だが10の手段がなければ、場所を変える根拠もなかった。

1. 格子の出現

TokiQRは、QRコード1枚に音声・画像・テキストを埋め込むサービスだ。QRコードのバージョン40、誤り訂正レベルLで格納できるデータは最大2,953バイト。この制約の中で、画像の解像度をできるだけ高く保つために、解像度とJPEG/WebP品質のバイナリサーチを行い、バイト数上限ぎりぎりまで詰め込む。

ある日、スマートフォンでQRコードを読み取り、埋め込んだ画像を表示したとき、格子状のパターンが目立つことに気づいた。ピクセルの境界が目に見える形で浮かび上がり、画像が粗く感じられる。同じQRコードをPCのChromeで開くと、それほど気にならない。

「表示の問題だ」と思った。スマートフォンはDevice Pixel Ratio(DPR)が2〜3倍あり、CSSピクセルを物理ピクセルに引き伸ばすときに、画像のピクセル境界が強調される。解像度の低い画像を大きく表示すれば格子が見えるのは、自然な現象だ。であれば、表示時のアップスケーリングを改善すればいい。

その仮説は、理にかなっていた。だからこそ、長い回り道が始まった。

2. 表示側への10のアプローチ

まず、Canvas APIのbicubic補間を試した。imageSmoothingQuality: 'high'を指定し、ブラウザの最高品質の補間アルゴリズムで画像を拡大する。次に、CSSのblur(0.6px)を重ねて、ピクセル境界を視覚的にぼかす。

効果が見えなかったので、より高度なアルゴリズムに手を伸ばした。pica.jsのLanczos3リサンプリング。信号処理に基づく高品質な補間手法で、写真のリサイズでは定評がある。

それでも変わらない。ならばAIの力を借りよう。UpscalerJSを導入し、TensorFlow.jsの上でESRGAN(Enhanced Super-Resolution Generative Adversarial Network)を走らせた。デフォルトモデルで変わらなければ、より重いThickモデルを。それでも駄目なら、クラウドのAI——OpenAIのgpt-image-1やSoraを手動で試した。

さらに発想を変えた。表示側ではなく、アップスケーリングの前にソース画像にぼかしを加えてみる。逆に、アップスケーリング後にノイズを注入して格子パターンを散らしてみる。最後に、ゲームエミュレータで使われるhqxアルゴリズム——ピクセルアートの拡大に特化した手法——まで試した。

結果はすべて同じだった。「未処理(Raw)と変わらない」。

10の手段を試して差が出ないとき、問題は手段ではなく前提にある。

3. 横断的な観察

転機は、表示側の改善を諦めかけたとき、ふと行った横断的な観察だった。

同じ画像を、PCのChromeとSafariの両方でQRコードに変換し、それぞれのQRコードをスマートフォンで読み取って復元した。同じソース画像、同じコード、同じ変換ロジック。違うのはQRコードを生成したブラウザだけだ。

結果は明確だった。PC Chromeで生成したQRコードからは、格子のない滑らかな画像が復元される。PC Safariで生成したQRコードからは、格子が見える。スマートフォンのSafariでもChromeでも、格子が見える。

「表示側の問題」という仮説が崩れた瞬間だった。もし表示側の問題なら、同じQRコードを同じ端末で開いたときに差が出るはずだ。だが差が出たのは、QRコードを「生成した」ブラウザが異なるときだった。問題は表示ではなく、生成——つまりエンコード側にあった。

再現条件を絞り込む行為は、仮説を捨てる行為でもある。

4. 無言のフォールバック

生成されたQRコードの中身を調べた。Chrome で生成された画像は192×256ピクセル、2,134バイトのWebP。Safari で生成された画像は96×128ピクセル、2,124バイトのJPEG。

バイト数はほぼ同じだ。だが解像度は半分になっている。

WebPはJPEGに比べて約2倍の圧縮効率を持つ。同じバイト数なら、WebPのほうが2倍の解像度を格納できる。2,953バイトの上限の中で最大解像度を追求するこのシステムでは、WebPとJPEGの差は解像度の差に直結する。

コードを確認した。canvas.toBlob('image/webp', quality)——Canvas APIに対して、WebP形式での出力を明示的に指示している。Chromeはこの指示に従い、WebPを出力する。ではSafariはどうしたのか。

Safariは、この指示を黙って無視した。'image/webp'というMIMEタイプの指定を受け取りながら、エラーも警告も出さず、JPEGにフォールバックした。コンソールには何も表示されない。toBlob()のコールバックは正常に呼ばれ、Blobオブジェクトが返る。ただし、その中身はWebPではなくJPEGだ。

最も厄介なバグは、エラーを出さないバグだ。

これはSafariのバグではない。Canvas APIの仕様上、ブラウザは指定されたMIMEタイプをサポートしていない場合、image/pngにフォールバックすることが許されている。Safariの実装では、image/webpを指定するとJPEG(またはPNG)にフォールバックする。仕様に準拠した挙動であり、エラーにはならない。

だからこそ気づきにくかった。テストは通る。画像は表示される。「正しく動いている」ように見える。ただし解像度が半分になっている。

5. オフラインという制約

原因がわかれば、解決策は見える。ブラウザのCanvas APIがWebPをサポートしていないなら、自前でWebPエンコーダを持てばいい。

クラウドAPIという選択肢もあった。画像をサーバーに送り、サーバー側でWebPにエンコードして返す。技術的には最も簡単だ。だがTokiQRの設計原則は「サーバーに依存しない」こと。QRコードの生成も、音声のエンコードも、すべてブラウザ内で完結する。オフラインでも動く。この原則を画像エンコードだけ例外にすることはできない。

@jsquash/webpを見つけた。Googleのlibwebp——WebPの参照実装——をWebAssemblyにコンパイルしたライブラリだ。ブラウザ内でネイティブに近い速度でWebPエンコードを実行できる。

275KBのWASMファイルと、38KBのJavaScriptラッパーをローカルに配置した。CDNへの依存はない。ネットワークが切れていても動く。ブラウザがCanvas APIでWebPをサポートしているかどうかを起動時に検出し、サポートしていなければWASMエンコーダにフォールバックする。

Safariで画像をQRコードに変換した。192×256ピクセルのWebPが生成された。QRコードを読み取り、画像を表示した。格子が消えていた。

制約は解法を限定するが、同時に解法を純化する。

6. 探す場所を間違えること

振り返れば、10以上のPRは「表示側には問題がない」ことの証明だった。Canvas bicubic、Lanczos3、ESRGAN、hqx——どれを試しても未処理と変わらなかったのは、表示側のアルゴリズムが問題ではなかったからだ。問題はその手前、エンコードの段階で既に起きていた。

では、10以上のPRは無駄だったのか。

そうではない。表示側に問題がないことを実証的に確認したからこそ、「問題はエンコード側にあるのではないか」という仮説に確信を持てた。もし最初の2つのアプローチだけで諦めていたら、「もっと良いアップスケーリング手法があるはずだ」という未練が残っただろう。10の手段を尽くしたからこそ、視点を切り替える根拠が生まれた。

消去法は遠回りに見える。だが、確信をもって正解にたどり着く唯一の道でもある。

問題を見つけるとは、問題でない場所を確定させることだ。

7. 設計の断層

この経験は、以前書いた「機種依存・ブラウザ依存・組織依存の断層」と同じ構造を持っている。あのエッセイでは、Safariの印刷エンジンがoverflow: hiddenを無視する問題を扱った。今回は、Safariの Canvas APIが'image/webp'を無視する問題だ。

どちらも、ブラウザが機能を「サポートしている」ことと「正しく動く」ことの間に横たわる溝だ。canvas.toBlob()はSafariでも動く。MIMEタイプの引数も受け付ける。エラーは出ない。ただし、指定した形式では出力されない。

この種の断層は、テストケースに書きにくい。toBlob()が呼ばれ、Blobが返り、画像が表示される——すべてのアサーションが通過する。「壊れている」のではなく「足りない」。エラーではなくフォールバック。だから正常系として素通りしてしまう。

印刷の断層では、「すべてのブラウザで同じ印刷体験を提供する」という前提を捨てた。今回は、「ブラウザのCanvas APIがWebPをサポートしている」という前提を捨て、自前のエンコーダで補った。どちらも、前提を疑い、境界を認めることから解決が始まっている。

8. 誰が気づけるのか

この問題を、AIだけで解決できただろうか。

実のところ、10のアプローチのうちほとんどは、AIが提案し、AIが実装した。Canvas bicubic、pica.js、ESRGAN、hqx——コードを書く手としてのAIは、極めて有能だった。だがすべてのアプローチが「表示側の改善」という同じ前提の上に立っていた。AIに「格子パターンを直して」と依頼すれば、AIは表示側の改善を提案し続ける。問いの枠組みが間違っていれば、どれほど優秀な回答者でも正解にはたどり着けない。

転機をもたらしたのは、ブラウザを並べて実機で観察するという、きわめて人間的な行為だった。ChromeとSafariを横に並べ、PCとスマートフォンを見比べ、「同じコードなのに結果が違う」という違和感を拾い上げた。この横断的な観察は、誰かに指示されたわけではない。98%正常に動いているシステムの残り2%に引っかかり、立ち止まった結果だ。

AIは「聞かれたことに答える」のは得意だ。
だが「何を聞くべきか」を発見するのは、まだ人間の仕事だ。

では、この「気づき」は、エンジニアだけの特権だろうか。

そうは思わない。今回の転機は「同じものを違う条件で並べて見た」ことだった。料理人が味の違いに気づく。デザイナーが1ピクセルのズレに気づく。営業が顧客の声色の変化に気づく。領域は異なっても、「期待値との差分を感じ取る」という行為の構造は同じだ。

差をつくるのは、おそらく二つの性質だ。一つは観察の解像度。同じ画面を見ても「格子がある」で止まる人と、「PCでは出ないのにスマホでは出る」まで分解する人がいる。これは才能というよりも、「なぜ?」をもう一回問う習慣だ。もう一つは、違和感を放置しない姿勢。98%動いていたら「まぁいいか」で流すのが普通だ。残り2%を掘り返すのは、几帳面さや執着に近い。

AIが進歩すれば、ブラウザを自動で操作し、出力を横断的に比較するテストは書けるようになるだろう。だが「何をテストすべきか」——どの差異が問題で、どの差異は許容範囲か——を判断するには、期待値を持っている人間が要る。仕様書のどこにも「ここを確認しろ」とは書いていない問題を発見するのは、正常系との微かな差分に引っかかる感覚だ。

そもそも、TokiQRが存在するのは「QRコードに声を残せるだろうか?」という問いを誰かが立てたからだ。「声を1000年残せるだろうか?」という問いを立てたからだ。その問いに対して、Codec2の450bpsモードを見つけ出し、WASMにコンパイルし、2,953バイトの制約に収まるように実装したのはAIだ。だが問いそのものは、人間が立てた。

AIがどれほど進歩しても、この構造は変わらないだろう。問いが立てば、AIは驚くべき精度と速度でその問いに伴走する。10のアプローチを次々と実装し、WASMエンコーダを組み込み、275KBのバイナリをオフラインで動かす仕組みを構築する。だが「そもそも何を問うべきか」——QRに声を閉じ込めたいという欲求、格子が気になるという違和感、98%の正常の中の2%の異変——これらは人間の内側から生まれる。

道具が賢くなるほど、問いの価値が上がる。
問いさえ正しければ、AIは最良の伴走者になる。
だが問いを立てるのは、いつも人間だ。
目に見える症状を追いかけ続けた先に、見えない原因があった。表示ではなくエンコード、エラーではなくフォールバック、壊れているのではなく足りない。断層は常に、見えにくい場所に横たわっている。そしてその断層に最初に気づくのは、技術ではなく、違和感を放置しない人間の目だ。AIは優れた伴走者だが、伴走する先を決めるのは、問いを立てた人間だ。

探す場所を変える勇気が、10のPRより価値がある。
だが10のPRがなければ、場所を変える根拠もなかった。
そして「ここは違う」と感じ取る目がなければ、場所を変えようとも思わなかった。
問いを立てること——それが、人間の側に残る最も重要な仕事だ。