iT邦幫忙

0

在瀏覽器用 WebAssembly 轉換 HEIC:從解碼到 JPEG 匯出

  • 分享至 

  • xImage
  •  

在瀏覽器轉換 HEIC,可以用 WebAssembly 解出像素,再用 Canvas 編碼成 JPEG。這條路徑的影像處理可在本機完成;WASM 程式與網站資源仍可能需要下載。

我參與開發圖片工具 Morphio。專案的本機轉換程式使用 libheif 解碼,像素交給 Canvas 產生輸出檔。以下說明這條路徑需要處理的資料與資源。

HEIC 到 JPEG 的資料路徑

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 編碼器編進去。發佈產物時也要一併處理所用函式庫的授權要求。

載入 WASM 後,先確定要解哪張圖

使用現成的 libheif-js 時,要核對實際入口。套件文件 將純 JavaScript、外部 WASM 和內嵌 WASM 的入口分開。部署前應核對實際載入的產物。JS 綁定與 .wasm 應固定為配套版本,並等待模組初始化完成後才接收解碼工作。

HEIC 檔案可能包含多張頂層圖、縮圖、透明輔助圖,或需要組合的分塊。若需求是匯出主圖,先使用容器的主圖標記;遇到無法取得主圖的情況,再按明確規則回退並記錄結果。直接取陣列第一項,可能選到另一張頂層圖。

以 libheif 1.23.1 的 JS 綁定為例,HeifDecoder.decode() 讀取容器並取得頂層圖像句柄,選中的 HeifImage.display() 再要求 RGBA 像素。這條呼叫路徑先取得圖像句柄,再解出像素;兩個方法並未各自完整解碼一次。呼叫順序可查閱上游的固定版本綁定。

旋轉、鏡像與裁切應沿用解碼器對容器屬性的處理順序。取得已套用變換的像素後,若又依 EXIF 方向轉一次,圖片可能被重複旋轉。90 度旋轉與鏡像組合都應有測試樣本。

RGBA 的大小、行跨度與生命週期

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 交出所有權;傳出後,原端的該緩衝區會失效。因此要分開記錄建立獨立緩衝區的複製成本,以及跨執行緒轉移所有權的成本。

把 RGBA 編碼成 JPEG

下面的函式接收已解碼的像素。輸入必須是完成方向處理、緊密排列、非預乘 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 與高位深資訊。若產品需要這些能力,應各自定義輸出策略與驗證樣本。


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言