上一篇介紹了 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-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 上。
每次要把橫幅風景照發 Instagram,都得打開 Lightroom 手動裁切——調整裁切框、對齊、最後再匯出。於是我就寫了 tilesplit,一個用 Rust 打造的小工具,專門在分割圖片的同時保留 Ultra HDR 資訊。這篇文章來聊聊 Ultra HDR 是什麼,以及這個工具背後的技術細節。
為什麼需要 TileSplit?
我最主要的使用情境是把 3:2 的橫幅風景照發到 Instagram 上。
雖然 Instagram 現在允許以原始比例上傳照片,但橫幅照片在動態牆上顯示得比直幅照片小很多,視覺衝擊力大打折扣。所以現在 IG 很流行「無縫滑動」的發法:把一張橫幅照片切成兩張(或更多)直幅圖,使用者左右滑的時候會有連續的視覺體驗,同時每張圖在動態牆上都能佔滿版面。但如果這張照片是 Ultra HDR 格式,用一般工具切完,HDR 就沒了——原本在 HDR 螢幕上那種亮眼的層次感,瞬間變得平淡。
原因很簡單:大多數工具在處理圖片時,根本不知道 Gain Map 的存在。它們只保留了 SDR 的部分,Gain Map 直接被丟掉了。
什麼是 Ultra HDR?
Ultra HDR 是 Google 在 Android 14 引入的圖片格式,本質上還是 JPEG,但多藏了一些東西。它最厲害的地方在於向後兼容。
傳統的 HDR 圖片(像 10-bit HEIF 或 AVIF)在舊手機或不支援 HDR 的螢幕上,常常會顏色怪怪的或是過暗。Ultra HDR 用了一個很聰明的方法解決這個問題:
- SDR 基礎圖片:本質上就是一張普通的 JPEG,任何軟體都能打開、正常顯示。
- Gain Map(增益圖):在 JPEG 裡面偷偷塞了一張「變亮地圖」——告訴系統每個區域可以變多亮。
- HDR 重建:在支援 HDR 的螢幕上,系統把 SDR 圖片和 Gain Map 合在一起算,就能還原出更亮的高光和更豐富的細節。
同一張圖片,舊手機上正常顯示,新手機上閃閃發光。
TileSplit 是如何工作的?
核心目標很單純:切圖的同時,把 Ultra HDR 的資料完整保留下來。整個流程大概分四步:
1. 偵測與解析
程式先用 jpegli 去讀輸入檔案,檢查 JPEG 裡有沒有 XMP Metadata 和 Gain Map。如果 jpegli 讀不出來(不同手機產生的 Ultra HDR 格式會有差異),就退回去用 Google 原生的 ultrahdr 解碼器做更深入的解析。
2. 雙層解碼
確認是 Ultra HDR 之後,程式會把圖片拆成兩層:SDR 主圖(標準 RGB 像素)和 Gain Map(亮度增益資料)。同時提取 Metadata(像 hdrgm:GainMapMin、hdrgm:Gamma 等),這些參數決定了 SDR 和 Gain Map 怎麼合成 HDR。
來看看程式碼長什麼樣子(省略了 debug logging):
fn try_extract_ultrahdr_with_jpegli(
source_bytes: &[u8],
debug: bool,
) -> Option<(ultrahdr::GainMapMetadata, RawImage, GainMap, Option<Vec<u8>>)> {
// 用 catch_unwind 保護 jpegli 解碼——某些損壞的 JPEG 會造成 panic
let decoded = match catch_unwind_quiet(AssertUnwindSafe(|| {
JpegDecoder::new()
.preserve(PreserveConfig::default())
.output_format(JpegPixelFormat::Rgb)
.decode(source_bytes)
})) {
Ok(Ok(image)) => image,
_ => return None,
};
// 從 JPEG extras 裡挖出 XMP metadata 和 Gain Map
let extras = decoded.extras()?;
let xmp = extras.xmp()?;
let (metadata, _) = ultrahdr::metadata::xmp::parse_xmp(xmp).ok()?;
let gainmap = match extras.gainmap() {
Some(gainmap) => gainmap.to_vec(),
// extras 裡找不到的話,試試 MPF(Multi-Picture Format)
None => extract_gainmap_jpeg_from_mpf(source_bytes, debug)?,
};
// 有些手機的 XMP metadata 不完整,嘗試從 Gain Map 本身的 XMP 補齊
let metadata = if metadata_looks_default_or_incomplete(&metadata) {
extract_metadata_from_gainmap_xmp(&gainmap, debug).unwrap_or(metadata)
} else {
metadata
};
let icc_profile = extras.icc_profile().map(|icc| icc.to_vec());
// 把像素資料包成 RawImage 結構
let sdr = RawImage::from_data(
decoded.width, decoded.height,
PixelFormat::Rgb8, ColorGamut::Bt709, ColorTransfer::Srgb,
decoded.data,
).ok()?;
// Gain Map 的 JPEG 也需要解碼成像素資料
let gainmap = decode_gainmap_jpeg(&gainmap, ColorGamut::Bt709).ok()?;
Some((metadata, sdr, gainmap, icc_profile))
}
幾個值得注意的設計:
catch_unwind_quiet:jpegli 是 C 庫的封裝,遇到格式損壞可能會 panic,用 catch_unwind 接住避免整個程式崩潰
- MPF fallback:有些手機(特別是早期支援 Ultra HDR 的機型)把 Gain Map 存在 JPEG 的 Multi-Picture Format 區段而非 Extras 裡,需要額外處理
- Metadata 補齊:某些裝置產生的 XMP metadata 不完整,程式會嘗試從 Gain Map 自身的 XMP 中提取更完整的參數
3. 幾何映射與分割
根據使用者要的比例(像 4:5 for Instagram),程式算出 SDR 主圖的裁剪區域。
這裡有個坑:Gain Map 的解析度通常比主圖小(常見是 1/4 大小),所以不能直接拿主圖的座標去切 Gain Map。需要根據兩者的比例做精確映射,不然切出來的 HDR 重建會位置偏移,高光對不準。
fn map_rect_to_gainmap(
rect: Rect,
source_width: u32,
source_height: u32,
gainmap_width: u32,
gainmap_height: u32,
) -> Rect {
// 用整數運算避免浮點誤差
let x0 = (rect.x as u64 * gainmap_width as u64 / source_width as u64) as u32;
let y0 = (rect.y as u64 * gainmap_height as u64 / source_height as u64) as u32;
let right_edge = rect.x.saturating_add(rect.width);
let bottom_edge = rect.y.saturating_add(rect.height);
// 右下角用 ceiling division,確保不會少切到像素
let mut x1 = div_ceil_u64(
right_edge as u64 * gainmap_width as u64,
source_width as u64,
) as u32;
let mut y1 = div_ceil_u64(
bottom_edge as u64 * gainmap_height as u64,
source_height as u64,
) as u32;
// 邊界保護
x1 = x1.clamp(0, gainmap_width);
y1 = y1.clamp(0, gainmap_height);
if x1 <= x0 {
x1 = (x0 + 1).min(gainmap_width);
}
if y1 <= y0 {
y1 = (y0 + 1).min(gainmap_height);
}
Rect {
x: x0,
y: y0,
width: x1.saturating_sub(x0),
height: y1.saturating_sub(y0),
}
}
4. 編碼與重組
最後一步是把切好的東西重新包裝成合法的 Ultra HDR JPEG。流程是:
- 把裁剪後的 Gain Map 編碼成獨立的 JPEG,並嵌入 XMP metadata
- 用
jpegli 編碼 SDR 主圖,保留 ICC Color Profile
- 手動組裝 Ultra HDR 容器:插入 XMP APP1 和 MPF APP2,再接上 Gain Map JPEG
為什麼不用 jpegli 的 .add_gainmap() 一行搞定?因為它生成的 MPF 偏移量是錯的(用了絕對偏移而非 MPF 規格要求的相對偏移),導致 viewer 找不到 Gain Map,HDR 就失效了。所以得自己手動組裝容器。
fn encode_ultrahdr_tile(
sdr_tile: RawImage,
gainmap_tile: GainMap,
metadata: &ultrahdr::GainMapMetadata,
source_icc_profile: Option<&[u8]>,
) -> Result<Vec<u8>, i32> {
// 1. Gain Map 編碼成 JPEG,同時嵌入 XMP metadata
let gainmap_jpeg = encode_gainmap_jpeg(&gainmap_tile, metadata)?;
// 2. 編碼 SDR 主圖(不含 Gain Map,容器組裝交給 assemble_ultrahdr_jpeg)
let mut config = EncoderConfig::ycbcr(SDR_TILE_JPEG_QUALITY, ChromaSubsampling::None);
if let Some(icc_profile) = source_icc_profile
&& !icc_profile.is_empty()
{
config = config.icc_profile(icc_profile.to_vec());
}
let (pixel_layout, pixel_data) = match sdr_tile.format {
PixelFormat::Rgb8 => (PixelLayout::Rgb8Srgb, Cow::Borrowed(&sdr_tile.data)),
PixelFormat::Rgba8 => { /* RGBA → RGB 轉換 */ }
_ => return Err(EXIT_IO_ERROR),
};
let sdr_jpeg = /* jpegli encode */;
// 3. 手動組裝 Ultra HDR 容器
assemble_ultrahdr_jpeg(&sdr_jpeg, &gainmap_jpeg, metadata)
}
assemble_ultrahdr_jpeg 做的事:把 XMP APP1 和 MPF APP2 插到主圖裡,再把 Gain Map JPEG 接在主圖後面。MPF header 裡的偏移量必須相對於 TIFF header(mpf_marker_offset + 8),這個細節後面的「踩坑」章節會詳細說明。
這樣輸出的每一張切片都是完整的 Ultra HDR 檔案,可以在支援的設備上獨立顯示 HDR 效果。
關於重新編碼與畫質
你可能會問:「重新編碼會不會讓照片變糟?」
會。但可以把損失降到很低。
JPEG 每次重新編碼都會因為 DCT 量化而失去一些細節。為了盡量保住畫質,tilesplit 做了幾件事:
- 用 Jpegli 編碼器:Google 開源的
jpegli 在同樣檔案大小下,畫質比傳統 libjpeg 好不少
- 高畫質設定:預設 Quality 100,在 Instagram 上基本看不出和原圖的差異
- 避免不必要的轉換:直接操作解碼後的原始像素,不做多餘的格式轉換
技術上不是「絕對無損」——要做到無損得用 jpegtran 那種直接操作 DCT 係數的方式,但那要同時處理 Ultra HDR 的 Metadata 封裝會極其困難。實際用起來,tilesplit 產出的畫質對攝影愛好者來說完全夠用。
關鍵相依庫
tilesplit 主要靠這三個 Rust crate:
image:Rust 生態系最常用的圖像處理庫,負責基礎的裁剪、格式轉換和非 HDR 圖片的處理。
jpegli-rs:Google jpegli 的 Rust 封裝,壓縮率比 libjpeg 好,而且支援 JPEG Extras——可以讀寫嵌在 JPEG 裡的 XMP Metadata 和 Gain Map。
ultrahdr-rs:Google libultrahdr 的封裝,包含 Ultra HDR 的核心邏輯(Metadata 解析、SDR/HDR 轉換公式等)。遇到格式比較特殊的圖片時,靠它來兜底。它的純 Rust 子 crate ultrahdr-core 也用來生成 XMP APP1 marker。
為什麼選擇 Rust?
Rust 剛好適合這種場景:記憶體安全(不用擔心 segfault)、執行效率接近 C++、而且 FFI 很方便——libultrahdr 和 jpegli 都是 C/C++ 的庫,Rust 可以直接呼叫再包一層安全介面。加上 Result 型別強制處理所有錯誤情況,遇到檔案損壞或 metadata 缺失,不會直接 crash,而是給出有意義的錯誤訊息。
安裝與使用
如果你也有類似需求,可以直接從 GitHub 安裝:
cargo install --git https://github.com/p47t/rust-52-projects --branch tilesplit
用法很簡單:
# 基本用法,自動生成 photo-left.jpg 和 photo-right.jpg
tilesplit --input photo.jpg
# 指定輸出路徑
tilesplit --input photo.jpg --left-output left.jpg --right-output right.jpg
原始碼在 rust-52-projects/tilesplit 上。
踩過的坑:亮度為什麼不對?
做完初版後,切出來的圖在 HDR 螢幕上看起來比原圖暗很多。花了不少時間 debug 才搞清楚,「把 Gain Map 塞進去」跟「讓 viewer 正確解讀」是兩回事。以下是踩過的幾個主要坑:
MPF 偏移量的基準點
Ultra HDR JPEG 是兩張圖接在一起——主圖和 Gain Map。MPF (Multi-Picture Format) APP2 marker 裡有個 TIFF IFD 結構,記錄 Gain Map 的位置。關鍵是:偏移量是相對於 MPF 裡的 TIFF header,不是檔案開頭。
MPF APP2 的二進制結構:
FF E2 ← marker (2 bytes)
xx xx ← length (2 bytes)
4D 50 46 00 ← "MPF\0" (4 bytes)
4D 4D 00 2A ← TIFF header ← 偏移量從這裡算起
TIFF header 在 MPF marker 起始位置 +8 bytes 的地方。一開始用了 jpegli 的 .add_gainmap() API,發現它寫入的是絕對偏移而非相對偏移,viewer 根本找不到 Gain Map。改成手動組裝容器才修好。
主圖和 Gain Map 都要有 XMP
原本以為只要主圖的 XMP 有完整參數就行。結果拿 Lightroom 匯出的 Ultra HDR 對比——主圖的 XMP 只有 hdrgm:Version="1.0",完整的 GainMapMin、GainMapMax、Gamma 等參數全在 Gain Map JPEG 自己的 XMP 裡。
這表示很多 viewer(包括 Android 的 Photos 和 Chrome)是優先從 Gain Map 的 XMP 讀取參數的。Gain Map JPEG 如果沒嵌入 XMP,viewer 可能拿不到正確的 boost 參數,畫面就是暗的。
rdf:Seq 才是標準格式
Gain Map 參數是 per-channel 的(RGB 各通道可以不同),在 XMP 裡有兩種表達方式:
<!-- 逗號分隔(有些 viewer 不認) -->
<rdf:Description hdrgm:GainMapMax="-0.699, -0.615, -0.603" />
<!-- rdf:Seq(標準,所有 viewer 都認) -->
<hdrgm:GainMapMax>
<rdf:Seq>
<rdf:li>-0.699220</rdf:li>
<rdf:li>-0.614892</rdf:li>
<rdf:li>-0.603440</rdf:li>
</rdf:Seq>
</hdrgm:GainMapMax>
ultrahdr-rs crate 的 generate_xmp() 用的是逗號分隔格式,實測某些 viewer 會 fallback 到預設值。改成 rdf:Seq 格式後就正常了。
Gain Map 品質不能省
Gain Map 每個像素代表 HDR boost 強度,套用公式大致是 boost = max_boost^(pixel/255)。因為是指數關係,JPEG 壓縮的量化誤差會被指數放大——pixel 值差個 5,亮度可能差 5% 以上。所以 Gain Map 一律用 quality 100 編碼,不管使用者設的 SDR 品質是多少。
教訓
Ultra HDR 的正確性散落在 MPF 規格、XMP schema、和各家 viewer 的實作細節裡。光看 ISO 21496-1 不夠,得拿 Lightroom、Google Photos 等軟體的實際輸出做二進制比對,才能確認哪些欄位是必要的、viewer 怎麼解析。
結語
HDR 螢幕越來越普及,Ultra HDR 這種格式以後只會更常見。現有的圖像處理工具大多還沒跟上,tilesplit 算是填補了這個小空白。如果你也有保留 HDR 切圖的需求,希望這個工具能幫到你。
最近在看 builder-derive 這個小專案時,會再次被 Rust 的 procedural macro 驚艷到:
你在程式碼裡只寫一行 #[derive(Builder)],編譯器就幫你生出一整套 Builder API。
但它到底怎麼做到的?
這篇就用 builder-derive 的實作,從 TokenStream 一路追到實際生成的程式碼,拆解 Rust proc macro 的完整流程。
先看使用端:我們到底得到了什麼
先看最終使用方式:
use builder_derive::Builder;
#[derive(Builder, Debug)]
struct User {
username: String,
email: String,
age: Option<u32>,
tags: Vec<String>,
}
let user = User::builder()
.username("alice".to_string())
.email("[email protected]".to_string())
.age(30)
.build()?;
這裡我們沒有手寫 UserBuilder,也沒有手寫 setter、build()。
#[derive(Builder)] 在編譯期把這些東西全部產生出來。
Proc Macro 的核心心法:編譯期程式碼產生器
很多人第一次接觸 macro 會覺得它像「文字替換」。
Rust 的 procedural macro 比這個更嚴謹:
- 編譯器把你標註
derive 的語法轉成 TokenStream
- macro crate 用
syn 把 token parse 成 AST(語法樹)
- 你在 AST 上做檢查與分析
- 用
quote 產生新的 Rust tokens 丟回編譯器
- 編譯器把這段新程式碼當成真的程式去編譯
builder-derive 的模組切分很清楚:
src/lib.rs:入口
src/parse.rs:輸入合法性檢查
src/field.rs:欄位型別分析
src/generate.rs:程式碼生成
第 1 步:入口函式(lib.rs)
builder-derive 的入口非常典型:
#[proc_macro_derive(Builder)]
pub fn derive_builder(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
match generate::impl_builder(&input) {
Ok(tokens) => tokens.into(),
Err(e) => e.to_compile_error().into(),
}
}
這段有三個重點:
#[proc_macro_derive(Builder)] 把這個函式註冊成 derive macro。
parse_macro_input! 把原始 token 解析成 syn::DeriveInput。
- 發生錯誤時用
to_compile_error() 轉成編譯錯誤,而不是 panic。
也就是說,macro 的錯誤訊息其實是你主動設計出來的 UX。
第 2 步:先擋掉不支援的型別(parse.rs)
在 parse.rs 裡,專案先做輸入驗證:
- 只接受「具名欄位 struct」
- 拒絕 enum / union / tuple struct / unit struct
這個設計超務實。因為 Builder 的生成邏輯依賴欄位名稱,沒有欄位名就沒辦法安全產生 setter。
像這段 compile-fail 測試會得到清楚錯誤:
#[derive(Builder)]
enum MyEnum {
Variant1,
Variant2,
}
錯誤訊息是:
Builder can only be derived for structs, not enums
builder-derive 這裡做得很對:把「不可能支援」的 case 儘早在編譯期擋下來,使用者不用等到 runtime 才踩雷。
第 3 步:欄位分析(field.rs)
接著是整個 macro 的關鍵資料結構 FieldInfo,每個欄位會被分析出:
- 欄位名稱
name
- 原始型別
ty
- 是否
Option<T>
Option<T> 的內層型別 T
- 是否
Vec<T>
怎麼判斷 Option<T>?
它用 syn::Type pattern matching:
- 先確認是
Type::Path
- 抓路徑最後一段(例如
Option)
- 看識別字是不是
Option
- 若是,從 angle-bracket 參數拿出
T
這是 proc macro 最常見也最實用的技巧:
不要自己 parse 字串;直接操作 AST。
syn 到底在做什麼?
可以把 syn 想成「Rust 語法的解析器 + AST 資料模型」。
在 builder-derive 裡,syn 主要做三件事:
- 把
TokenStream 轉成 DeriveInput(看到 struct 名稱、可見性、欄位)
- 用 enum pattern matching 檢查資料型別(
Data::Struct / Fields::Named)
- 深入欄位型別結構(
Type::Path -> PathSegment -> PathArguments)判斷 Option<T>、Vec<T>
為什麼這很重要?因為 procedural macro 要的是「語法結構」,不是字串比對。
舉例來說,Option<String>、std::option::Option<String> 在字串上看起來不同,但在 AST 層都能被一致處理。這就是 syn 在 macro 裡的核心價值。
另外像 syn::Error::new_spanned(...) 也很關鍵:它可以把錯誤綁在原始程式碼 span 上,讓編譯錯誤直接指到真正寫錯的那一行。
這個專案一個值得注意的設計
對 Option<T> 欄位,setter 參數型別是 T,不是 Option<T>。
也就是你寫:
而不是:
這讓 API 更順手。不過 tradeoff 是:目前 API 不能顯式設定 None,只能「不呼叫 setter」來得到 None。
第 4 步:用 quote 生成程式碼(generate.rs)
generate.rs 裡把生成工作拆成四塊,這個切法很值得學:
generate_builder_struct
generate_builder_constructor
generate_setter_methods
generate_build_method
quote! 怎麼把 AST 變回 Rust 程式?
quote 的角色是「把分析結果重新組裝成 token」。
在這個專案裡,最常用的語法有兩個:
#var:把變數插進模板
#(...)*:把 iterator 產生的多段程式碼展開
例如:
let builder_fields = field_infos.iter().map(|field| {
let name = &field.name;
let builder_ty = field.builder_field_type();
quote! { #name: #builder_ty }
});
quote! {
struct #builder_name {
#(#builder_fields,)*
}
}
這段可以讀成:「每個欄位先變成一小段 token,最後用 repetition 一次展開成完整 struct 欄位列表。」
也因為這種寫法,macro 可以維持很強的可組合性:
- 每個生成函式只負責一種片段
- 最後在
impl_builder 把片段拼回完整輸出
對大型 macro 專案來說,這比單一巨大 quote! 區塊更容易維護。
4.1 生成 Builder struct
每個 builder 欄位都用 Option<...> 包起來,目的是追蹤「這個欄位有沒有被設定」。
4.2 生成 builder()
在原始 struct 上加:
impl User {
pub fn builder() -> UserBuilder { ... }
}
所有欄位初始化成 None。
4.3 生成 setter(可 chain)
每個 setter 都是這個形狀:
pub fn field(mut self, value: T) -> Self {
self.field = Some(value);
self
}
self by value + 回傳 Self,就是 fluent API 的來源。
4.4 生成 build(),而且不同欄位策略不同
builder-derive 在這裡把欄位分三類處理:
Option<T>:直接 passthrough(沒設就 None)
Vec<T>:沒設就 unwrap_or_default(),變空陣列
- 其他欄位:必填,沒設就回
Err("field is required")
這個策略很實用,因為 Vec<T> 在很多 config 型別裡確實適合預設空集合。
展開後大概長怎樣?
以這個輸入:
#[derive(Builder)]
struct Config {
host: String,
port: u16,
timeout: Option<u64>,
features: Vec<String>,
}
macro 會產生近似這樣的程式碼(簡化版):
struct ConfigBuilder {
host: Option<String>,
port: Option<u16>,
timeout: Option<u64>,
features: Option<Vec<String>>,
}
impl Config {
fn builder() -> ConfigBuilder {
ConfigBuilder {
host: None,
port: None,
timeout: None,
features: None,
}
}
}
impl ConfigBuilder {
fn host(mut self, value: String) -> Self {
self.host = Some(value);
self
}
fn port(mut self, value: u16) -> Self {
self.port = Some(value);
self
}
fn timeout(mut self, value: u64) -> Self {
self.timeout = Some(value);
self
}
fn features(mut self, value: Vec<String>) -> Self {
self.features = Some(value);
self
}
fn build(self) -> Result<Config, String> {
Ok(Config {
host: self.host.ok_or_else(|| "host is required".to_string())?,
port: self.port.ok_or_else(|| "port is required".to_string())?,
timeout: self.timeout,
features: self.features.unwrap_or_default(),
})
}
}
重點是:這些都發生在編譯期,你 runtime 不需要額外 macro 成本。
錯誤處理:compile-time 跟 runtime 分工
這個專案的錯誤策略很清楚:
Compile-time(macro 階段)
- 型別不支援就直接編譯失敗
- 錯誤訊息會標到對應程式碼位置(透過
syn::Error::new_spanned)
Runtime(build 階段)
- 缺必要欄位時,
build() 回 Result::Err(String)
這種分工很合理:
- 能在語法層判斷的,就盡量提早失敗
- 必須等使用者呼叫流程才能判斷的,就用
Result 回報
為什麼這個範例很適合學 proc macro
builder-derive 的好處是它「剛好夠真實,但不會太重」:
- 有完整 pipeline(parse -> analyze -> generate)
- 有 compile-fail test(
trybuild)
- 有 integration tests 驗證行為
- 邏輯分層乾淨,不會全部擠在一個檔案
如果正要開始學 proc macro,建議順序是:
- 先看
src/lib.rs 入口
- 再看
parse.rs 怎麼做輸入保護
- 再看
field.rs 怎麼做型別判斷
- 最後看
generate.rs 的 quote! 拼裝
這樣你比較不會一開始就被 quote! 的 token interpolation 淹沒。
目前限制與下一步
以目前實作來看,還有幾個可以進化的方向:
- 支援 generics(例如
struct Foo<T>)
- 支援
#[builder(...)] 欄位屬性(default、setter(into)、skip)
- 更結構化的錯誤型別(取代
String)
- 允許 optional setter 傳
Option<T>(可顯式設 None)
但以「教學用、理解 proc macro 原理」來說,現在這個版本其實已經很漂亮了。
結語
Rust 的 procedural macro 真正厲害的地方,不是語法炫技,而是把重複樣板在「編譯期」變成可驗證、可維護的程式碼生成流程。
builder-derive 這個專案剛好把這件事示範得很清楚:
- 用
syn 看懂你的 Rust 程式
- 用
quote 生成你本來不想手寫的 boilerplate
- 用型別檢查和測試把 macro 行為收斂到可預期
下次看到 #[derive(...)],你可以把它想成:
「這不是魔法,是一個在編譯器裡跑的小型 code generator。」
專案連結:rust-52-projects/builder-derive
看了 wasm-pack build --target web 產生的 pkg/ 目錄,搞懂了 JS 膠水程式碼的角色。
wasm-pack 輸出四個檔案:
| 檔案 |
用途 |
.wasm |
編譯後的 WASM 二進位 |
.js |
JS 膠水程式碼(runtime 必須) |
.d.ts |
TypeScript 型別宣告(純靜態,runtime 不用) |
_bg.wasm.d.ts |
原始 WASM exports 的型別宣告 |
.d.ts 不參與執行——它們只給 TypeScript 編譯器做型別檢查用。整個流程不涉及 TypeScript 編譯。
膠水程式碼做兩件事:
1. 載入 WASM:init() 用 WebAssembly.instantiateStreaming 串流編譯 .wasm 檔,邊下載邊編譯。
2. 雙向橋接 WASM 和瀏覽器 API:
-
JS → WASM:處理型別轉換。WASM 只懂數字,所以字串要先編碼成 UTF-8 寫進 WASM 線性記憶體,再傳指標和長度:
export function start(canvas_id, grid_width, grid_height) {
const ptr0 = passStringToWasm0(canvas_id, wasm.__wbindgen_malloc, ...);
const len0 = WASM_VECTOR_LEN;
return wasm.start(ptr0, len0, grid_width, grid_height);
}
-
WASM → JS:web_sys 的每個呼叫都對應一個 JS import 函式。以 WebGPU 的 device.createBuffer() 為例,Rust 端呼叫 web_sys → WASM 呼叫 import → JS 膠水程式碼呼叫真正的瀏覽器 API:
__wbg_createBuffer_fb1752eab5cb2a7f: function(arg0, arg1) {
const ret = arg0.createBuffer(arg1);
return ret;
}
在 wgpu Game of Life 專案裡,膠水程式碼包含大約 200 個這樣的橋接函式,全部由 wasm-bindgen 在建置時根據 #[wasm_bindgen] 和 web_sys 的使用自動生成。
在做 wgpu Game of Life 時發現一件事:WASM 環境下的 Rust async 不需要 Tokio 或 async-std——瀏覽器的 event loop 就是 runtime。
在 native Rust,你需要一個 executor 來 poll futures。但在 WASM,wasm-bindgen-futures 把 Rust 的 Future 轉換成 JavaScript 的 Promise,交給瀏覽器的 event loop 來驅動:
#[wasm_bindgen]
pub async fn start(canvas_id: &str) {
// 每個 .await 都是把控制權交還給瀏覽器的 event loop
let adapter = instance.request_adapter(&options).await;
let (device, queue) = adapter.request_device(&desc, None).await;
}
JavaScript 端看到的就是一個回傳 Promise 的函式:
await wasm.start("canvas");
每次 .await 時,Rust future 暫停執行,控制權回到瀏覽器。當底層的 JS 操作(例如 WebGPU 的 requestAdapter)完成時,瀏覽器透過 microtask 觸發 Rust 的 waker,從暫停處繼續執行。
|
Native |
WASM |
| Runtime |
Tokio / async-std |
瀏覽器 event loop |
| Executor |
Rust 端的 thread pool |
JS microtask queue |
| Spawn |
tokio::spawn(多執行緒) |
spawn_local(單執行緒) |
所以 Cargo.toml 只需要 wasm-bindgen-futures,不需要任何 Rust async runtime。