Images 與媒體交付
第 30 章的 Browser Run 解決的是「渲染一個網頁然後截圖」。這一章解決的是完全不同的問題:你有一堆既有的圖片位元組(使用者上傳的頭像、產品照),需要縮放、裁切、換格式、加浮水印,然後高效地交付出去。
這件事在 Cloudflare 上有三個不同的介面,而且沒有一個被棄用。搞清楚哪個是哪個,是本章的第一個目標。第二個目標是 2026 年那次很重要的計費模型變動。
31.1 先處理文件的混亂
Section titled “31.1 先處理文件的混亂”如果你搜尋 Cloudflare 圖片處理,會撞到三個名詞與兩套路徑,其中一半是過時的。
(一)「Image Resizing」這個產品名已經退役。 現在統一叫 transformations。這個合併發生在 2024 年(Cloudflare Images 與 Image Resizing 合併)。
有一個技術遺跡值得記住:
image-resizing這個字串仍然存在於Viaheader 裡,而且是 31.5 那個無窮迴圈陷阱的唯一解法。
(二)文件路徑重組過。 /images/transform-images/* 現在轉址到 /images/optimization/*。舊連結還能用,但你搜到的舊教學裡的路徑結構已經對不上現在的目錄。
(三)Polish 和 Transformations 是兩個不同的產品,常被混為一談。官方的區分很清楚:
| Polish | Transformations | |
|---|---|---|
| 做什麼 | 「自動最佳化所有從你的來源伺服器提供的圖片」 | 「建立新的圖片,套用縮放、裁切、浮水印與其他處理」 |
| URL | 「維持相同的圖片 URL」 | 「這些圖片會有自己的新 URL」 |
| 要改 markup 嗎 | 「不需要更改頁面的 markup」 | 「你需要把它們嵌入頁面」 |
而且官方明說:經過 Cloudflare Images 處理的圖片「已經最佳化過了,不需要再套用 Polish」。
簡單記法:Polish 是原地壓縮,Transformations 是產生衍生圖。
31.2 三個介面
Section titled “31.2 三個介面”| 介面 | 輸入 | 適合 |
|---|---|---|
(a) URL /cdn-cgi/image/<options>/<source> | URL | 最簡單,<img src> 直接寫 |
(b) fetch() 的 cf.image | URL | 需要在 Worker 裡決定選項(裝置、網路狀況) |
(c) images binding | 位元組串流 | 需要串接多步驟、合成、或來源是 R2 / 上傳的位元組 |
三個都還活著,沒有一個被標為 deprecated。
⚠️ 官方沒有一份完整的「該用哪一個」指南。 Features 頁只把它分成「URL Interface」與「Workers」兩條路,並列出 Workers 的優勢:能控制操作順序、能用自訂 URL scheme、能依裝置/網路做內容協商。沒有 binding vs
cf.image的比較表。本章的判準是:來源是 URL 就用
cf.image,來源是位元組(R2 物件、request body)就用 binding。 binding 的.input()吃的是ReadableStream,不是 URL —— 這個型別差異本身就說明了分工。
binding 的實際形狀
Section titled “binding 的實際形狀”實測 wrangler dev 的 /shape:
{ "ctorName": "ImagesBindingImpl", "protoKeys": ["constructor", "hosted", "info", "input"], "hasHosted": "object", "hostedKeys": ["constructor", "image", "upload", "list"]}值得高興的一件事:這是一個真正的本地實作,不是 RPC stub。 對照第 23 章的 Pipelines 與第 30 章的 Browser Run(兩者都是
Fetcher+JsRpcProperty,本機呼叫必炸),Images binding 在本機是ImagesBindingImpl,方法真的在 prototype 上,而且真的會執行。
hosted 是 2026-06-10 新增的第二個介面,31.4 會談。
31.3 轉換管線
Section titled “31.3 轉換管線”三段式:input() → transform()(可鏈式)→ output()。
const res = await env.IMAGES .input(objectFromR2.body) .transform({ width: 64, height: 64, fit: "cover" }) .output({ format: "image/webp", quality: 80 });
return new Response(res.image(), { headers: { "content-type": res.contentType() } });實測(8×8 PNG → 64×64 WebP):
{ "contentType": "image/webp", "bytes": 88, "ms": 17 }ImageTransformationResult 有三個方法:response()(直接給你一個 Response)、contentType()、image()(ReadableStream)。
輸出格式(實測型別):
format: 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp' | 'image/avif' | 'rgb' | 'rgba'rgb / rgba 是原始像素輸出 —— 如果你要把結果餵進 Workers AI(第 34 章)做影像推論,這就是那個介面。
.draw():合成與浮水印
Section titled “.draw():合成與浮水印”.draw() 可以吃另一個 transformer,所以浮水印本身也能先被轉換:
const watermark = env.IMAGES.input(logoStream).transform({ width: 16, height: 16 });
const res = await env.IMAGES .input(photoStream) .transform({ width: 200, height: 200 }) .draw(watermark, { bottom: 8, right: 8, opacity: 0.6 }) .output({ format: "image/png" });實測成功。ImageDrawOptions 有 opacity、repeat、composite、以及 top/left/bottom/right 四個定位參數。
.info():現在是免費的
Section titled “.info():現在是免費的”await env.IMAGES.info(stream);// { "format": "image/png", "fileSize": 74, "width": 8, "height": 8 }這是 2026-07-01 起的重要變動:.info() 不再計費(31.6 詳述)。所以「先看看這張圖多大再決定怎麼處理」現在是零成本的:
const meta = await env.IMAGES.info(stream);if (meta.format === "image/svg+xml") return passThrough(); // SVG 不能縮放if (meta.width <= 256) return passThrough(); // 已經夠小,別浪費一次轉換⚠️
.info()的錯誤碼,型別註解與實測不一致。生成的型別寫著:
@throws {@link ImagesError} with code 9412 if input is not an image。實測餵一段純文字進去,拿到的是 code 9523,而且訊息裡直接洩漏了本機路徑:
Unexpected error response 500: Error: Input buffer contains unsupported image formatat Sharp.metadata (file:///...node_modules/sharp/dist/input.mjs:642:17)at runInfo (/...node_modules/miniflare/dist/src/index.js:...)兩件事:(1)不要用 code 9412 做錯誤分流,至少本機不是這個值。(2)本機的 Images 實作是 miniflare 裡的 Sharp。 這解釋了為什麼本機與 production 的行為會有差異。
本機是低保真的
Section titled “本機是低保真的”官方 binding 頁明說有兩種模式:
- 離線 / 低保真:
wrangler dev—— 「只支援width、height、rotate與format」 - 遠端 / 高保真:
wrangler dev --remote—— 「與 Cloudflare 全球在 production 執行的是同一個版本」
所以本機轉出來的位元組、品質、甚至某些參數是否生效,都不能當數。 我實測 fit: "cover" 與 quality: 80 在本機不會報錯,但依上面那句話,它們很可能根本沒生效。
文件說本機是「低保真、只支援部分功能」,但沒有明說用了不同的編碼器。不過從錯誤訊息裡的 Sharp 堆疊來看,本機用的是 Sharp(libvips),而 production 用的是 Cloudflare 自己的實作。任何對品質或位元組大小敏感的驗證,都必須用
--remote。
Images binding 在 bindings-per-env 支援矩陣上是 local ✅ / remote ✅,設定裡也有 remote 欄位(實測 config-schema.json 只有 binding 與 remote 兩個欄位)。
31.4 hosted:2026-06 新增的 CRUD 介面
Section titled “31.4 hosted:2026-06 新增的 CRUD 介面”以前要在 Worker 裡管理 Images 儲存的圖片,得自己拿 API token 打 REST API。現在不用了:
const h = env.IMAGES.hosted;
await h.upload(bytes, { id: "avatar-u1", metadata: { userId: "u1" } });await h.list({ limit: 50, cursor, sortOrder: "desc" });await h.image("avatar-u1").details(); // ImageMetadata | nullawait h.image("avatar-u1").bytes(); // ReadableStream | nullawait h.image("avatar-u1").update({ ... });await h.image("avatar-u1").delete(); // booleanchangelog(2026-06-10):
「直接從你的 Worker 上傳、列出、取得、更新、刪除儲存在 Images 的圖片,不需要管理 API token 或發出 HTTP 請求。」
實測本機也有模擬實作:
{ "list": { "images": [], "listComplete": true }, "upload": { "id": "ch31-probe", "filename": "uploaded.jpg", "requireSignedURLs": false, "meta": { "chapter": 31 }, "variants": ["http://localhost:9040/__cf_local/imagedelivery/..."] }, "details": { ...same... }, "missing": null}三個觀察:
- 本機會產生
__cf_local/imagedelivery/的 variant URL —— 可以真的拿來測前端。 details()對不存在的 ID 回傳null而不是 throw,與型別Promise<ImageMetadata | null>一致。這比丟例外好處理,但也代表你必須檢查 null。- 上傳 PNG 時
filename被填成uploaded.jpg—— 一個預設值,和實際格式無關。別依賴它。
⚠️
hosted需要付費的 Images 方案(含 storage)。 官方 storage/binding 頁:「Hosted image operations require a paid Images plan with storage」、「這些呼叫計入你的 storage 額度,方式與使用 REST API 或 dashboard 相同」。changelog 本身沒有提到方案要求,只有文件頁有。本機模擬會讓你完全感覺不到這件事。
31.5 兩個經典陷阱
Section titled “31.5 兩個經典陷阱”陷阱一:無窮迴圈
Section titled “陷阱一:無窮迴圈”如果你把一個做轉換的 Worker 掛在 /*(或任何會涵蓋原圖路徑的 route),Worker 發出的取原圖請求會再次打到自己。
官方警告:如果 Worker 處理的路徑與圖片實際所在位置重疊,「it could cause an infinite loop by the Worker trying to request images from itself」。
官方的解法是檢查 Via header:
「當
Viaheader 裡出現image-resizing字串時,代表這是來自另一個 Worker 的請求,應該直接導向來源伺服器。」
export default { async fetch(request: Request) { // 一定要放在最前面 if (/image-resizing/.test(request.headers.get("via") ?? "")) { return fetch(request); } // ... 你的轉換邏輯 },};注意這裡的字串是
image-resizing—— 那個已經退役的產品名,在這個地方仍然是唯一正確的判斷依據。這正是 31.1 提到的技術遺跡。
更好的做法是根本不要讓路徑重疊。 範例專案的 /img 路由把來源放在 R2(env.MEDIA.get(key)),完全不發 HTTP 請求,所以結構上不可能迴圈。能用 binding 從 R2 讀位元組,就不要用 fetch 去取自己的 URL。
陷阱二:把轉換選項放進 cache key 的錯誤位置
Section titled “陷阱二:把轉換選項放進 cache key 的錯誤位置”常見的錯誤寫法是:從 Accept header 或自訂 header 決定輸出格式,然後回應加上 Vary。這會讓快取碎裂到幾乎沒有命中率,而且很容易寫成快取污染。
正確做法:把所有影響輸出的參數放進 URL。
// ✅ /img/avatars/u1.png?w=256 -> cache key 天生就是唯一的const w = Math.min(Number(q.get("w") ?? 256), 1024);注意那個 Math.min —— 一定要對尺寸做白名單或上限。開放任意 w 等於開放任意人幫你生成無限多個 unique transformation,而 31.6 會說明那正是計費單位。
// 更嚴格:只允許固定的幾個尺寸const ALLOWED = new Set([64, 128, 256, 512]);const w = ALLOWED.has(Number(q.get("w"))) ? Number(q.get("w")) : 256;31.6 計費:2026-07-01 的重要變動
Section titled “31.6 計費:2026-07-01 的重要變動”這是本章最需要更新認知的地方。
changelog(2026-07-01)原文:
「The Images binding is now billed per unique transformation, matching the model already used for URL-based transformations.」 「Repeat requests for the same combination of source image and parameters within the same calendar month are counted only once.」 「Calls to
.info()are no longer billed.」
理由也寫得很清楚:讓你「可以在熱路徑上呼叫 binding,而不用為每一次個別請求付費」。
在此之前,binding 的每一次呼叫都算一次轉換,這讓「每個請求都跑一次 binding」在成本上是不可行的。現在可以了。
| 項目 | 費率 |
|---|---|
| Unique transformations | 每月含 5,000,超出 $0.50 / 1,000 |
| 儲存的圖片 | $5 / 100,000 張 / 月 |
| 交付的圖片 | $1 / 100,000 張 / 月 |
「Unique transformation」的定義:「對一張原圖套用一組支援的參數的請求」。同一張圖 100×100 與 200×200 = 兩次轉換。而 format 參數不論最後實際輸出幾種格式都只算一次。
Free 方案也有每月 5,000 次 unique transformations;超過之後新的轉換會回傳錯誤 9422(被擋下,但不計費)。
儲存與交付是付費方案限定,而且只適用於存在 Images 裡的圖片,不適用於遠端來源。
⚠️ 官方的 pricing 頁還沒更新。 我逐字確認過,
/images/pricing/上仍然寫著改版前的那句:「When using the Images binding in Workers, every call to the binding counts as a transformation, regardless of whether the image or parameters are unique.」
這與 2026-07-01 的 changelog 和 binding 文件頁直接矛盾。以 changelog 與 binding 頁為準,pricing 頁只拿來看費率。
架構上的意義
Section titled “架構上的意義”「per unique transformation」改變了正確的設計:
- 以前:必須自己在前面加一層快取(R2 或 Cache API),否則每個請求都是一次計費。
- 現在:同月同參數只算一次,所以平台已經幫你做了去重。你的快取層變成純粹的延遲最佳化,不再是成本剛需。
但兩件事沒變:
- 尺寸必須白名單。 計費單位是「unique」,所以攻擊面是「產生大量不同的參數組合」。
?w=1、?w=2、?w=3… 一萬個請求就是一萬次轉換。 - 仍然值得快取。 去重是按月算的,而且轉換本身有延遲。範例的 R2 快取模式(第 12 章)依然是對的。
31.7 限制
Section titled “31.7 限制”| 限制 | 值 |
|---|---|
binding .input() 最大輸入 | 20 MB |
| 遠端圖片(URL / fetch) | 100 MB |
| Hosted 圖片 | 10 MB |
| 最大邊長 | 12,000 px(WebP 與 AVIF 除外) |
| AVIF 最大邊長 | 1,200 px |
| 最大面積 | 100 MP(動畫 GIF 除外) |
| 動畫(GIF / WebP) | 全部影格合計 100 MP;超過 50 MP 的動畫會原樣交付,不套用任何轉換 |
輸入格式:PNG、JPEG、GIF(動畫)、WebP(動畫)、SVG、HEIC、AVIF(AVIF 輸入是 Enterprise)。 輸出格式:PNG、JPEG、GIF(動畫)、WebP(動畫)、SVG、AVIF。
三個容易踩的:
- binding 只吃 20 MB,比遠端來源的 100 MB 小五倍。使用者上傳的手機原始照片很容易超過 —— 上傳流程要先擋。
- AVIF 的 1,200 px 上限遠低於其他格式的 12,000 px。想輸出大尺寸 AVIF 是做不到的。
- SVG 不會被縮放。 官方:「Cloudflare 不會調整 SVG 檔案的大小,並會忽略任何最佳化參數。」但它們會經過
svg-hush清洗(移除 script、超連結、跨來源引用)。所以 SVG 的處理路徑應該是「偵測到就直接放行」,而 31.3 的免費.info()正是用來偵測它的。
31.8 LinkForge:頭像與 OG 圖
Section titled “31.8 LinkForge:頭像與 OG 圖”頭像:上傳時正規化,交付時只挑尺寸
Section titled “頭像:上傳時正規化,交付時只挑尺寸”// 上傳:先用免費的 info() 擋掉不該處理的東西app.post("/api/avatar", async (c) => { const body = c.req.raw.body!; const [forInfo, forStore] = body.tee();
const meta = await c.env.IMAGES.info(forInfo); // 免費(2026-07 起) if (meta.format === "image/svg+xml") { return c.json({ error: "svg not allowed for avatars" }, 400); } if (meta.fileSize > 20 * 1024 * 1024) { // binding 上限 return c.json({ error: "too large" }, 413); }
// 一次性正規化:存一份 512px 的 master,之後所有尺寸從它衍生 const master = await c.env.IMAGES.input(forStore) .transform({ width: 512, height: 512, fit: "cover" }) .output({ format: "image/webp", quality: 85 });
await c.env.MEDIA.put(`avatars/${userId}.webp`, master.image()); return c.json({ ok: true });});.tee() 那一步很重要 —— ReadableStream 只能讀一次,而我們要先 info() 再 input()。
交付:白名單尺寸 + immutable 快取
Section titled “交付:白名單尺寸 + immutable 快取”const AVATAR_SIZES = new Set([32, 64, 128, 256]);
app.get("/avatars/:userId", async (c) => { const w = AVATAR_SIZES.has(Number(c.req.query("w"))) ? Number(c.req.query("w")) : 64;
const obj = await c.env.MEDIA.get(`avatars/${c.req.param("userId")}.webp`); if (!obj) return c.notFound();
const res = await c.env.IMAGES.input(obj.body) .transform({ width: w, height: w }) .output({ format: "image/webp", quality: 82 });
return new Response(res.image(), { headers: { "content-type": res.contentType(), "cache-control": "public, max-age=31536000, immutable", }, });});成本結構:每個使用者最多 4 種尺寸 × 每月去重一次 = 每人每月最多 4 次 unique transformation。一萬個活躍使用者也才 4 萬次,$17.5/月。如果沒有白名單,同樣的流量可以輕易變成幾百萬次。
自訂 OG 圖:接上第 30 章
Section titled “自訂 OG 圖:接上第 30 章”第 30 章用 Browser Run 從 HTML 產生 OG 圖。如果租戶想放自己的品牌圖,就變成合成問題 —— 這正是 .draw() 的用途:
const bg = env.IMAGES.input(tenantBackground.body).transform({ width: 1200, height: 630, fit: "cover" });
const res = await env.IMAGES .input(await (await fetch(textLayerFromBrowserRun)).body!) // 第 30 章產生的文字層 PNG .draw(bg, { opacity: 1 }) .output({ format: "image/png" });分工很清楚:Browser Run 負責「排版文字」(那需要一個排版引擎),Images binding 負責「合成位元組」(那不需要瀏覽器)。 用 Browser Run 做單純的圖層疊加,是拿 1-3 秒的冷啟去做一件 17 毫秒的事。
31.9 本章實測結論彙整
Section titled “31.9 本章實測結論彙整”| # | 結論 | 影響 |
|---|---|---|
| 1 | 2026-07-01 起 binding 改為按 unique transformation 計費,同月同參數只算一次 | 可以放在熱路徑上了 |
| 2 | .info() 不再計費 | 「先檢查再決定」變成零成本策略 |
| 3 | 官方 pricing 頁仍寫著改版前的舊規則,與 changelog 直接矛盾 | 以 changelog 與 binding 頁為準 |
| 4 | Unique transformation 每月含 5,000;超出 $0.50/1,000;Free 也有 5,000(超出回 9422,不計費) | |
| 5 | binding 是真正的本地實作(ImagesBindingImpl,方法在 prototype 上) | 對照第 23、30 章的 RPC stub,這是好消息 |
| 6 | 本機是「低保真」,官方明說只支援 width/height/rotate/format | 品質與位元組大小必須用 --remote 驗證 |
| 7 | 本機實作是 miniflare 裡的 Sharp(從錯誤堆疊可見),production 不是 | 文件只說「低保真」,沒說編碼器不同 |
| 8 | .info() 對非圖片實測丟 code 9523,但型別註解說是 9412 | 不要用 9412 做錯誤分流 |
| 9 | 錯誤訊息會洩漏本機 node_modules 路徑 | 不要把原始錯誤回傳給使用者 |
| 10 | hosted CRUD 於 2026-06-10 新增,本機也有模擬(含 __cf_local/imagedelivery/ variant URL) | Worker 裡不再需要 API token |
| 11 | hosted 需要付費 Images 方案(含 storage),但 changelog 沒提,只有文件頁有 | 本機模擬完全感覺不到 |
| 12 | details() 對不存在的 ID 回傳 null 而非 throw | 必須檢查 null |
| 13 | 上傳 PNG 時 filename 被填成 uploaded.jpg | 預設值與實際格式無關 |
| 14 | 輸出格式含 rgb / rgba 原始像素 | 可餵給 Workers AI(第 34 章) |
| 15 | .draw() 可以吃另一個 transformer(不只是 stream) | 浮水印本身可先被轉換 |
| 16 | 無窮迴圈的官方解法是檢查 Via header 裡的 image-resizing 字串 | 那個已退役的產品名仍是唯一判準 |
| 17 | 從 R2 讀位元組(而非 fetch 自己的 URL)結構上不可能迴圈 | 更好的解法 |
| 18 | binding .input() 上限只有 20 MB,遠端來源是 100 MB、hosted 是 10 MB | 手機原始照片很容易超過 |
| 19 | AVIF 最大邊長 1,200 px,其他格式 12,000 px | 大尺寸 AVIF 做不到 |
| 20 | SVG 不會被縮放,參數會被忽略;但會經 svg-hush 清洗 | 偵測到就放行 |
| 21 | 超過 50 MP 的動畫原樣交付,不套用任何轉換 | 靜默降級 |
| 22 | Polish 與 Transformations 是不同產品:原地壓縮 vs 產生新 URL | 官方明說 Images 處理過的圖不需要 Polish |
| 23 | 「Image Resizing」產品名已退役;/images/transform-images/* 轉址到 /images/optimization/* | 舊教學的路徑對不上 |
| 24 | 官方沒有 binding vs cf.image 的比較指南 | 本章判準:URL 來源用 cf.image,位元組來源用 binding |
31.10 動手練習
Section titled “31.10 動手練習”- 對同一張圖分別用
wrangler dev與wrangler dev --remote跑同一組轉換,比較輸出位元組大小 —— 這是 31.3 那個「本機低保真」的實證。 - 把
fit: "cover"換成一個本機不支援的參數(例如blur),確認本機是靜默忽略還是報錯。 - 寫一個沒有尺寸白名單的交付端點,用腳本打 1,000 個不同的
?w=,然後在 dashboard 上看 unique transformation 的計數 —— 用小規模驗證 31.6 的攻擊面。 - 上傳一個 25 MB 的圖到
.input(),記錄實際的錯誤形式(20 MB 上限)。 - 用
.info()+.tee()實作 31.8 的上傳守門邏輯,並確認.info()確實沒有增加轉換計數。
- Images · Optimization · Binding · Features · Transform via fetch
- Storage binding(hosted) · Limits · Pricing(⚠️ binding 計費段落已過時) · Polish
- Changelog:binding 改按 unique transformation 計費(2026-07-01) · hosted binding(2026-06-10)
- API 表面來源:
wrangler types產生的worker-configuration.d.ts(ImagesBinding、HostedImagesBinding、ImageTransformer等)
下一章(第 32 章):Email —— Email Routing 的收信 Worker、
send_emailbinding 的寄信路徑,以及為什麼「用 Workers 寄行銷信」是個壞主意。