上一篇介紹了 tilesplit CLI,一個用 Rust 寫的工具,可以在切割圖片的同時保留 Ultra HDR 資訊。但要用 CLI 切個照片還得先裝 Rust 工具鏈… 不如直接在瀏覽器裡搞定?

這篇就來聊聊怎麼把 tilesplit 移植到 WebAssembly,讓任何人打開網頁就能用。

先玩再說

使用方式:拖一張 JPEG 進去(支援 3:2 或 16:10 比例),調整品質後按 Split。如果是 Ultra HDR 照片,會自動偵測並顯示 HDR 標籤。切完的圖可以直接下載。

從 CLI 到 WASM:關鍵差異

tilesplit 搬到瀏覽器不是改個編譯目標就完事的。最大的挑戰在於依賴庫的替換

CLI 版本用了兩個 C/C++ FFI 的 crate:

  • ultrahdr-rs:Google libultrahdr 的封裝,處理 Ultra HDR 的編解碼和容器組裝
  • jpegli-rs:Google jpegli 的封裝,高品質 JPEG 編解碼器,支援讀寫 Gain Map

問題是:WASM 沒辦法呼叫 C FFI。這兩個 crate 都需要編譯 C/C++ 原始碼,在 wasm32-unknown-unknown 目標下根本編不過。

WASM 版本的替代方案:

功能 CLI 版本 WASM 版本
JPEG 編解碼 jpegli-rs (C FFI) image crate (純 Rust)
Ultra HDR metadata ultrahdr-rs (C FFI) ultrahdr-core (純 Rust)
容器組裝 ultrahdr-rsadd_gainmap() 手動組裝 MPF 格式

ultrahdr-core 是個輕量的純 Rust crate,只做 metadata 解析和 XMP 生成,不碰任何 C 程式碼——正好適合 WASM。而 image crate 的 JPEG 功能雖然沒有 jpegli 那麼強,但對我們的需求來說夠用了。

最麻煩的是容器組裝。CLI 版本靠 jpegli-rs.add_gainmap() 一行搞定,但 WASM 版本得自己手動把 XMP marker、MPF header、Gain Map JPEG 按照正確的格式塞回主圖裡。這部分後面會詳細說明。

WASM API 設計

WASM 模組只暴露三個函式給 JavaScript:

#[wasm_bindgen]
pub fn validate_image(data: &[u8]) -> Result<JsValue, JsValue> {
    console_error_panic_hook::set_once();
    let img = image::load_from_memory(data)
        .map_err(|e| JsValue::from_str(&format!("Failed to decode image: {e}")))?;

    let (width, height) = (img.width(), img.height());
    let is_ultra_hdr = detect_ultrahdr(data).is_some();
    let (left, _) = compute_split_rectangles(width, height)
        .map_err(|e| JsValue::from_str(&e))?;

    let info = ImageInfo { width, height, /* ... */ };
    serde_wasm_bindgen::to_value(&info)
        .map_err(|e| JsValue::from_str(&format!("Serialization error: {e}")))
}

#[wasm_bindgen]
pub fn split_left(data: &[u8], quality: u8) -> Result<Vec<u8>, JsValue> {
    console_error_panic_hook::set_once();
    split_tile(data, quality, Side::Left).map_err(|e| JsValue::from_str(&e))
}

#[wasm_bindgen]
pub fn split_right(data: &[u8], quality: u8) -> Result<Vec<u8>, JsValue> {
    console_error_panic_hook::set_once();
    split_tile(data, quality, Side::Right).map_err(|e| JsValue::from_str(&e))
}

回傳給 JS 的 ImageInfoserde 序列化成 JS object:

#[derive(Serialize)]
struct ImageInfo {
    width: u32,
    height: u32,
    aspect: String,
    #[serde(rename = "isUltraHdr")]
    is_ultra_hdr: bool,
    #[serde(rename = "tileWidth")]
    tile_width: u32,
    #[serde(rename = "tileHeight")]
    tile_height: u32,
}

資料流很直覺:JS 透過 FileReader 讀取使用者選的檔案,轉成 Uint8Array 傳進 WASM,WASM 處理完回傳 Vec<u8>(自動轉成 Uint8Array),JS 再包成 Blob URL 給 <img> 顯示和下載。

