跳到內容

Images 與媒體交付

查證日期
驗證環境wrangler@4.118.0·compatibility_date: 2026-07-24
前置章節30. Browser Run

第 30 章的 Browser Run 解決的是「渲染一個網頁然後截圖」。這一章解決的是完全不同的問題:你有一堆既有的圖片位元組(使用者上傳的頭像、產品照),需要縮放、裁切、換格式、加浮水印,然後高效地交付出去。

這件事在 Cloudflare 上有三個不同的介面,而且沒有一個被棄用。搞清楚哪個是哪個,是本章的第一個目標。第二個目標是 2026 年那次很重要的計費模型變動。


如果你搜尋 Cloudflare 圖片處理,會撞到三個名詞與兩套路徑,其中一半是過時的。

(一)「Image Resizing」這個產品名已經退役。 現在統一叫 transformations。這個合併發生在 2024 年(Cloudflare Images 與 Image Resizing 合併)。

有一個技術遺跡值得記住:image-resizing 這個字串仍然存在於 Via header 裡,而且是 31.5 那個無窮迴圈陷阱的唯一解法。

(二)文件路徑重組過。 /images/transform-images/* 現在轉址到 /images/optimization/*。舊連結還能用,但你搜到的舊教學裡的路徑結構已經對不上現在的目錄。

(三)Polish 和 Transformations 是兩個不同的產品,常被混為一談。官方的區分很清楚:

PolishTransformations
做什麼「自動最佳化所有從你的來源伺服器提供的圖片」「建立新的圖片,套用縮放、裁切、浮水印與其他處理」
URL「維持相同的圖片 URL」「這些圖片會有自己的 URL」
要改 markup 嗎「不需要更改頁面的 markup」「你需要把它們嵌入頁面」

而且官方明說:經過 Cloudflare Images 處理的圖片「已經最佳化過了,不需要再套用 Polish」。

簡單記法:Polish 是原地壓縮,Transformations 是產生衍生圖。


介面輸入適合
(a) URL /cdn-cgi/image/<options>/<source>URL最簡單,<img src> 直接寫
(b) fetch()cf.imageURL需要在 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 —— 這個型別差異本身就說明了分工。

實測 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 上,而且真的會執行

hosted2026-06-10 新增的第二個介面,31.4 會談。


三段式: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() 可以吃另一個 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" });

實測成功。ImageDrawOptionsopacityrepeatcomposite、以及 top/left/bottom/right 四個定位參數。

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 format
at 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 的行為會有差異。

官方 binding 頁明說有兩種模式:

  • 離線 / 低保真wrangler dev —— 「只支援 widthheightrotateformat
  • 遠端 / 高保真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 只有 bindingremote 兩個欄位)。


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 | null
await h.image("avatar-u1").bytes(); // ReadableStream | null
await h.image("avatar-u1").update({ ... });
await h.image("avatar-u1").delete(); // boolean

changelog(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
}

三個觀察:

  1. 本機會產生 __cf_local/imagedelivery/ 的 variant URL —— 可以真的拿來測前端。
  2. details() 對不存在的 ID 回傳 null 而不是 throw,與型別 Promise<ImageMetadata | null> 一致。這比丟例外好處理,但也代表你必須檢查 null
  3. 上傳 PNG 時 filename 被填成 uploaded.jpg —— 一個預設值,和實際格式無關。別依賴它。

⚠️ hosted 需要付費的 Images 方案(含 storage)。 官方 storage/binding 頁:「Hosted image operations require a paid Images plan with storage」、「這些呼叫計入你的 storage 額度,方式與使用 REST API 或 dashboard 相同」。

changelog 本身沒有提到方案要求,只有文件頁有。本機模擬會讓你完全感覺不到這件事。


如果你把一個做轉換的 Worker 掛在 /*(或任何會涵蓋原圖路徑的 route),Worker 發出的取原圖請求會再次打到自己

官方警告:如果 Worker 處理的路徑與圖片實際所在位置重疊,「it could cause an infinite loop by the Worker trying to request images from itself」。

官方的解法是檢查 Via header:

「當 Via header 裡出現 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;

這是本章最需要更新認知的地方。

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 頁只拿來看費率。

「per unique transformation」改變了正確的設計:

  • 以前:必須自己在前面加一層快取(R2 或 Cache API),否則每個請求都是一次計費。
  • 現在:同月同參數只算一次,所以平台已經幫你做了去重。你的快取層變成純粹的延遲最佳化,不再是成本剛需。

但兩件事沒變:

  1. 尺寸必須白名單。 計費單位是「unique」,所以攻擊面是「產生大量不同的參數組合」。?w=1?w=2?w=3… 一萬個請求就是一萬次轉換。
  2. 仍然值得快取。 去重是按月算的,而且轉換本身有延遲。範例的 R2 快取模式(第 12 章)依然是對的。

限制
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() 正是用來偵測它的。

頭像:上傳時正規化,交付時只挑尺寸

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/月。如果沒有白名單,同樣的流量可以輕易變成幾百萬次。

第 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 毫秒的事。


#結論影響
12026-07-01 起 binding 改為按 unique transformation 計費,同月同參數只算一次可以放在熱路徑上了
2.info() 不再計費「先檢查再決定」變成零成本策略
3官方 pricing 頁仍寫著改版前的舊規則,與 changelog 直接矛盾以 changelog 與 binding 頁為準
4Unique transformation 每月含 5,000;超出 $0.50/1,000;Free 也有 5,000(超出回 9422,不計費)
5binding 是真正的本地實作(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 路徑不要把原始錯誤回傳給使用者
10hosted CRUD 於 2026-06-10 新增,本機也有模擬(含 __cf_local/imagedelivery/ variant URL)Worker 裡不再需要 API token
11hosted 需要付費 Images 方案(含 storage),但 changelog 沒提,只有文件頁有本機模擬完全感覺不到
12details() 對不存在的 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)結構上不可能迴圈更好的解法
18binding .input() 上限只有 20 MB,遠端來源是 100 MB、hosted 是 10 MB手機原始照片很容易超過
19AVIF 最大邊長 1,200 px,其他格式 12,000 px大尺寸 AVIF 做不到
20SVG 不會被縮放,參數會被忽略;但會經 svg-hush 清洗偵測到就放行
21超過 50 MP 的動畫原樣交付,不套用任何轉換靜默降級
22Polish 與 Transformations 是不同產品:原地壓縮 vs 產生新 URL官方明說 Images 處理過的圖不需要 Polish
23「Image Resizing」產品名已退役;/images/transform-images/* 轉址到 /images/optimization/*舊教學的路徑對不上
24官方沒有 binding vs cf.image 的比較指南本章判準:URL 來源用 cf.image,位元組來源用 binding

  1. 對同一張圖分別用 wrangler devwrangler dev --remote 跑同一組轉換,比較輸出位元組大小 —— 這是 31.3 那個「本機低保真」的實證。
  2. fit: "cover" 換成一個本機不支援的參數(例如 blur),確認本機是靜默忽略還是報錯。
  3. 寫一個沒有尺寸白名單的交付端點,用腳本打 1,000 個不同的 ?w=,然後在 dashboard 上看 unique transformation 的計數 —— 用小規模驗證 31.6 的攻擊面。
  4. 上傳一個 25 MB 的圖到 .input(),記錄實際的錯誤形式(20 MB 上限)。
  5. .info() + .tee() 實作 31.8 的上傳守門邏輯,並確認 .info() 確實沒有增加轉換計數。

下一章(第 32 章):Email —— Email Routing 的收信 Worker、send_email binding 的寄信路徑,以及為什麼「用 Workers 寄行銷信」是個壞主意。