上一篇介紹了 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:Googlelibultrahdr的封裝,處理 Ultra HDR 的編解碼和容器組裝jpegli-rs:Googlejpegli的封裝,高品質 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-rs 的 add_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 的 ImageInfo 用 serde 序列化成 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 上。