Ultra HDR 偵測

WASM 版本的 Ultra HDR 偵測是直接解析 JPEG 的二進制結構,不靠任何外部庫的 JPEG 解碼器:

fn detect_ultrahdr(data: &[u8]) -> Option<UltraHdrData> {
    // 1. 從 JPEG 的 APP1 marker 裡撈出 XMP metadata
    let xmp = extract_xmp_from_jpeg_bytes(data)?;
    // 2. 解析 XMP 得到 GainMapMetadata
    let (mut metadata, _) = ultrahdr_core::metadata::xmp::parse_xmp(&xmp).ok()?;
    // 3. 從 MPF (Multi-Picture Format) 裡提取 Gain Map JPEG
    let gainmap_jpeg = extract_gainmap_from_mpf(data)?;

    // 主圖的 metadata 可能不完整,試試從 Gain Map 自己的 XMP 補齊
    if metadata_looks_default_or_incomplete(&metadata) {
        if let Some(gm_xmp) = extract_xmp_from_jpeg_bytes(&gainmap_jpeg) {
            if let Ok((mut gm_meta, _)) = ultrahdr_core::metadata::xmp::parse_xmp(&gm_xmp) {
                apply_lenient_xmp_overrides(&gm_xmp, &mut gm_meta);
                if !metadata_looks_default_or_incomplete(&gm_meta) {
                    metadata = gm_meta;
                }
            }
        }
    }

    apply_lenient_xmp_overrides(&xmp, &mut metadata);
    Some(UltraHdrData { metadata, gainmap_jpeg })
}

跟 CLI 版本比起來,最大的差異是 XMP 提取。CLI 版本靠 jpegli 解碼器順便把 XMP 和 Gain Map 從 JPEG extras 裡讀出來。WASM 版本沒有 jpegli,只好自己掃描 JPEG marker——找 0xFFE1 (APP1) 拿 XMP,再用 ultrahdr-core 的 MPF parser 找 Gain Map 的位置。

手動組裝 Ultra HDR 容器

這是 WASM 移植裡最複雜的部分。CLI 版本一行 .add_gainmap() 就搞定的事,在 WASM 版本得手動處理 MPF (Multi-Picture Format) 的二進制結構。

一張 Ultra HDR JPEG 的結構大概長這樣:

┌─────────────────────────────────┐
│ SOI (0xFFD8)                    │
│ APP1 - XMP metadata             │  ← 告訴系統「我是 Ultra HDR」
│ APP2 - MPF header               │  ← 告訴系統「Gain Map 在哪裡」
│ ... 正常的 JPEG 資料 ...         │
│ EOI (0xFFD9)                    │
├─────────────────────────────────┤
│ Gain Map JPEG (完整的第二張圖)   │  ← 亮度增益資訊
└─────────────────────────────────┘

assemble_ultrahdr_tile 函式負責把這些東西拼在一起:

fn assemble_ultrahdr_tile(
    sdr_jpeg: &[u8],
    gainmap_jpeg: &[u8],
    metadata: &GainMapMetadata,
) -> Result<Vec<u8>, String> {
    // 1. 在 Gain Map JPEG 裡嵌入 XMP metadata
    let gainmap_xmp = generate_gainmap_xmp(metadata);
    let gainmap_jpeg_with_xmp = embed_xmp_in_jpeg(gainmap_jpeg, &gainmap_xmp);

    // 2. 產生主圖的 XMP APP1 marker(包含完整參數 + Container directory)
    let xmp = generate_primary_xmp(metadata, gainmap_jpeg_with_xmp.len());
    let xmp_marker = ultrahdr_core::metadata::xmp::create_xmp_app1_marker(&xmp);

    // 3. 把 XMP 插到 SOI 後面
    let mut primary_with_xmp = Vec::with_capacity(sdr_jpeg.len() + xmp_marker.len());
    primary_with_xmp.extend_from_slice(&sdr_jpeg[..2]); // SOI
    primary_with_xmp.extend_from_slice(&xmp_marker);
    primary_with_xmp.extend_from_slice(&sdr_jpeg[2..]);

    // 4. 找到插入 MPF APP2 的位置(在 APP0/APP1 之後)
    let insert_pos = find_mpf_insert_position(&primary_with_xmp)?;

    // 5. 計算 MPF header(需要知道最終的 primary 大小,所以先算一次)
    let gm_len = gainmap_jpeg_with_xmp.len() as u32;
    let placeholder_mpf = create_mpf_app2(u32::MAX, &[gm_len], insert_pos);
    let primary_final_size = (primary_with_xmp.len() + placeholder_mpf.len()) as u32;
    let mpf_header = create_mpf_app2(primary_final_size, &[gm_len], insert_pos);

    // 6. 組裝最終輸出
    let mut output = Vec::with_capacity(
        primary_with_xmp.len() + mpf_header.len() + gainmap_jpeg_with_xmp.len()
    );
    output.extend_from_slice(&primary_with_xmp[..insert_pos]);
    output.extend_from_slice(&mpf_header);
    output.extend_from_slice(&primary_with_xmp[insert_pos..]);
    output.extend_from_slice(&gainmap_jpeg_with_xmp);

    Ok(output)
}

