上一篇介紹了 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 上,有興趣的話歡迎去翻翻看。