在瀏覽器轉換 HEIC,可以用 WebAssembly 解出像素,再用 Canvas 編碼成 JPEG。這條路徑的影像處理可在本機完成;WASM 程式與網站資源仍可能需要下載。
我參與開發圖片工具 Morphio。專案的本機轉換程式使用 libheif 解碼,像素交給 Canvas 產生輸出檔。以下說明這條路徑需要處理的資料與資源。
HEIF 是容器格式,HEIC 常見的壓縮資料使用 HEVC。讀到 .heic 副檔名之後,還需要解析容器、找到圖像項目,並確認編譯產物包含對應的解碼器。
匯出單張 JPEG 的流程如下:
File.arrayBuffer()
→ Worker 內的 libheif WASM
→ 選定主圖,完成解碼與必要的圖像變換
→ 8-bit RGBA 像素
→ 合成 JPEG 背景色
→ Canvas.toBlob("image/jpeg")
→ Blob
libheif 提供容器與圖像處理能力,HEVC 解碼可搭配 libde265。用 Emscripten 建立只做 HEIC 解碼的 WASM 產物時,可以按需求關閉其他編解碼器;後面的 JPEG 編碼由瀏覽器完成,這條路徑不需要另外把 HEVC 編碼器編進去。發佈產物時也要一併處理所用函式庫的授權要求。
使用現成的 libheif-js 時,要核對實際入口。套件文件 將純 JavaScript、外部 WASM 和內嵌 WASM 的入口分開。部署前應核對實際載入的產物。JS 綁定與 .wasm 應固定為配套版本,並等待模組初始化完成後才接收解碼工作。
HEIC 檔案可能包含多張頂層圖、縮圖、透明輔助圖,或需要組合的分塊。若需求是匯出主圖,先使用容器的主圖標記;遇到無法取得主圖的情況,再按明確規則回退並記錄結果。直接取陣列第一項,可能選到另一張頂層圖。
以 libheif 1.23.1 的 JS 綁定為例,HeifDecoder.decode() 讀取容器並取得頂層圖像句柄,選中的 HeifImage.display() 再要求 RGBA 像素。這條呼叫路徑先取得圖像句柄,再解出像素;兩個方法並未各自完整解碼一次。呼叫順序可查閱上游的固定版本綁定。
旋轉、鏡像與裁切應沿用解碼器對容器屬性的處理順序。取得已套用變換的像素後,若又依 EXIF 方向轉一次,圖片可能被重複旋轉。90 度旋轉與鏡像組合都應有測試樣本。
8-bit RGBA 每個像素有四個位元組。一張 4032 × 3024 的照片,只算一份緊密排列的 RGBA 就需要 48,771,072 bytes,約 46.5 MiB;另外還有解碼器、Canvas 和中間緩衝區的開銷。壓縮檔只有幾 MB,不能據此推算解碼時也只需要幾 MB。
原生介面可能回傳行跨度 stride,它不一定等於 width * 4。交給 ImageData 前,應逐行取出有效像素,排除行尾填充。若 JS 包裝層已完成這件事,就沿用其緊密排列結果。
JS 若借用 WASM 記憶體視圖,必須保留底層結果,並在記憶體成長後按介面要求重新取得視圖。結果釋放後就不可繼續讀取。跨 Worker 傳遞時,可先建立獨立的 ArrayBuffer,再透過 transfer list 交出所有權;傳出後,原端的該緩衝區會失效。因此要分開記錄建立獨立緩衝區的複製成本,以及跨執行緒轉移所有權的成本。
下面的函式接收已解碼的像素。輸入必須是完成方向處理、緊密排列、非預乘 alpha 的 8-bit RGBA;色彩解讀採 sRGB。它在主執行緒使用 Canvas,沒有包含 HEIC 解碼與 Worker 建立。
async function rgbaToJpeg(rgba, width, height, quality = 0.9) {
const length = width * height * 4;
if (!Number.isSafeInteger(width) || width <= 0 ||
!Number.isSafeInteger(height) || height <= 0 ||
!Number.isSafeInteger(length) ||
!(rgba instanceof Uint8ClampedArray) || rgba.length !== length) {
throw new Error("Invalid RGBA image");
}
if (!Number.isFinite(quality) || quality < 0 || quality > 1) {
throw new Error("Invalid JPEG quality");
}
const pixels = new Uint8ClampedArray(rgba);
for (let i = 0; i < pixels.length; i += 4) {
const a = pixels[i + 3] / 255;
for (let c = 0; c < 3; c++) {
pixels[i + c] = Math.round(pixels[i + c] * a + 255 * (1 - a));
}
pixels[i + 3] = 255;
}
const canvas = document.createElement("canvas");
try {
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Canvas 2D is unavailable");
ctx.putImageData(new ImageData(pixels, width, height), 0, 0);
const blob = await new Promise((resolve) => {
canvas.toBlob(resolve, "image/jpeg", quality);
});
if (!blob || blob.type !== "image/jpeg") {
throw new Error("JPEG encoding failed");
}
return blob;
} finally {
canvas.width = 0;
canvas.height = 0;
}
}
JPEG 不保留透明通道,這裡先在副本上合成白底,避免改動解碼結果。範例直接對 sRGB 通道數值混色,適合說明一般匯出流程;需要線性光混色的影像流程,應另外進行色彩轉換。
toBlob() 使用回呼且可能回傳 null,所以包成 Promise 後檢查結果。輸出 MIME 也要核對,瀏覽器遇到不支援的格式時可能改用 PNG。具體行為見 MDN 的 toBlob 文件。
這段程式會增加一份像素副本。在允許修改輸入的介面裡,可以把白底合成移到已接管的緩衝區;共用給預覽或其他輸出的像素則應保留。PNG 輸出則可保留 alpha,跳過白底合成。
Worker 可以把解碼工作移出主執行緒;批次轉換仍需限制同時解碼的數量。可以先從單 Worker 的佇列開始,測量目標裝置的峰值記憶體,再決定是否增加並行度。模組可在第一次轉換時載入,之後由同一 Worker 重用已初始化的實例。
每張圖完成或失敗後,依綁定介面的所有權規則釋放句柄、上下文與暫存結果;JS 變數離開作用域不代表原生配置已回收。WASM 堆容量停在高水位,也不能單憑這個現象判定洩漏。
驗證至少應涵蓋普通照片、旋轉與鏡像、多頂層圖、全景分塊、透明圖和截斷檔案。除了確認能下載,還要比對輸出尺寸、方向、背景色與 MIME,並連續轉換多張圖觀察資源持有情況。
經過 8-bit RGBA 再編碼的 JPEG,沒有自動保留來源的完整 EXIF、ICC、HDR 與高位深資訊。若產品需要這些能力,應各自定義輸出策略與驗證樣本。