MPF header 裡面是個迷你的 TIFF IFD 結構,記錄了每張圖片的大小和偏移量。這裡有個雞生蛋的問題:MPF header 裡需要填 primary 的最終大小,但 primary 的大小又取決於 MPF header 有多大。解法是先用 placeholder 算出 MPF 的固定大小,再用正確的數值重新生成。

WASM 體積優化

WASM 版本最終編出來的 .wasm 檔案大約 286 KB,對一個包含完整 JPEG 編解碼和 Ultra HDR 處理的模組來說算很小了。主要靠幾個手段:

[profile.release]
opt-level = "s"   # 優化體積而非速度
lto = true         # 跨 crate 的 Link-Time Optimization

加上 image crate 只啟用 jpeg feature,不拉進 PNG、GIF 等不需要的格式:

[dependencies]
image = { version = "0.25.5", default-features = false, features = ["jpeg"] }

default-features = false 很重要——image crate 預設會拉進一大堆圖片格式的支援,對 WASM 體積影響很大。

JS 端的整合

前端部分很簡單,用原生的 ES module 載入 WASM:

import init, { validate_image, split_left, split_right } from './tilesplit_wasm.js';

async function run() {
    await init();  // 載入並初始化 WASM 模組
    console.log('TileSplit WASM loaded');
}
run();

wasm-pack build --target web 產生的 JS glue code 會自動處理 WASM 的載入和記憶體管理。init() 內部用 WebAssembly.instantiateStreaming 做串流載入,瀏覽器可以在下載 WASM 的同時開始編譯,體驗很流暢。

圖片處理的流程:

// 使用者選擇檔案後
const reader = new FileReader();
reader.onload = (e) => {
    const data = new Uint8Array(e.target.result);
    const info = validate_image(data);  // WASM 驗證
    // 顯示圖片資訊...
};
reader.readAsArrayBuffer(file);

// 按下 Split 後
const leftBytes = split_left(currentData, quality);   // WASM 切割
const rightBytes = split_right(currentData, quality);
const leftBlob = new Blob([leftBytes], { type: 'image/jpeg' });
previewLeft.src = URL.createObjectURL(leftBlob);       // 顯示預覽

整個過程都在瀏覽器本地完成,圖片不會上傳到任何伺服器。

小結

Ultra HDR 的「正確」其實散落在規格的各個角落。光看 ISO 21496-1 規格書不夠,得拿 Lightroom、Google Photos 等軟體的實際輸出來對照,才能確定哪些欄位是必要的、viewer 實際上怎麼解析。

結語

把 Rust CLI 工具移植到 WASM 最大的收穫是:降低了使用門檻。不需要安裝任何東西,打開網頁就能用。對於像 tilesplit 這種偶爾才用一次的小工具,WASM 版本可能比 CLI 更實用。

當然也有取捨——WASM 版本用的 image crate JPEG 編碼器品質沒有 jpegli 好,處理速度也稍慢一些。但對於「切一張照片發 IG」這個使用情境來說,完全夠用。

完整程式碼在 GitHub 上。