ブラウザだけでHEICをPNGに変換する方法(heic-to × WebAssembly)

概要

今回は、ブラウザだけでHEICをPNGに変換する方法について、自作ツールに実際に組み込んだ実装を交えて紹介していきます。
きっかけは、自分で作っているブラウザ完結型の背景透過ツール「とうかねっこ」で、ある困りごとに突き当たったことでした。

iPhoneで撮った写真を読み込ませようとすると、new Image() のところで失敗して、画像がまったく開けないのです。
原因はファイル形式。
iPhoneは標準でHEIC(HEIF)という形式で写真を保存していて、これがChromeやFirefoxではそのまま開けません。
かといって、このツールは画像を一切サーバーに送らないクライアント完結が売りなので、サーバー側で変換する選択肢も取れませんでした。

そこで採用したのが、heic-to というライブラリをWebAssembly(WASM)で動かして、ブラウザの中だけでHEICをPNGに変換するという方法です。
この記事では、なぜブラウザでHEICが開けないのか、ライブラリに何を選びどう組み込んだのか、そして実地でハマった「最後の1割」まで、判断の理由ごと書いていきます。

それではやっていきましょう!

とうかねっこの画像選択画面。ここにHEICを渡せるようにしたい
とうかねっこの画像選択画面。画像をドロップまたは選択する入口
この記事は自作ツールへの組み込みを題材にしていますが、考え方(検出 → 変換 → 既存フローへ合流)はどんなWebアプリにもそのまま使えちゃいます!
バージョンは記事執筆時点でheic-to v1.5.2 / libheif 1.22.xです。

目次

なぜブラウザはHEICをそのまま開けないのか

まず大前提として、HEICをそのまま <img> で表示できるブラウザはSafari 17以降だけです(macOS Sonoma / iOS 17以降)。
Chrome・Firefox・Edgeは、執筆時点でもHEICのネイティブデコードに対応していません。

理由はざっくり言うと、HEICの中身がHEVCというコーデックで圧縮されていて、そのライセンス事情などから主要ブラウザがデコーダを積んでいないためです。
Safariだけ開けるのは、OS側のHEICサポートに乗っかっているからですね。

