diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.i18n.yaml
index 344499741c..e2e60901af 100644
--- a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.i18n.yaml
+++ b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md
-2026-08-24-alpha-routed-image-quality-ladders.md: 16b3213268999d846f0b2a5ab63b3f5ae4adc332
-2026-08-24-alpha-routed-image-quality-ladders.zh.md: dde9ba60996cdfb779a6a1a88810fec608bc0609
+2026-08-24-alpha-routed-image-quality-ladders.md: cdf5e7bd908782781e66a4f6494428f0f174e258
+2026-08-24-alpha-routed-image-quality-ladders.zh.md: fbe8215b0ca1c606cd66d60fe1109d2f9aa1cbad
diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md
index 16b3213268..cdf5e7bd90 100644
--- a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md
+++ b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md
@@ -10,15 +10,17 @@ Image normalization and request-image encoding in `@deepseek-ai/dsh-attachment-l
## Decision
-Both encoders route by one decoded fact only: sources with an alpha channel encode as lossy WebP at effort 0, opaque sources as JPEG (libjpeg-turbo), each down a shared quality ladder of 85, 75, 60 (`IMAGE_ENCODING_QUALITIES` / `WEBP_ENCODING_EFFORT` in `normalization.ts`). The colour-count classifier and the palette PNG branch are deleted, not repaired, so the misclassification bug class cannot recur and no image pays the classification decode. `normalizedImageMaxBytes` and the route `maxBytes` become ladder targets rather than caps: the ladder still stops at the first quality that fits, but when every quality exceeds the target the smallest output is kept and the downscale retry loop is gone. Provider byte limits (DeepSeek 32MiB per image, inline budgets) remain enforced where the bytes are transmitted. The request transform version moves to `request-image-v5`, so existing cached variants regenerate by identity; content-addressed masters stay valid without migration. The request cache read no longer rejects entries above the byte target, since a ladder-exhausted output is the deterministic result for its variant id.
+Both encoders route by one decoded fact only: sources with an alpha channel encode as lossy WebP at effort 0, opaque sources as JPEG (libjpeg-turbo), each down a shared quality ladder of 85, 75, 60 (`IMAGE_ENCODING_QUALITIES` / `WEBP_ENCODING_EFFORT` / `encodingLadder` in `encoding.ts`). The colour-count classifier and the palette PNG branch are deleted, not repaired, so the misclassification bug class cannot recur and no image pays the classification decode. `normalizedImageMaxBytes` and the route `maxBytes` become ladder targets rather than caps: the ladder still stops at the first quality that fits, but when every quality exceeds the target the smallest output is kept and the downscale retry loop is gone. Provider byte limits (DeepSeek 32MiB per image, inline budgets) remain enforced where the bytes are transmitted. Master dimensions move from a long-edge rule to a total-pixel budget: `normalizedImageMaxPixels` (default 2048x2048) scales the raster proportionally and `normalizedImageMaxDimension` (default 8192, matching the admission per-side cap) clamps the long edge afterwards, so extreme aspect ratios such as tall page screenshots keep their short-edge resolution (a 2000x20000 source keeps about 647px of width instead of 204px) while square sources normalize exactly as before. The request transform version moves to `request-image-v5`, so existing cached variants regenerate by identity; content-addressed masters stay valid without migration. The request cache read no longer rejects entries above the byte target, since a ladder-exhausted output is the deterministic result for its variant id.
Pareto measurements over the issue #2885 reproduction set (PR #2989 appendices) back the choice: on photographic content JPEG is one to two orders of magnitude faster than every alternative, and WebP at effort 0 matches palette PNG's size on graphics content while never being misrouted; uniform-noise worst cases fit the default 4MiB/1MiB targets at quality 85 for opaque sources, and only an adversarial random-alpha plane exhausts the WebP ladder (about 6.3MiB, five times under the provider cap).
+This decision partially supersedes the [unified image request pipeline note](../feature/2026-08-20-unified-image-request-pipeline.md), whose normalization and request-encoding sections now describe this routing; its durable-version split, Files lifecycle, and offload projection stand unchanged.
+
## Alternatives considered
**Repair the classifier (higher-resolution sampling, gradient statistics) and keep palette PNG.** Rejected: any content classifier retains a misrouting class and the per-image classification decode; palette PNG's only frontier niche (graphics) is matched by WebP at a fraction of the encode time.
-**A single WebP ladder for everything.** Rejected in review discussion: JPEG is four to six times faster on opaque photographic content, the dominant real workload, and the alpha probe is a metadata read costing nothing.
+**A single WebP ladder for everything.** Rejected: JPEG is four to six times faster on opaque photographic content, the dominant real workload, and the alpha probe is a metadata read costing nothing.
**Keep the downscale retry loop for ladder-exhausted outputs.** Rejected: measured worst cases show the loop is dead code within default budgets, and its only reachable effect was degrading adversarial inputs to 1×1 before erroring.
@@ -27,5 +29,5 @@ Pareto measurements over the issue #2885 reproduction set (PR #2989 appendices)
- Opaque low-colour graphics (charts, text screenshots) now store as JPEG: two to three times larger than palette PNG in the hundreds-of-kilobytes range, with JPEG ringing on hard edges; the model-visible request version was already dominated by pixel-budget downscaling, so legibility impact is marginal. Reintroducing a graphics codec would add a WebP step to the opaque ladder, not restore classification.
- GIF sources decode with an alpha plane under gifload, so still-frame GIFs normalize onto the WebP ladder.
- `IMAGE_TOO_LARGE` no longer arises from encoding; it remains the admission error for oversized sources.
-- A ladder-exhausted attachment can exceed its byte target on disk and on the wire until a provider cap rejects it; measured reachable only with adversarial random-alpha input.
+- A ladder-exhausted attachment can exceed its byte target on disk and on the wire until a provider cap rejects it; measured reachable only with adversarial random-alpha input. Re-submitting such an over-target master as a new upload fails the pass-through byte check and re-encodes it down the lossy ladder again, so normalization is not idempotent for this adversarial-only class and each round adds generation loss.
- Test evidence: `packages/attachment/attachment-local/tests` pins the routing, ladder-exhaustion, and readable-text behavior against real encoders, including the issue #2885 misrouting characteristics (high-frequency photographic content leaving the slow path).
diff --git a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md
index dde9ba6099..fbe8215b0c 100644
--- a/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md
+++ b/.agents/notes/implemented/bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md
@@ -10,15 +10,17 @@ Status: implemented
## 决定
-两个编码器只按一个解码事实路由:带 alpha 通道的源图编码为 effort 0 的有损 WebP,不透明源图编码为 JPEG(libjpeg-turbo),共用质量阶梯 85、75、60(`normalization.ts` 的 `IMAGE_ENCODING_QUALITIES` / `WEBP_ENCODING_EFFORT`)。色数分类器和 palette PNG 分支被删除而不是修复,误判这一 bug 类别因此不可能复发,也不再有图片付出分类解码成本。`normalizedImageMaxBytes` 和路由 `maxBytes` 的语义从上限改为阶梯目标:阶梯仍在第一个装得下的质量档停下,但全部档位都超过目标时保留最小产物,缩图重试循环被删除。提供方字节硬限制(DeepSeek 单图 32MiB、inline 预算)仍在传输字节的位置执行。请求变换版本升到 `request-image-v5`,已有变体缓存按身份自然重建;内容寻址的 master 无需迁移,继续有效。请求缓存读取不再拒绝超过字节目标的条目,因为阶梯耗尽的产物就是该 variant id 的确定性结果。
+两个编码器只按一个解码事实路由:带 alpha 通道的源图编码为 effort 0 的有损 WebP,不透明源图编码为 JPEG(libjpeg-turbo),共用质量阶梯 85、75、60(`encoding.ts` 的 `IMAGE_ENCODING_QUALITIES` / `WEBP_ENCODING_EFFORT` / `encodingLadder`)。色数分类器和 palette PNG 分支被删除而不是修复,误判这一 bug 类别因此不可能复发,也不再有图片付出分类解码成本。`normalizedImageMaxBytes` 和路由 `maxBytes` 的语义从上限改为阶梯目标:阶梯仍在第一个装得下的质量档停下,但全部档位都超过目标时保留最小产物,缩图重试循环被删除。提供方字节硬限制(DeepSeek 单图 32MiB、inline 预算)仍在传输字节的位置执行。master 尺寸规则从长边上限改为总像素预算:`normalizedImageMaxPixels`(默认 2048×2048)按比例缩放,`normalizedImageMaxDimension`(默认 8192,与准入单边上限一致)随后夹住长边,因此长页面截图这类极端长宽比保留短边分辨率(2000×20000 的源图短边保留约 647px 而不是 204px),正方形源图的规范化结果与之前完全一致。请求变换版本升到 `request-image-v5`,已有变体缓存按身份自然重建;内容寻址的 master 无需迁移,继续有效。请求缓存读取不再拒绝超过字节目标的条目,因为阶梯耗尽的产物就是该 variant id 的确定性结果。
对 issue #2885 复现集的 Pareto 实测(PR #2989 附录)支撑这个选择:摄影类内容上 JPEG 比其余所有编码器快 1 至 2 个数量级,effort 0 的 WebP 在图形类内容上体积与 palette PNG 相当且不会被误判;均匀噪声最坏输入在不透明链的 q85 一档即落入默认 4MiB/1MiB 目标,只有对抗性的随机 alpha 平面会耗尽 WebP 阶梯(约 6.3MiB,距提供方上限还有 5 倍)。
+本决定部分取代[统一图片请求管线记录](../feature/2026-08-20-unified-image-request-pipeline.zh.md):其规范化与请求编码章节现在以本路由为准;其耐久版本拆分、Files 生命周期与卸载投影不变。
+
## 考虑过的替代方案
**修复分类器(提高采样分辨率、加入梯度统计)并保留 palette PNG。** 否决:任何内容分类器都保留一类误判和每张图的分类解码成本;palette PNG 唯一的前沿生态位(图形类)WebP 用远少的编码时间即可达到。
-**全部走单一 WebP 阶梯。** 在评审讨论中否决:JPEG 在不透明摄影内容(真实负载的大头)上快 4 至 6 倍,而 alpha 探测只是零成本的元数据读取。
+**全部走单一 WebP 阶梯。** 否决:JPEG 在不透明摄影内容(真实负载的大头)上快 4 至 6 倍,而 alpha 探测只是零成本的元数据读取。
**为阶梯耗尽的产物保留缩图重试循环。** 否决:实测最坏情况表明该循环在默认预算内是死代码,其唯一可达效果是把对抗性输入一路缩到 1×1 再报错。
@@ -27,5 +29,5 @@ Status: implemented
- 不透明的低色数图形(图表、文字截图)现在存为 JPEG:在几百 KB 量级上比 palette PNG 大 2 至 3 倍,锐利边缘有 JPEG 振铃;模型可见的请求版本本就被像素预算缩尺寸主导,可读性影响很小。将来若需要图形类专用编码,正确做法是给不透明阶梯加一档 WebP,而不是恢复分类。
- GIF 源图经 gifload 解码后带 alpha 平面,因此静帧 GIF 规范化走 WebP 阶梯。
- `IMAGE_TOO_LARGE` 不再产生于编码环节;它仍是超大源图的准入错误。
-- 阶梯耗尽的附件可能以超过字节目标的大小落盘和上行,直到提供方上限拒绝;实测只有对抗性随机 alpha 输入可达。
+- 阶梯耗尽的附件可能以超过字节目标的大小落盘和上行,直到提供方上限拒绝;实测只有对抗性随机 alpha 输入可达。把这样的超目标 master 再次作为新上传提交时,直通的字节检查不通过,会再走一遍有损阶梯,因此规范化对这一仅对抗性可达的类别不幂等,每轮都会累积代际损失。
- 测试证据:`packages/attachment/attachment-local/tests` 用真实编码器钉住路由、阶梯耗尽和文字可读性行为,包括 issue #2885 误判特征(高频摄影内容离开慢路径)。
diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml
index 66ba6d1139..55331d4816 100644
--- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md
-2026-08-20-unified-image-request-pipeline.md: 85296f1d8bd7d6ee458f8bc4230e52f1d3128bff
-2026-08-20-unified-image-request-pipeline.zh.md: bcbc0110001e9f974e57a456140e9ecdf4eb888c
+2026-08-20-unified-image-request-pipeline.md: 1c7a8040232bab0d73f578a9170e235c2f175c07
+2026-08-20-unified-image-request-pipeline.zh.md: af6497f5a06018b87d166842615f6b1e12b1aed4
diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md
index 85296f1d8b..1c7a804023 100644
--- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md
+++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.md
@@ -14,17 +14,17 @@ The image path has two explicit versions. The attachment backend owns a provider
### Provider-independent normalized attachment
-Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source is fully decoded under configurable 20MiB, 64,000,000-pixel, and 8192px-per-side limits. Normalization applies EXIF orientation, removes metadata and color profiles, converts to 8-bit sRGB/sRGBA, and preserves aspect ratio while limiting the long edge to `normalizedImageMaxDimension`, 2048px by default. When scaling reduces the raster, `originalDimensions` records its orientation-applied width and height before normalization.
+Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source is fully decoded under configurable 20MiB, 64,000,000-pixel, and 8192px-per-side limits. Normalization applies EXIF orientation, removes metadata and color profiles, converts to 8-bit sRGB/sRGBA, and preserves aspect ratio while scaling into the `normalizedImageMaxPixels` total-pixel budget (2048x2048 by default) under a `normalizedImageMaxDimension` long-edge cap (8192px by default). When scaling reduces the raster, `originalDimensions` records its orientation-applied width and height before normalization.
-The normalized attachment has an independent `normalizedImageMaxBytes` safety cap, 4MiB by default. Alpha is never flattened. A nearest-neighbour bounded sample classifies color complexity without averaging high-frequency pixels. Confirmed low-color input tries PNG, with palette encoding only when no alpha channel is present, followed by WebP qualities 85, 80, and 75. Other alpha input tries WebP at those qualities; other opaque input tries JPEG. Candidates execute in order and stop at the first result within the cap. Dimensions shrink only after every candidate at one size exceeds the cap. The source extension does not classify a PNG as low color. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP within both normalization limits passes through byte-identically and retains content-addressed deduplication. GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. The source and a converted output are each fully decoded once; the output must match its format, dimensions, depth, color space, and alpha facts before its digest enters the reference.
+The normalized attachment has an independent `normalizedImageMaxBytes` encoded-byte target, 4MiB by default. Alpha is never flattened. Codec routing is by the decoded alpha fact alone — alpha input encodes as WebP (effort 0) and opaque input as JPEG, each down the shared 85/75/60 quality ladder; when every quality exceeds the target the smallest output is kept, per the superseding [alpha-routed quality ladders note](../bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md). A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP within every normalization limit passes through byte-identically and retains content-addressed deduplication. GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. The source and a converted output are each fully decoded once; the output must match its format, dimensions, depth, color space, and alpha facts before its digest enters the reference.
Batch admission prepares and verifies every normalized attachment once before publishing any member. Validation failure starts no writes. Publication uses those prepared bytes directly, so a large batch does not repeat full decoding and encoding during commit. A later storage failure returns no partial references; already published immutable objects may remain unreachable under the existing storage rule.
### Deterministic request versions
-`AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and 1MiB raw encoded bytes by default. Its catalog uses one `imagePixelBudget` field: a positive integer selects an exact total-pixel budget, `low` selects 512 by 512 total pixels, and omission selects the route default. A 2048 by 1024 normalized attachment projects to 1130 by 565 under the hard cap. Request encoding uses the same color branches, with PNG (palette only without alpha) then WebP 85 and 80 for low-color input, WebP 85 then 80 for other alpha input, and JPEG 85 then 80 for other opaque input. Each fallback runs only after the previous result exceeds 1MiB, and dimensions shrink only after both quality attempts exceed it. The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams.
+`AttachmentStore.readImageRequest` derives a request version under route-owned total-pixel and encoded-byte budgets. Scaling is `min(1, sqrt(maxPixels / (width * height)))`, with no enlargement, followed by inward integer rounding so the encoded raster never exceeds the total-pixel cap. DeepSeek V4 Flash Vision Exp uses 640,000 total pixels and a 1MiB raw encoded-byte target by default. Its catalog uses one `imagePixelBudget` field: a positive integer selects an exact total-pixel budget, `low` selects 512 by 512 total pixels, and omission selects the route default. A 2048 by 1024 normalized attachment projects to 1130 by 565 under the hard cap. Request encoding uses the same alpha routing and 85/75/60 quality ladder as normalization, executed lazily; a target no quality meets keeps the smallest ladder output (see the [alpha-routed quality ladders note](../bug-fix/2026-08-24-alpha-routed-image-quality-ladders.md)). The same derivation is used by normal agent turns, direct `ctx.llm.stream` calls, compaction, and other auxiliary streams.
-The `variantId` and cache path cover the normalized attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, alpha, and byte limits without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the normalized attachment byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. Callers preserve order by applying `Promise.all` to singular `readImageRequest` calls. The local implementation runs normalization and request transforms through one FIFO limiter; `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every normalized attachment has been prepared.
+The `variantId` and cache path cover the normalized attachment id, transform version, route pixel and byte budgets, and fixed encoder parameters. A new cache entry is fully decoded before publication. Cache hits use a header probe to check format, 8-bit sRGB/sRGBA facts, dimensions, and alpha without decoding the complete raster again; a mismatch regenerates the entry. DeepSeek Files and pi-ai inline base64 therefore use the same deterministic bytes for the same policy. Inline accounting uses the derived byte length after base64 expansion, not the normalized attachment byte count. Equal in-process `variantId` calls share one transform and cache write. Each caller can cancel its own wait; the shared transform is aborted only after every waiter has cancelled. Callers preserve order by applying `Promise.all` to singular `readImageRequest` calls. The local implementation runs normalization and request transforms through one FIFO limiter; `imageCompressionConcurrency` is configurable from 1 through 8 and defaults to 2. Batch publication remains sequential after every normalized attachment has been prepared.
Request-size offload is a deterministic oldest-first projection. Before reading attachments, each route uses `min(attachmentBytes, requestVersionMaxBytes)` as a conservative upper bound and removes the oldest over-budget prefix. Only retained attachments are read and transformed, so an omitted missing or corrupt object cannot block the request. A second projection uses exact derived lengths without bringing omitted images back. DeepSeek defaults to 128MiB and 600 referenced images. Its removed prefix advances past successive 64MiB byte boundaries and in 20-image count quanta, so 129 one-megabyte images remove the oldest 65, retain 64MiB, and keep that prefix stable until total history passes 192MiB. Pi-ai retains a configurable base64 request bound. Each omitted image becomes a per-image placeholder that retains its identity and access resolved for the current tool execution world, including nested tool-result images, while append-only session history keeps the original references.
@@ -62,7 +62,7 @@ Historical attachment objects that later disappear or fail integrity verificatio
## Verification
-Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, distinguish high-frequency and ordinary photos from low-color graphics, stop lazy encoding after the first fitting candidate, cover square and wide 640,000-pixel projections, enforce 1MiB request bytes, singleflight equal variants and uploads without shared-cancellation leaks, bound transform concurrency, preserve cache and upload identity, skip attachment reads for conservatively offloaded history, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, fall back to bounded all-inline requests after file resolution failure, paginate before quota deletion, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry.
+Package tests generate 16-bit RGB and RGBA PNG fixtures, prove 8-bit conversion and clean 8-bit passthrough, retain alpha under byte pressure, stop lazy encoding after the first fitting candidate, keep the smallest ladder output above an unreachable byte target, cover square and wide 640,000-pixel projections, singleflight equal variants and uploads without shared-cancellation leaks, bound transform concurrency, preserve cache and upload identity, skip attachment reads for conservatively offloaded history, prepare batches once, reject inconsistent Files responses, refresh near-expiry ids without retrieve, recover once from single-id, multiple-id, and ambiguous stale responses, fall back to bounded all-inline requests after file resolution failure, paginate before quota deletion, normalize provider diagnostics, project text-only history, and share normal/compaction request bytes. Keyless assembled snapshots cover the real tool schemas and image request path. A credentialed test uses the built-in `deepseek-official` route and its configured endpoint, never a custom provider entry.
## Consequences
diff --git a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md
index bcbc011000..af6497f5a0 100644
--- a/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-20-unified-image-request-pipeline.zh.md
@@ -14,17 +14,17 @@ Status: implemented
### 提供方无关的规范化附件
-每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图会在可配置的 20MiB、64,000,000 像素和单边 8192px 限制内完整解码。规范化过程会应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`,默认 2048px。缩放减小光栅时,`originalDimensions` 记录规范化之前、应用方向之后的输入宽高。
+每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图会在可配置的 20MiB、64,000,000 像素和单边 8192px 限制内完整解码。规范化过程会应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比缩放进 `normalizedImageMaxPixels` 总像素预算(默认 2048×2048),随后受 `normalizedImageMaxDimension` 长边上限约束(默认 8192px)。缩放减小光栅时,`originalDimensions` 记录规范化之前、应用方向之后的输入宽高。
-规范化附件有独立的 `normalizedImageMaxBytes` 安全上限,默认 4MiB。透明通道绝不铺平。系统通过 nearest-neighbour 对有界样本判断色彩复杂度,不会通过像素平均把高频图片误判为低色数。确认的低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80、75 的 WebP;其他透明输入依次尝试这些质量的 WebP;其他非透明输入依次尝试这些质量的 JPEG。候选按顺序执行,首个不超过上限的结果会立即返回。同一尺寸的候选全部超限后才会缩小尺寸。源扩展名不会把 PNG 归类为低色数图片。处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通,并保留内容寻址去重。GIF、动图、元数据、方向、16-bit PNG 和不兼容色彩空间都会触发转换。源图和转换输出各完整解码一次;输出的格式、尺寸、位深、色彩空间和透明通道事实通过校验后,其摘要才会进入引用。
+规范化附件有独立的 `normalizedImageMaxBytes` 编码字节目标,默认 4MiB。透明通道绝不铺平。编码路由只看解码出的 alpha 事实:透明输入编码为 WebP(effort 0),非透明输入编码为 JPEG,共用 85/75/60 质量阶梯;全部档位都超过目标时保留最小产物,见取代本节的[按 alpha 路由的质量阶梯记录](../bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md)。处于全部规范化限制内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通,并保留内容寻址去重。GIF、动图、元数据、方向、16-bit PNG 和不兼容色彩空间都会触发转换。源图和转换输出各完整解码一次;输出的格式、尺寸、位深、色彩空间和透明通道事实通过校验后,其摘要才会进入引用。
批量准入在发布任何成员前,为每张图片各准备并验证一次规范化附件。校验失败不会开始写入。发布直接使用这些已准备字节,因此大批次不会在提交时重复完整解码和编码。之后发生的存储失败不会返回部分引用;按现有存储规则,已经发布的不可变对象可能保持不可达。
### 确定性请求版本
-`AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节 1MiB。其 catalog 只使用一个 `imagePixelBudget` 字段:正整数选择确切总像素预算,`low` 选择总像素 512×512,省略时使用路由默认值。2048×1024 规范化附件在这个硬上限下会投影为 1130×565。请求编码使用相同的分类分支:低色数输入先尝试 PNG,只有不带 alpha 通道时才使用 palette,随后依次尝试质量 85、80 的 WebP;其他透明输入依次尝试质量 85、80 的 WebP;其他非透明输入依次尝试质量 85、80 的 JPEG。只有前一结果超过 1MiB 时才执行下一个候选;两个质量档都超限后才缩小尺寸。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。
+`AttachmentStore.readImageRequest` 按路由拥有的总像素和编码字节预算派生请求版本。缩放公式为 `min(1, sqrt(maxPixels / (width * height)))`,不会放大小图,随后向预算内取整,确保编码光栅不超过总像素上限。DeepSeek V4 Flash Vision Exp 默认使用总像素 640,000 和原始编码字节目标 1MiB。其 catalog 只使用一个 `imagePixelBudget` 字段:正整数选择确切总像素预算,`low` 选择总像素 512×512,省略时使用路由默认值。2048×1024 规范化附件在这个硬上限下会投影为 1130×565。请求编码与规范化共用同一套 alpha 路由和 85/75/60 质量阶梯,按需执行;没有任何档位达到目标时保留最小产物(见[按 alpha 路由的质量阶梯记录](../bug-fix/2026-08-24-alpha-routed-image-quality-ladders.zh.md))。普通 agent 轮次、直接 `ctx.llm.stream` 调用、压缩和其他辅助流都使用同一派生过程。
-`variantId` 和缓存路径覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸、透明通道和字节上限,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用规范化附件字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。调用方对单数 `readImageRequest` 使用 `Promise.all` 保持结果顺序。本地实现通过一个 FIFO 限流器运行规范化和请求变换,`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部规范化附件准备完成后,批次仍按顺序发布。
+`variantId` 和缓存路径覆盖规范化附件 ID、变换策略版本、路由像素和字节预算及固定编码参数。新缓存条目在发布前会完整解码。缓存命中只探测文件头,校验格式、8-bit sRGB/sRGBA、尺寸和透明通道,不会再次完整解码光栅;不匹配时会重新生成。因此,同一策略下的 DeepSeek Files 和 pi-ai 内联 base64 使用相同的确定性字节。内联计量使用派生字节经过 base64 膨胀后的长度,不使用规范化附件字节数。同一进程内相同 `variantId` 的调用共享一次变换和缓存写入。每个调用方可以取消自己的等待;只有全部等待方都取消时,共享变换才会中止。调用方对单数 `readImageRequest` 使用 `Promise.all` 保持结果顺序。本地实现通过一个 FIFO 限流器运行规范化和请求变换,`imageCompressionConcurrency` 的可配置范围为 1 至 8,默认值为 2。全部规范化附件准备完成后,批次仍按顺序发布。
请求大小 offload 是确定性的从旧到新投影。读取附件前,每条路由先以 `min(附件字节数, 请求版本字节上限)` 作为保守上界,移除超出预算的最旧前缀。系统只读取并转换保留的附件,因此已省略的缺失或损坏对象不会阻塞请求。第二次投影使用确切派生长度,但不会重新加入已省略图片。DeepSeek 默认上限为 128MiB 和 600 张引用图片。被移除前缀会越过连续的 64MiB 字节边界,并按 20 张图片数量步长递增,因此 129 张 1MiB 图片会移除最旧的 65 张并保留 64MiB;持久历史超过 192MiB 前,该前缀保持不变。Pi-ai 保留可配置的 base64 请求上限。每张省略图片都会变成逐图占位文本,保留自己的身份和本次工具执行环境解析出的访问方式,嵌套工具结果图片也使用相同规则;追加式会话历史继续保留原始引用。
@@ -62,7 +62,7 @@ Status: implemented
## Verification
-包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、区分高频和普通照片与低色数图形、首个候选合规后停止编码、正方形和宽屏 640,000 像素投影、请求字节不超过 1MiB、相同变体与上传 singleflight 且不会共享取消、变换并发上限、缓存与上传身份、跳过已保守 offload 的历史附件读取、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、文件解析失败后回退到有界全内联请求、删除配额文件前完成分页、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。
+包测试会生成 16-bit RGB 和 RGBA PNG fixture,验证 8-bit 转换与干净 8-bit 字节直通、字节压力下保留透明通道、首个候选合规后停止编码、字节目标不可达时保留最小阶梯产物、正方形和宽屏 640,000 像素投影、相同变体与上传 singleflight 且不会共享取消、变换并发上限、缓存与上传身份、跳过已保守 offload 的历史附件读取、批量只准备一次、Files 响应不一致、进入刷新余量时不查询远端并更新 ID、单个 ID、多个 ID 和模糊失效响应只恢复一次、文件解析失败后回退到有界全内联请求、删除配额文件前完成分页、规范化提供方诊断、纯文本投影,以及普通请求与压缩共享请求字节。无需密钥的组装快照覆盖真实工具 schema 和图片请求路径。使用凭据的测试只使用内置 `deepseek-official` 路由及其已配置端点,不使用自定义提供方条目。
## Consequences
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index 9c6eb14a09..de34a153cf 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: bbcfbef6dc98f8739c825bca0b2807ca58a1d144
-config-catalog.zh.md: f1db1c5b7d78ee32fd9a0e0d22bcdf8ebb17abb1
+config-catalog.md: 46228907ea4241e69a4439c2dc1c1786de94eaff
+config-catalog.zh.md: ed09a3d44f93815ab667ef4613249a33306d9899
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index bbcfbef6dc..46228907ea 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -308,7 +308,9 @@ export interface Config {
maxImagePixels?: number
/** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */
maxImageDimension?: number
- /** Long-edge pixel cap of the stored provider-independent normalized image. */
+ /** Total-pixel budget of the stored provider-independent normalized image. */
+ normalizedImageMaxPixels?: number
+ /** Long-edge pixel cap of the stored provider-independent normalized image, applied after the total-pixel budget. */
normalizedImageMaxDimension?: number
/**
* Encoded-byte target of the stored provider-independent normalized image;
@@ -320,7 +322,7 @@ export interface Config {
}
```
-Source: [`packages/attachment/attachment-local/src/index.ts:51`](../packages/attachment/attachment-local/src/index.ts)
+Source: [`packages/attachment/attachment-local/src/index.ts:55`](../packages/attachment/attachment-local/src/index.ts)
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index f1db1c5b7d..ed09a3d44f 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -310,7 +310,9 @@ export interface Config {
maxImagePixels?: number
/** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */
maxImageDimension?: number
- /** Long-edge pixel cap of the stored provider-independent normalized image. */
+ /** Total-pixel budget of the stored provider-independent normalized image. */
+ normalizedImageMaxPixels?: number
+ /** Long-edge pixel cap of the stored provider-independent normalized image, applied after the total-pixel budget. */
normalizedImageMaxDimension?: number
/**
* Encoded-byte target of the stored provider-independent normalized image;
@@ -322,7 +324,7 @@ export interface Config {
}
```
-来源:[`packages/attachment/attachment-local/src/index.ts:51`](../packages/attachment/attachment-local/src/index.ts)
+来源:[`packages/attachment/attachment-local/src/index.ts:55`](../packages/attachment/attachment-local/src/index.ts)
diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml
index 195f6acaf3..8e69eaae28 100644
--- a/docs/subsystems/attachment.i18n.yaml
+++ b/docs/subsystems/attachment.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/attachment.md
-attachment.md: aa29c5fc6c011f31fbdb3d4fb9f218d0801e5205
-attachment.zh.md: 9ee90794d28f7cfa0d7b30986aa33d6cbc298f8f
+attachment.md: 15daa2b8d541ba847d48f5c43a06c1c537df6d11
+attachment.zh.md: c74a18f6bec63e117afcdb141512be04f8d75f9a
diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md
index aa29c5fc6c..15daa2b8d5 100644
--- a/docs/subsystems/attachment.md
+++ b/docs/subsystems/attachment.md
@@ -187,7 +187,7 @@ imageHostPath(ref: ImageAttachmentRef): string | undefined
/**
* Generate or read one deterministic model-request version from the stored normalized image.
* @param ref - durable provider-independent normalized attachment reference.
- * @param policy - exact route pixel and encoded-byte budget.
+ * @param policy - exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output.
* @param signal - optional cancellation.
* @returns request bytes and the cache/upload identity covering every transform input.
*/
diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md
index 9ee90794d2..c74a18f6be 100644
--- a/docs/subsystems/attachment.zh.md
+++ b/docs/subsystems/attachment.zh.md
@@ -187,7 +187,7 @@ imageHostPath(ref: ImageAttachmentRef): string | undefined
/**
* Generate or read one deterministic model-request version from the stored normalized image.
* @param ref - durable provider-independent normalized attachment reference.
- * @param policy - exact route pixel and encoded-byte budget.
+ * @param policy - exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output.
* @param signal - optional cancellation.
* @returns request bytes and the cache/upload identity covering every transform input.
*/
diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml
index 2828e95d6a..353d5d1d7b 100644
--- a/packages/attachment/attachment-local/README.i18n.yaml
+++ b/packages/attachment/attachment-local/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/attachment/attachment-local/README.md
-README.md: dd779f5e9af7d97d871134296ac95d5536649b22
-README.zh.md: 0c92838c91b2e72eb80b34c0b9780f685642ceed
+README.md: 7bd0283e7f922ecfa2be4b67e296f5c0016f4302
+README.zh.md: 6e8ad367cf7e81f373c112e27cace07491638e0f
diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md
index dd779f5e9a..7bd0283e7f 100644
--- a/packages/attachment/attachment-local/README.md
+++ b/packages/attachment/attachment-local/README.md
@@ -4,9 +4,9 @@ English | [中文](README.zh.md)
The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root. Writes use a private staging directory, a synced temporary file, an atomic exclusive hard-link publish, owner-read-only object permissions, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash.
-Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent normalized attachment. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the long edge is reduced proportionally to `normalizedImageMaxDimension` (2048px by default). The normalized attachment has its own `normalizedImageMaxBytes` encoded-byte target (4MiB by default). Transparent pixels are retained; Sharp/libvips may omit an alpha plane whose samples are all opaque. Sources with an alpha channel encode as WebP (effort 0) and opaque sources as JPEG, both on the quality ladder 85, 75, 60. Each ladder step runs only after the preceding step exceeds the target, and when every step exceeds it the smallest output is kept; provider byte caps stay enforced by the route that transmits the bytes. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both normalization limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and converted attachment are each fully decoded once. `saveImages` prepares and verifies every normalized attachment once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding.
+Admission accepts at most 20 images and 200MiB of encoded source bytes per message. Each source may use up to 20MiB, 64,000,000 pixels, and 8192px per side. It then prepares a provider-independent normalized attachment. EXIF orientation is applied, metadata and color profiles are removed, pixels become 8-bit sRGB/sRGBA, and the raster is reduced proportionally to the `normalizedImageMaxPixels` total-pixel budget (2048x2048 by default) with a `normalizedImageMaxDimension` long-edge cap (8192px by default), so extreme aspect ratios keep their short-edge resolution instead of collapsing under a long-edge rule. The normalized attachment has its own `normalizedImageMaxBytes` encoded-byte target (4MiB by default). Transparent pixels are retained; Sharp/libvips may omit an alpha plane whose samples are all opaque. Sources with an alpha channel encode as WebP (effort 0) and opaque sources as JPEG, both on the quality ladder 85, 75, 60. Each ladder step runs only after the preceding step exceeds the target, and when every step exceeds it the smallest output is kept; provider byte caps stay enforced by the route that transmits the bytes. A clean, single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP already within both normalization limits passes through byte-identically; 16-bit PNG, GIF, animated input, metadata, orientation, and incompatible color spaces force conversion. The source and converted attachment are each fully decoded once. `saveImages` prepares and verifies every normalized attachment once before publishing the batch, so validation failure leaves no partial references and commit does not repeat full image encoding.
-Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then applies a separate encoded-byte target. The request encoder uses the same alpha routing and quality ladder as normalization, WebP (effort 0) at 85, 75, 60 for alpha sources and JPEG at those qualities for opaque sources, executed lazily and keeping the smallest output when every quality exceeds the target. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are fully decoded and checked as 8-bit sRGB/sRGBA before use. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation.
+Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales the stored normalized attachment under a total-pixel budget without enlargement, then applies a separate encoded-byte target. The request encoder uses the same alpha routing and quality ladder as normalization, WebP (effort 0) at 85, 75, 60 for alpha sources and JPEG at those qualities for opaque sources, executed lazily and keeping the smallest output when every quality exceeds the target. Its cache identity includes the attachment id, transform version, pixel and byte budgets, and fixed encoder settings. Cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimension, and alpha facts before use; a mismatch regenerates the entry. Concurrent calls for one identity share one transform and cache write; cancelling one waiter does not cancel the shared work. Callers compose ordered batches from singular reads, while the service's FIFO limiter applies `imageCompressionConcurrency` to simultaneous normalization and request transforms. The setting ranges from 1 through 8 and defaults to 2; file publication remains ordered after preparation.
`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata. `imageHostPath` derives the normalized object's absolute host path and does not inspect the tool execution world. At request assembly, an LLM consumer asks the mounted filesystem to map that host object into its execution world. A host-backed filesystem returns a process path; a remote filesystem without a shared mount returns no path. The mapped path is absent from durable history and from `RequestImageAttachment`. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`.
diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md
index 0c92838c91..6e8ad367cf 100644
--- a/packages/attachment/attachment-local/README.zh.md
+++ b/packages/attachment/attachment-local/README.zh.md
@@ -4,9 +4,9 @@
这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会把每级祖先目录项同步到文件系统根目录,以此一次性证明 home 已持久化。写入使用私有暂存目录、经过同步的临时文件、原子且排他的硬链接发布、仅所有者可读的对象权限,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。
-每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的规范化附件:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把长边限制到 `normalizedImageMaxDimension`(默认 2048px)。规范化附件有独立的 `normalizedImageMaxBytes` 编码字节目标(默认 4MiB)。透明像素会保留;当所有 alpha 样本均为不透明时,Sharp/libvips 可能省略没有实际作用的 alpha 平面。带 alpha 通道的源图编码为 WebP(effort 0),不透明源图编码为 JPEG,共用质量阶梯 85、75、60。只有前一档超过目标时才会执行下一档;全部档位都超过目标时保留最小的产物,提供方字节硬上限仍由传输该字节的路由执行。已经处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的附件各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次规范化附件,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。
+每条消息最多准入 20 张图片,源图编码字节总量不超过 200MiB。每张源图不得超过 20MiB、64,000,000 像素和单边 8192px。随后生成提供方无关的规范化附件:应用 EXIF 方向,删除元数据和色彩配置文件,转换为 8-bit sRGB/sRGBA,并保持宽高比把像素总量缩到 `normalizedImageMaxPixels` 总像素预算内(默认 2048×2048),再受 `normalizedImageMaxDimension` 长边上限约束(默认 8192px),因此极端长宽比的图保留短边分辨率,而不会在长边规则下坍缩。规范化附件有独立的 `normalizedImageMaxBytes` 编码字节目标(默认 4MiB)。透明像素会保留;当所有 alpha 样本均为不透明时,Sharp/libvips 可能省略没有实际作用的 alpha 平面。带 alpha 通道的源图编码为 WebP(effort 0),不透明源图编码为 JPEG,共用质量阶梯 85、75、60。只有前一档超过目标时才会执行下一档;全部档位都超过目标时保留最小的产物,提供方字节硬上限仍由传输该字节的路由执行。已经处于两个规范化上限内的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 按字节原样直通;16-bit PNG、GIF、动图、元数据、方向和不兼容色彩空间都会触发转换。源图和转换后的附件各完整解码一次。`saveImages` 在发布任何批次成员前为每张图片各准备并验证一次规范化附件,因此校验失败不会留下部分引用,提交阶段也不会重复执行完整图片编码。
-请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再应用独立的编码字节目标。请求编码器与规范化共用同一套 alpha 路由和质量阶梯:带 alpha 的源图依次尝试质量 85、75、60 的 WebP(effort 0),不透明源图依次尝试这些质量的 JPEG;候选按需执行,全部档位都超过目标时保留最小的产物。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前会完整解码并校验为 8-bit sRGB/sRGBA。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。
+请求版本保存在 `/attachments/v1/request-images/`。`readImageRequest` 在不放大小图的前提下,把存储的规范化附件缩放到总像素预算内,再应用独立的编码字节目标。请求编码器与规范化共用同一套 alpha 路由和质量阶梯:带 alpha 的源图依次尝试质量 85、75、60 的 WebP(effort 0),不透明源图依次尝试这些质量的 JPEG;候选按需执行,全部档位都超过目标时保留最小的产物。缓存身份包含附件 ID、变换策略版本、像素和字节预算及固定编码参数。缓存字节在使用前经头部探测校验格式、8-bit sRGB/sRGBA、尺寸和 alpha 事实;不匹配则重新生成该条目。同一身份的并发调用共享一次变换和缓存写入;取消一个等待方不会取消共享任务。调用方组合单数读取得到有序批次,服务的 FIFO 限流器通过 `imageCompressionConcurrency` 限制同时执行的规范化和请求变换。该配置范围为 1 至 8,默认值为 2;文件发布仍在准备结束后按顺序执行。
`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据。`imageHostPath` 派生规范化对象的绝对宿主路径,不检查工具执行环境。组装请求时,LLM 消费方要求当前文件系统把该宿主对象映射到其执行环境。宿主文件系统返回进程路径;没有共享挂载的远程文件系统不返回路径。映射后的路径不进入持久历史,也不进入 `RequestImageAttachment`。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。
diff --git a/packages/attachment/attachment-local/src/encoding.ts b/packages/attachment/attachment-local/src/encoding.ts
index bf83d48cf9..1b16aafb30 100644
--- a/packages/attachment/attachment-local/src/encoding.ts
+++ b/packages/attachment/attachment-local/src/encoding.ts
@@ -1,4 +1,41 @@
-/** Shared lazy candidate execution for normalization and request-image encoders. */
+/** Shared quality ladder and lazy candidate execution for normalization and request-image encoders. */
+
+import type { Sharp } from 'sharp'
+
+/** Shared ladder for both encoders: spaced so each step buys a real size reduction. */
+export const IMAGE_ENCODING_QUALITIES = [85, 75, 60] as const
+/** Fixed lossy-WebP effort; deeper search costs 3-4x encode time for about 5% size. */
+export const WEBP_ENCODING_EFFORT = 0
+
+/** One ladder output carrying its complete bytes and exact facts. */
+export interface EncodedImage {
+ data: Uint8Array
+ mediaType: 'image/jpeg' | 'image/webp'
+ width: number
+ height: number
+}
+
+async function encode(pipeline: Sharp, mediaType: EncodedImage['mediaType'], quality: number): Promise {
+ const encoded = mediaType === 'image/webp'
+ ? pipeline.webp({ quality, effort: WEBP_ENCODING_EFFORT })
+ : pipeline.jpeg({ quality })
+ const { data, info } = await encoded.toBuffer({ resolveWithObject: true })
+ return { data: new Uint8Array(data), mediaType, width: info.width, height: info.height }
+}
+
+/**
+ * Build the lazy quality ladder for one prepared pipeline: WebP keeps a source
+ * alpha channel, everything else is JPEG.
+ * @param prepared - sized sRGB pipeline; cloned per candidate.
+ * @param hasAlpha - decoded source alpha fact selecting the codec.
+ * @returns encoders ordered from highest to lowest ladder quality.
+ */
+export function encodingLadder(prepared: Sharp, hasAlpha: boolean): Array<() => Promise> {
+ const mediaType = hasAlpha ? 'image/webp' : 'image/jpeg'
+ return IMAGE_ENCODING_QUALITIES.map(quality => (
+ () => encode(prepared.clone(), mediaType, quality)
+ ))
+}
/** One encoded candidate carrying its complete bytes. */
export interface EncodedCandidate {
@@ -13,7 +50,7 @@ export interface ExhaustedEncoding {
/**
* Execute encoding candidates in preference order and stop after the first fitting output.
* @param attempts - lazy encoders ordered from preferred to fallback representation.
- * @param maxBytes - positive encoded-byte cap.
+ * @param maxBytes - positive encoded-byte target.
* @returns the first fitting candidate, otherwise the smallest completed fallback.
*/
export async function encodeFirstWithinLimit(
@@ -37,7 +74,7 @@ export async function encodeFirstWithinLimit(
/**
* Whether a lazy encoding result exhausted every candidate at one size.
* @param result - first fitting candidate or exhausted result.
- * @returns whether every candidate exceeded the byte cap.
+ * @returns whether every candidate exceeded the byte target.
*/
export function isExhaustedEncoding(
result: T | ExhaustedEncoding,
diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts
index 59467107a8..46919200eb 100644
--- a/packages/attachment/attachment-local/src/index.ts
+++ b/packages/attachment/attachment-local/src/index.ts
@@ -35,11 +35,15 @@ export const DEFAULT_MAX_IMAGE_PIXELS = 64_000_000
/** Default per-side pixel cap for one submitted image. */
export const DEFAULT_MAX_IMAGE_DIMENSION = 8192
/**
- * Default long-edge target of the stored normalized image. A larger source
- * is admitted and downscaled to this edge, so admission bounds what rides
- * every later model request without refusing ordinary large sources.
+ * Default total-pixel budget of the stored normalized image. A larger source
+ * is admitted and downscaled proportionally, so admission bounds what rides
+ * every later model request without refusing ordinary large sources; extreme
+ * aspect ratios keep their short-edge resolution instead of collapsing under
+ * a long-edge rule.
*/
-export const DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION = 2048
+export const DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS = 2048 * 2048
+/** Default long-edge cap of the stored normalized image, applied after the total-pixel budget. */
+export const DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION = 8192
/** Default encoded-byte target for one stored normalized image. */
export const DEFAULT_NORMALIZED_IMAGE_MAX_BYTES = 4 * 1024 * 1024
/** Conservative default number of simultaneous native image transformations per store. */
@@ -61,7 +65,9 @@ export interface Config {
maxImagePixels?: number
/** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */
maxImageDimension?: number
- /** Long-edge pixel cap of the stored provider-independent normalized image. */
+ /** Total-pixel budget of the stored provider-independent normalized image. */
+ normalizedImageMaxPixels?: number
+ /** Long-edge pixel cap of the stored provider-independent normalized image, applied after the total-pixel budget. */
normalizedImageMaxDimension?: number
/**
* Encoded-byte target of the stored provider-independent normalized image;
@@ -142,6 +148,7 @@ export class LocalAttachmentStore extends AttachmentStore {
maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES),
maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS),
maxImageDimension: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_DIMENSION),
+ normalizedImageMaxPixels: z.number().step(1).min(1).default(DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS),
normalizedImageMaxDimension: z.number().step(1).min(1).default(DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION),
normalizedImageMaxBytes: z.number().step(1).min(1).default(DEFAULT_NORMALIZED_IMAGE_MAX_BYTES),
imageCompressionConcurrency: z.number().step(1).min(1).max(MAX_IMAGE_COMPRESSION_CONCURRENCY)
@@ -170,6 +177,7 @@ export class LocalAttachmentStore extends AttachmentStore {
mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const),
})
this.normalizationPolicy = Object.freeze({
+ maxPixels: config.normalizedImageMaxPixels ?? DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS,
maxDimension: config.normalizedImageMaxDimension ?? DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION,
maxBytes: config.normalizedImageMaxBytes ?? DEFAULT_NORMALIZED_IMAGE_MAX_BYTES,
})
diff --git a/packages/attachment/attachment-local/src/normalization.ts b/packages/attachment/attachment-local/src/normalization.ts
index 4d679912a8..22db15b740 100644
--- a/packages/attachment/attachment-local/src/normalization.ts
+++ b/packages/attachment/attachment-local/src/normalization.ts
@@ -3,13 +3,16 @@
import sharp, { type Sharp } from 'sharp'
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
-import { encodeFirstWithinLimit, isExhaustedEncoding } from './encoding.ts'
+import { encodeFirstWithinLimit, encodingLadder, isExhaustedEncoding } from './encoding.ts'
+import { requestImageDimensions } from './request-image.ts'
import { detectImage, encodedAlphaIsCompatible } from './image.ts'
import type { DetectedImage } from './image.ts'
/** Deployment-resolved policy for the persisted normalized attachment. */
export interface NormalizationPolicy {
- /** Long-edge cap in pixels; larger sources are downscaled proportionally. */
+ /** Total-pixel budget; larger sources are downscaled proportionally. */
+ maxPixels: number
+ /** Long-edge cap in pixels applied after the total-pixel budget, bounding extreme aspect ratios. */
maxDimension: number
/** Encoded-byte target for the quality ladder; the smallest ladder output is kept when no quality fits. */
maxBytes: number
@@ -23,24 +26,6 @@ export interface NormalizedImage {
height: number
}
-/** Shared ladder for both encoders: spaced so each step buys a real size reduction. */
-export const IMAGE_ENCODING_QUALITIES = [85, 75, 60] as const
-/** Fixed lossy-WebP effort; deeper search costs 3-4x encode time for about 5% size. */
-export const WEBP_ENCODING_EFFORT = 0
-
-/** Encode one prepared pipeline and report exact output facts. */
-async function encode(
- pipeline: Sharp,
- mediaType: 'image/jpeg' | 'image/webp',
- quality: number,
-): Promise {
- const encoded = mediaType === 'image/webp'
- ? pipeline.webp({ quality, effort: WEBP_ENCODING_EFFORT })
- : pipeline.jpeg({ quality })
- const { data, info } = await encoded.toBuffer({ resolveWithObject: true })
- return { data: new Uint8Array(data), mediaType, width: info.width, height: info.height }
-}
-
/**
* Whether bytes already satisfy the normalization requirements.
* @param detected - fully decoded source facts.
@@ -59,6 +44,7 @@ export function canPassThroughNormalization(
&& detected.depth === 'uchar'
&& detected.space === 'srgb'
&& bytes <= policy.maxBytes
+ && detected.width * detected.height <= policy.maxPixels
&& Math.max(detected.width, detected.height) <= policy.maxDimension
}
@@ -92,33 +78,22 @@ function preparedPipeline(data: Uint8Array, width: number, height: number): Shar
.resize({ width, height, fit: 'inside', withoutEnlargement: true })
}
-/** Dimensions after the long edge is capped without changing aspect ratio. */
-function initialDimensions(detected: DetectedImage, maxDimension: number): { width: number; height: number } {
- const scale = Math.min(1, maxDimension / Math.max(detected.width, detected.height))
+/** Dimensions under the total-pixel budget, then the long-edge cap, without changing aspect ratio. */
+function initialDimensions(detected: DetectedImage, policy: NormalizationPolicy): { width: number; height: number } {
+ const budgeted = requestImageDimensions(detected.width, detected.height, policy.maxPixels)
+ const longEdge = Math.max(budgeted.width, budgeted.height)
+ if (longEdge <= policy.maxDimension) return budgeted
+ const scale = policy.maxDimension / longEdge
return {
- width: Math.max(1, Math.round(detected.width * scale)),
- height: Math.max(1, Math.round(detected.height * scale)),
+ width: Math.max(1, Math.floor(budgeted.width * scale)),
+ height: Math.max(1, Math.floor(budgeted.height * scale)),
}
}
-/** Lazy quality ladder: WebP keeps a source alpha channel, everything else is JPEG. */
-function encodingAttempts(
- data: Uint8Array,
- width: number,
- height: number,
- hasAlpha: boolean,
-): Array<() => Promise> {
- const prepared = preparedPipeline(data, width, height)
- const mediaType = hasAlpha ? 'image/webp' : 'image/jpeg'
- return IMAGE_ENCODING_QUALITIES.map(quality => (
- () => encode(prepared.clone(), mediaType, quality)
- ))
-}
-
/**
* Produce the persisted provider-independent normalized version of one fully decoded source.
* The source is passed through only when it is already clean, single-frame, 8-bit sRGB/sRGBA,
- * and inside both normalization limits. Re-encoding never removes transparency. When every
+ * and inside every normalization limit. Re-encoding never removes transparency. When every
* ladder quality exceeds the byte target, the smallest ladder output is kept; provider byte
* caps stay enforced at the route that transmits the bytes.
* @param data - complete admitted source bytes.
@@ -135,9 +110,9 @@ export async function normalizeImage(
return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height }
}
try {
- const { width, height } = initialDimensions(detected, policy.maxDimension)
+ const { width, height } = initialDimensions(detected, policy)
const encoded = await encodeFirstWithinLimit(
- encodingAttempts(data, width, height, detected.hasAlpha),
+ encodingLadder(preparedPipeline(data, width, height), detected.hasAlpha),
policy.maxBytes,
)
const chosen = isExhaustedEncoding(encoded) ? encoded.smallest : encoded
diff --git a/packages/attachment/attachment-local/src/request-image.ts b/packages/attachment/attachment-local/src/request-image.ts
index 1764eded3a..598e79a893 100644
--- a/packages/attachment/attachment-local/src/request-image.ts
+++ b/packages/attachment/attachment-local/src/request-image.ts
@@ -12,8 +12,13 @@ import type {
RequestImageAttachment,
StoredImageAttachment,
} from '@deepseek-ai/dsh-attachment'
-import { IMAGE_ENCODING_QUALITIES, WEBP_ENCODING_EFFORT } from './normalization.ts'
-import { encodeFirstWithinLimit, isExhaustedEncoding } from './encoding.ts'
+import {
+ IMAGE_ENCODING_QUALITIES,
+ WEBP_ENCODING_EFFORT,
+ encodeFirstWithinLimit,
+ encodingLadder,
+ isExhaustedEncoding,
+} from './encoding.ts'
import { detectImage, encodedAlphaIsCompatible, probeImage } from './image.ts'
/** Transform version included in every cache and upload-index identity. */
@@ -116,31 +121,6 @@ function sourcePipeline(attachment: StoredImageAttachment): Sharp {
return sharp(attachment.data, { failOn: 'error', limitInputPixels: false }).toColourspace('srgb')
}
-async function encoded(
- image: Sharp,
- mediaType: 'image/jpeg' | 'image/webp',
- quality: number,
-): Promise {
- const output = mediaType === 'image/webp'
- ? image.webp({ quality, effort: WEBP_ENCODING_EFFORT })
- : image.jpeg({ quality })
- const { data, info } = await output.toBuffer({ resolveWithObject: true })
- return { data: new Uint8Array(data), mediaType, width: info.width, height: info.height }
-}
-
-function encodingAttempts(
- attachment: StoredImageAttachment,
- width: number,
- height: number,
- hasAlpha: boolean,
-): Array<() => Promise> {
- const prepared = pipeline(attachment, width, height)
- const mediaType = hasAlpha ? 'image/webp' : 'image/jpeg'
- return IMAGE_ENCODING_QUALITIES.map(quality => (
- () => encoded(prepared.clone(), mediaType, quality)
- ))
-}
-
async function createRequestImage(
attachment: StoredImageAttachment,
policy: ImageRequestPolicy,
@@ -158,7 +138,7 @@ async function createRequestImage(
}
}
const encodedVersion = await encodeFirstWithinLimit(
- encodingAttempts(attachment, dimensions.width, dimensions.height, hasAlpha),
+ encodingLadder(pipeline(attachment, dimensions.width, dimensions.height), hasAlpha),
policy.maxBytes,
)
return isExhaustedEncoding(encodedVersion) ? encodedVersion.smallest : encodedVersion
diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts
index da0cef1400..4d9d9f350b 100644
--- a/packages/attachment/attachment-local/tests/index.spec.ts
+++ b/packages/attachment/attachment-local/tests/index.spec.ts
@@ -9,6 +9,7 @@ import sharp from 'sharp'
import LocalAttachmentStore, {
DEFAULT_NORMALIZED_IMAGE_MAX_BYTES,
DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION,
+ DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS,
DEFAULT_IMAGE_COMPRESSION_CONCURRENCY,
DEFAULT_MAX_IMAGE_BYTES,
DEFAULT_MAX_IMAGE_DIMENSION,
@@ -34,6 +35,7 @@ describe('local attachment service', () => {
mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'],
})
expect(service.normalizationPolicy).toEqual({
+ maxPixels: DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS,
maxDimension: DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION,
maxBytes: DEFAULT_NORMALIZED_IMAGE_MAX_BYTES,
})
diff --git a/packages/attachment/attachment-local/tests/normalization-verification.spec.ts b/packages/attachment/attachment-local/tests/normalization-verification.spec.ts
index 4e39223f24..4a9faf6d7d 100644
--- a/packages/attachment/attachment-local/tests/normalization-verification.spec.ts
+++ b/packages/attachment/attachment-local/tests/normalization-verification.spec.ts
@@ -29,7 +29,7 @@ describe('normalization verification', () => {
const detected = await detectImage(data)
control.mismatch = true
- await expect(normalizeImage(data, detected, { maxDimension: 5, maxBytes: 4 * 1024 * 1024 }))
+ await expect(normalizeImage(data, detected, { maxPixels: 2048 * 2048, maxDimension: 5, maxBytes: 4 * 1024 * 1024 }))
.rejects.toMatchObject({
code: 'ATTACHMENT_WRITE_FAILED',
message: 'Image normalization did not produce a single-frame 8-bit sRGB image with matching metadata.',
diff --git a/packages/attachment/attachment-local/tests/normalization.spec.ts b/packages/attachment/attachment-local/tests/normalization.spec.ts
index 6403e1a0b4..d60989075d 100644
--- a/packages/attachment/attachment-local/tests/normalization.spec.ts
+++ b/packages/attachment/attachment-local/tests/normalization.spec.ts
@@ -4,7 +4,7 @@ import { canPassThroughNormalization, normalizeImage } from '../src/normalizatio
import type { NormalizationPolicy } from '../src/normalization.ts'
import { detectImage } from '../src/image.ts'
-const POLICY: NormalizationPolicy = { maxDimension: 2048, maxBytes: 4 * 1024 * 1024 }
+const POLICY: NormalizationPolicy = { maxPixels: 2048 * 2048, maxDimension: 8192, maxBytes: 4 * 1024 * 1024 }
/** Deterministic pseudo-random RGB noise; PNG cannot compress it below raw size. */
function noisePixels(width: number, height: number): Uint8Array {
@@ -34,13 +34,14 @@ async function flatImage(width: number, height: number, format: 'png' | 'jpeg' |
describe('canPassThroughNormalization', () => {
it('accepts an in-budget clean PNG/JPEG/WebP and refuses GIF, animation, metadata, oversized edges, and oversized bytes', () => {
const clean = { animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb', hasAlpha: false }
- expect(canPassThroughNormalization({ mediaType: 'image/png', width: 2048, height: 4, ...clean }, 100, POLICY)).toBe(true)
+ expect(canPassThroughNormalization({ mediaType: 'image/png', width: 8192, height: 4, ...clean }, 100, POLICY)).toBe(true)
expect(canPassThroughNormalization({ mediaType: 'image/gif', width: 4, height: 4, ...clean }, 100, POLICY)).toBe(false)
expect(canPassThroughNormalization({ mediaType: 'image/webp', width: 4, height: 4, animated: true, carriesMetadata: false, depth: 'uchar', space: 'srgb', hasAlpha: false }, 100, POLICY)).toBe(false)
expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 4, height: 4, animated: false, carriesMetadata: true, depth: 'uchar', space: 'srgb', hasAlpha: false }, 100, POLICY)).toBe(false)
expect(canPassThroughNormalization({ mediaType: 'image/png', width: 4, height: 4, ...clean, depth: 'ushort' }, 100, POLICY)).toBe(false)
expect(canPassThroughNormalization({ mediaType: 'image/png', width: 4, height: 4, ...clean, space: 'rgb16' }, 100, POLICY)).toBe(false)
- expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 2049, height: 4, ...clean }, 100, POLICY)).toBe(false)
+ expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 2049, height: 2048, ...clean }, 100, POLICY)).toBe(false)
+ expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 8193, height: 4, ...clean }, 100, POLICY)).toBe(false)
expect(canPassThroughNormalization({ mediaType: 'image/webp', width: 4, height: 4, ...clean }, POLICY.maxBytes + 1, POLICY)).toBe(false)
})
})
@@ -76,19 +77,20 @@ describe('normalizeImage', () => {
const data = await flatImage(10, 6, 'png')
const detected = await detectImage(data)
- const normalized = await normalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes })
+ const normalized = await normalizeImage(data, detected, { maxPixels: POLICY.maxPixels, maxDimension: 5, maxBytes: POLICY.maxBytes })
expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 5, height: 3 })
await expect(detectImage(normalized.data)).resolves.toMatchObject({ mediaType: 'image/jpeg', width: 5, height: 3, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' })
- const again = await normalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes })
+ const again = await normalizeImage(data, detected, { maxPixels: POLICY.maxPixels, maxDimension: 5, maxBytes: POLICY.maxBytes })
expect(again.data).toEqual(normalized.data)
})
it('re-encodes the normalized output of a resize into itself (idempotence)', async () => {
const data = await flatImage(10, 6, 'png')
- const first = await normalizeImage(data, await detectImage(data), { maxDimension: 5, maxBytes: POLICY.maxBytes })
+ const budget = { maxPixels: POLICY.maxPixels, maxDimension: 5, maxBytes: POLICY.maxBytes }
+ const first = await normalizeImage(data, await detectImage(data), budget)
- const second = await normalizeImage(first.data, await detectImage(first.data), { maxDimension: 5, maxBytes: POLICY.maxBytes })
+ const second = await normalizeImage(first.data, await detectImage(first.data), budget)
expect(second.data).toBe(first.data)
})
@@ -109,7 +111,7 @@ describe('normalizeImage', () => {
const data = await flatImage(9, 5, 'webp', true)
const detected = await detectImage(data)
- const normalized = await normalizeImage(data, detected, { maxDimension: 4, maxBytes: POLICY.maxBytes })
+ const normalized = await normalizeImage(data, detected, { maxPixels: POLICY.maxPixels, maxDimension: 4, maxBytes: POLICY.maxBytes })
expect(normalized).toMatchObject({ mediaType: 'image/webp', width: 4, height: 2 })
await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: true })
@@ -132,6 +134,7 @@ describe('normalizeImage', () => {
await expect(detectImage(data)).resolves.toMatchObject({ hasAlpha: true })
const normalized = await normalizeImage(data, await detectImage(data), {
+ maxPixels: POLICY.maxPixels,
maxDimension: 32,
maxBytes: POLICY.maxBytes,
})
@@ -154,7 +157,9 @@ describe('normalizeImage', () => {
}
const data = new Uint8Array(await sharp(pixels, { raw: { width: side, height: side, channels: 4 } }).png().toBuffer())
- const normalized = await normalizeImage(data, await detectImage(data), { maxDimension: side, maxBytes: 1_024 })
+ const normalized = await normalizeImage(data, await detectImage(data), {
+ maxPixels: POLICY.maxPixels, maxDimension: side, maxBytes: 1_024,
+ })
expect(normalized.data.byteLength).toBeGreaterThan(1_024)
expect(normalized).toMatchObject({ mediaType: 'image/webp', width: side, height: side })
@@ -165,7 +170,7 @@ describe('normalizeImage', () => {
const data = await noiseImage(64, 32, 'jpeg')
const detected = await detectImage(data)
- const normalized = await normalizeImage(data, detected, { maxDimension: 32, maxBytes: POLICY.maxBytes })
+ const normalized = await normalizeImage(data, detected, { maxPixels: POLICY.maxPixels, maxDimension: 32, maxBytes: POLICY.maxBytes })
expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 32, height: 16 })
})
@@ -183,7 +188,7 @@ describe('normalizeImage', () => {
}
const data = new Uint8Array(await sharp(pixels, { raw: { width: side, height: side, channels: 3 } }).png().toBuffer())
const detected = await detectImage(data)
- const budget = { maxDimension: 128, maxBytes: POLICY.maxBytes }
+ const budget = { maxPixels: POLICY.maxPixels, maxDimension: 128, maxBytes: POLICY.maxBytes }
const normalized = await normalizeImage(data, detected, budget)
@@ -195,7 +200,9 @@ describe('normalizeImage', () => {
it('keeps the smallest opaque ladder output above an unreachable byte target', async () => {
const data = await noiseImage(64, 64, 'png')
- const normalized = await normalizeImage(data, await detectImage(data), { maxDimension: 2048, maxBytes: 512 })
+ const normalized = await normalizeImage(data, await detectImage(data), {
+ maxPixels: POLICY.maxPixels, maxDimension: 2048, maxBytes: 512,
+ })
expect(normalized.data.byteLength).toBeGreaterThan(512)
expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 64, height: 64 })
@@ -262,6 +269,26 @@ describe('normalizeImage', () => {
})
})
+ it('downscales by total pixels so an extreme aspect ratio keeps its short edge', async () => {
+ const data = await flatImage(10, 40, 'png')
+
+ const normalized = await normalizeImage(data, await detectImage(data), {
+ maxPixels: 100, maxDimension: 8192, maxBytes: POLICY.maxBytes,
+ })
+
+ expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 5, height: 20 })
+ })
+
+ it('caps the long edge after the total-pixel budget', async () => {
+ const data = await flatImage(4, 64, 'png')
+
+ const normalized = await normalizeImage(data, await detectImage(data), {
+ maxPixels: 10_000, maxDimension: 16, maxBytes: POLICY.maxBytes,
+ })
+
+ expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 1, height: 16 })
+ })
+
it('keeps an antialiased text screenshot readable on the JPEG ladder', async () => {
const source = new Uint8Array(await sharp(Buffer.from(`