なので、new Image() + URL.createObjectURL(blob) で読み込んでCanvasに描く、という普通の読み込み線がHEICだけ通らない。
これがこの問題の核心でした(- -;

このツールの制約(設計の前提)

解決策を考える前に、このツールが背負っている設計の前提を整理しておきます。
ここがそのまま「取れる手段」を縛るからです。

  • ノービルド(vanilla JS)
    • バンドラを使わず素のJavaScriptで書く方針。重いライブラリを常時読み込ませたくない
  • 画像はサーバーに送らない
    • プライバシー重視で、変換も含めてすべて端末内で完結させる。これは絶対に崩せない根幹
  • Cloudflare Pagesで静的配信
    • サーバーサイドの処理を持たない。つまり「サーバーでHEICを変換」は最初から選択肢に無い

サーバー変換が使えない以上、ブラウザの中だけでHEICをデコードしきるしかありません。
そして、ブラウザ内でHEICを開く現実解は、結局1つに収束します。

解決策:libheifをWASMで動かす

ブラウザ内でHEICをデコードする、と言ったときの実体は、ほぼ例外なくC言語の libheif をEmscriptenでWebAssembly化したものです。
heic2any / heic-to / libheif-js など主要ライブラリはどれも、中身はlibheif。
つまり選択肢は「libheifを直接使うか、薄いラッパー経由で使うか」の違いに過ぎず、どれを選んでもLGPLは共通でついてきます(ここは後述します)。

ラッパーを使う意味は、isHeic() のようなヘルパーや、wasmの同梱・読み込みまわりの面倒を肩代わりしてくれるところにあります。
自分でwasmをビルド・保守したくないなら、メンテされているラッパーに乗るのが現実的です。

heic-toを選んだ理由

候補は heic2anyheic-to の2つでした。
機能としてはどちらもHEIC→PNG/JPEGができて同等です。
ただ、決め手はメンテナンスが継続しているかでした。

観点 heic2any heic-to(採用)
中身 libheif(LGPL) libheif(LGPL)
メンテ状況 12ヶ月以上リリース停止 積極的にメンテ中
libheifの追従 古いまま 新版に追従(1.22.x)
配布形態 単一IIFE・wasm内包
実務ヘルパ isHeic() 等あり / CSP対応ビルドも

heic2any はラッパー部分こそMITですが、リリースが1年以上止まっていて、同梱されるlibheifが古いままになります。
画像パーサは過去にCVE実績がある領域なので、上流のセキュリティ修正を取り込めないのは地味に怖いですよね。

その点 heic-to は積極的にメンテされていて、libheifの新版に追従しています。
さらに単一のIIFEファイルにwasmまで内包しているので、別の .wasm ファイルをホストする必要がなく、オフラインでも動きます。

ポイントは、これは「libheifを避けた」のではなく、libheifを最小コストで安全に使い続けるための選択だということです。
「自分でwasmをビルド・保守したくない、でも上流のセキュリティ修正は受け取りたい」——このわがままを両立してくれるのがheic-toでした。

約3MBを「必要なときだけ」読む(遅延読込)

heic-toは約3MB(wasmをJSに内包しているぶん大きい)あります。
これを全ユーザーに常時ロードさせるのは、さすがに過剰です。
だって、HEICを使わずJPG/PNGを読み込むだけの人が大半ですから。

そこでHTMLには重いライブラリ本体を書かず、HEICが選ばれた瞬間にだけ <script> を動的注入する軽量ローダ js/heic.js を用意しました。
処理の流れはこんなイメージです。

HEICを選んだときだけ前段に変換を挟む処理フロー(通常画像は変換をスキップ)
画像をドロップしてisHeic判定し、HEICのときだけheic-toを遅延読込してWASMでPNGに変換し、既存のnew Image読み込みへ合流する処理フロー図

肝は ensureLoaded() で、一度だけ注入してPromiseをメモ化します。
何度HEICを読み込んでも、ライブラリの取得は最初の1回きりです。

js/heic.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var LIB_SRC = "js/vendor/heic-to.js"; // IIFE 版。グローバル HeicTo を公開
var loadPromise = null; // 多重注入防止のメモ化

function ensureLoaded() {
if (loadPromise) return loadPromise;
loadPromise = new Promise(function (resolve, reject) {
if (global.HeicTo) { resolve(global.HeicTo); return; }
var s = document.createElement("script");
s.src = LIB_SRC;
s.async = true;
s.onload = function () {
if (global.HeicTo) resolve(global.HeicTo);
else reject(new Error("HeicTo not available after load"));
};
s.onerror = function () {
loadPromise = null; // 次回リトライ可能に
reject(new Error("failed to load " + LIB_SRC));
};
document.head.appendChild(s);
});
return loadPromise;
}

onerrorloadPromisenull に戻しているのは、読み込みに失敗したとき次回リトライできるようにするためです。
効果としては、通常のJPG/PNG利用では js/vendor/heic-to.js へのリクエストが一切発生しません
「HEICを選ぶまでロード0」はE2Eでも検証しています。

実装:検出 → 変換 → 既存フローへ合流

組み込みは3ステップです。
HEICかどうかを判定し、HEICならPNGに変換し、あとは既存の読み込み線にそのまま合流させる。
既存コードへの傷を最小化したかったので、HEICのときだけ前段に変換を1枚挟む形にしました。

HEIC検出は「MIME + 拡張子」の二段

最初につまずきやすいのがここです。
File.type(MIME)だけ見て判定すると、HEICを取りこぼすことがあります。

js/heic.js

1
2
3
4
5
6
7
8
/** ファイルが HEIC/HEIF か(type は空のことがあるため拡張子も見る)。 */
function isHeic(file) {
if (!file) return false;
var type = (file.type || "").toLowerCase();
if (type === "image/heic" || type === "image/heif") return true;
var name = (file.name || "").toLowerCase();
return /\.(heic|heif)$/.test(name);
}
ドラッグ&ドロップや一部のOSでは `File.type` が空文字になることがあります。 MIMEだけで判定すると、その場合にHEICを見逃します。 `image/heic` / `image/heif` または拡張子 `.heic` / `.heif` の二段で見るのが安全です。

この二段判定にしておくと、「ファイル選択」「ドラッグ&ドロップ」のどちらの入口からでも、安定してHEICを拾えます。

変換はタイムアウト付きでPNGへ

判定でHEICだとわかったら、PNGに変換します。
ここで重要なのがタイムアウトを必ず入れることです。
端末が遅かったり、特殊なHEICだったりで万一ハングしても、無限待ちにならないようにします。

js/heic.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var CONVERT_TIMEOUT_MS = 90000; // 端末が遅い/巨大画像でも余裕を持たせつつ、無限待ちは避ける

function toPngBlob(file) {
return ensureLoaded().then(function (HeicTo) {
var convert = Promise.resolve().then(function () {
return HeicTo({ blob: file, type: "image/png" });
});
var guard = new Promise(function (_resolve, reject) {
setTimeout(function () { reject(new Error("HEIC conversion timed out")); }, CONVERT_TIMEOUT_MS);
});
return Promise.race([convert, guard]); // 変換 or タイムアウトの早い方
}).catch(function (err) {
if (global.console && console.error) console.error("[HeicSupport] 変換失敗:", err);
throw err;
});
}

Promise.race で「変換」と「90秒タイマー」を競わせて、早いほうを採用します。
失敗時には console.error を残しておくと、特定のHEICで失敗したときに原因を追いやすくなります。

変換後はこんなふうに編集画面へ画像が載ります。

変換後のPNGが編集画面に読み込まれた状態(画像はサンプルの白猫イラスト)
HEICから変換したPNGが透過編集の編集画面に読み込まれた状態

ちなみに実測では、4032×3024のHEICが約3秒でPNG化できました(PNGで約2.2MB)。
ただしこれはデスクトップのChromiumで測った一例で、端末・画像サイズ・環境でかなり変わります。
スマホの実機ではもっと遅くなることもあるので、数字は目安として捉えてください。

入口2か所を共通ヘルパに寄せる

このツールには画像の入口が2つあります。
画像選択画面(start)と、編集画面でのドラッグ&ドロップ差し替え(app)です。
どちらも「HEICなら変換してから既存処理へ」という同じ共通ヘルパ HeicSupport.toPngBlob() に寄せました。

js/start.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function handleFile(file) {
var isHeic = window.HeicSupport && HeicSupport.isHeic(file);
if (!file || !(file.type.startsWith("image/") || isHeic)) {
toast("画像ファイルを選んでください");
return;
}
// HEIC はブラウザが直接デコードできないため PNG に変換してから渡す。
// 変換は数秒かかりうるため、完了/失敗まで消えない表示にする。
var prepared = isHeic
? (toast("HEIC を変換中…(数秒かかることがあります)", true), HeicSupport.toPngBlob(file))
: Promise.resolve(file);
prepared
.then(function (blob) { return Handoff.setFile(blob); }) // 変換後 PNG を編集画面へ受け渡し
.then(goEditor)
.catch(function () {
toast(isHeic ? "HEIC の変換に失敗しました" : "画像の読み込みに失敗しました");
});
}

通常画像は Promise.resolve(file) でそのまま素通り、HEICのときだけ toPngBlob() を挟む。
start側は変換後のPNG BlobをIndexedDB経由で編集画面へ受け渡し、app側は変換後のPNGを new Image() で読み込みます。
既存の受け渡し機構・読み込み線をそのまま流用できるので、HEIC対応のための分岐は本当に「前段に1枚」だけで済みました。

なぜPNGに変換するのか

変換先をPNGにしたのには、はっきりした理由があります。
このツールの本質は背景透過=縁のピクセル精度が品質を左右するところにあります。

ここでJPEGに変換してしまうと、再圧縮で縁がにじんで劣化しうる。
なので、可逆なPNGに変換してから編集に渡しています。

出力対応はあくまでHEIC→PNGの入力変換です。 HEICで「保存」する機能ではありません(HEICは読み込みにだけ対応)。 最終的な書き出しは、背景を透過したPNGになります。
背景を透過してPNG保存できるところまで一気通貫(画像はサンプルの白猫イラスト)
HEICから読み込んだ画像の背景を透過し、PNGとして保存できる完了画面

LGPL-3.0とどう向き合ったか

ここはこの記事の見せ場のひとつです。
HEIC対応にlibheifは不可避なので、LGPL-3.0は必ずついてきます
これは「悪いこと」ではなく、満たすべき要件として淡々と向き合えばいいだけです。

LGPLの “Lesser” たる所以は、条件を守れば自前コードにライセンスを伝播させずに使えるところにあります。
具体的にはこうしました。

  • ライブラリを改変しない
    • 配布されたheic-toをそのまま使う。手を入れると話がややこしくなる
  • 独立した単一ファイルとして同梱
    • js/vendor/heic-to.js として切り離しておき、自分のアプリコードと混ぜない
  • 動的読込で利用する
    • 利用者がライブラリを差し替えられる状態を保つ。これがLGPLの肝

これで、自分のアプリコードは従来どおり非公開のままでも合法に使えます。
そのうえで、頒布側の義務として次のことをやりました。

  • ライセンス全文を無改変で同梱・配信
    • /js/vendor/heic-to.LICENSE.txt を200で配信する
  • エンドユーザーが見られる告知を用意
    • 免責ページの「オープンソースソフトウェア」節に、出典・LGPL・全文リンクを明示
  • 出典(GitHub)リンクを明示
    • heic-to / libheifのリポジトリへのリンクを置く
配信ページ上のOSS告知。出典・LGPL・ライセンス全文リンクをエンドユーザーに見せる
免責ページのオープンソースソフトウェア節。heic-toの著作権表記・LGPL-3.0・ソースコードとライセンス全文へのリンク

見落としがちなのは、heic-toは訪問者のブラウザにダウンロードされる=「頒布」にあたるという点です。
リポジトリ内のREADMEに書いておくだけでは不足で、配信ページ上でエンドユーザーに告知する必要があります。
「LGPLライブラリをWebで配る=ユーザーにバイナリを頒布している」という意識が、ここでの要点でした。

実地で出た「最後の1割」:トーストが消えるバグ

最後に、実装の正しさとUXの正しさは別物だ、と痛感した失敗談を1つ。

ドラッグ&ドロップでHEICを入れると、「HEICを変換中…」と出たあと、何も起きないように見える症状が出ました。

「HEICを変換中…」の表示。旧実装ではこれが1.9秒で消えていた
HEICを変換中というトースト通知が表示されている画面

調べてみると、ドロップ処理のコード自体は正しくて、合成ドロップ+実機サイズ(4032×3024)でも約3秒できちんと成功していました。
犯人は表示のほうだったのです。

「変換中」トーストが1.9秒で自動的に消える実装になっていて、実機HEICの変換が数秒かかる間にトーストが先に消えてしまう。
だから「無反応」に見えていた、というオチでした(- -;

対処は4つです。

  • 変換中トーストをstickyに
    • 完了/失敗まで消さない。sticky=true の間は自動で消さないようにした
  • 90秒タイムアウトを追加
    • 万一ハングしても、必ずエラー表示に落ちるようにする
  • 変換後の new Image()onerror を追加
    • 読み込み失敗も無反応にせず、ちゃんと拾う
  • 失敗時に console.error を出す
    • 特定のHEICで失敗したとき、原因を追えるようにする

stickyトーストの切り替えはこんな実装です。

js/start.js

1
2
3
4
5
6
7
8
9
10
var toastTimer;
// sticky=true の間は自動で消さない(HEIC 変換など数秒かかる処理の進捗表示用)。
function toast(msg, sticky) {
var t = $("#toast");
if (!t) return;
t.textContent = msg;
t.classList.add("show");
clearTimeout(toastTimer);
if (!sticky) toastTimer = setTimeout(function () { t.classList.remove("show"); }, 1900);
}

編集画面側でも、変換後の new Image()onerror を足して、読み込み失敗を取りこぼさないようにしています。

js/app.js

1
2
3
4
5
6
7
8
prepared.then((blob) => {
const img = new Image();
img.onload = () => { loadFromImage(img); toast("画像を読み込みました"); };
img.onerror = () => { toast("画像の読み込みに失敗しました"); }; // 変換後の読込失敗も拾う
img.src = URL.createObjectURL(blob);
}).catch(() => {
toast(isHeic ? "HEIC の変換に失敗しました" : "画像の読み込みに失敗しました");
});

教訓は、非同期な重い処理は「進捗が見えること」まで含めて初めて完成するということ。
コードが正しく動いていても、ユーザーに伝わらなければ「壊れている」のと同じなんですよね。

どんなHEICでも必ず変換成功する、とは言い切れません。 特殊なHEIC(HDRや特定のカラープロファイル等)では失敗しうるため、失敗時はエラー表示+コンソールにログが出る設計にしてあります。 「必ず成功」を前提にUIを組まないのが安全です。

まとめ

ブラウザだけでHEICをPNGに変換する、という今回の対応を振り返ると、勘所は次のあたりに集約されます。

  • 現実解はlibheifをWASMで動かすこと
    • heic-toを選んだのは、メンテ継続=上流のセキュリティ修正に追従できるから
  • 重いライブラリは必要なときだけ読む
    • 約3MBを遅延読込にして、通常利用には一切負担をかけない
  • HEICのときだけ前段に変換を1枚挟む
    • 検出は「MIME + 拡張子」の二段、変換はタイムアウト付き、あとは既存フローへ合流
  • LGPLとUXは“最後の1割”
    • Webで配る=頒布の意識、そして進捗が見えるまでが実装の完成

クライアント完結のまま、HEIC対応は十分に実用的です。
画像をサーバーに送らないという根幹を守ったまま、iPhoneの写真もちゃんと扱えるようになりました。

同じようにWebでHEICを扱いたい場面は意外と多いと思うので、ぜひ参考にしてみてください。
それではやっていきましょう(^^

採用したライブラリ(heic-to)
github.comhttps://github.com/hoppergee/heic-to

HEICデコードの本体(libheif・LGPL)
github.comhttps://github.com/strukturag/libheif