fix(attachment-local): exclude metadata carriers and animation from passthrough; validate the canonical budget up front
Review-round hardening of canonical admission: - passthrough now requires a single-frame source free of EXIF/XMP/IPTC metadata, so location/device metadata never enters durable storage and stored dimensions always describe the perceived pixels; animated WebP joins GIF on the always-re-encode path (first frame only) - SourceImageInfo records orientation-applied dimensions, keeping source and stored raster on shared axes for coordinate mapping - validateImage runs a canonical-encoding dry run, so a validated batch can no longer be refused mid-write by the byte target (no partial writes) - read_image names per-axis multipliers when rounding splits the two ratios and maps IMAGE_TOO_LARGE to actionable downscale guidance
This commit is contained in:
1 parent
c90a944abd
commit
118f244420
19 files changed
+167
-45
No files matched your search
@@ -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-canonical-image-admission.md
|
||||
2026-08-20-canonical-image-admission.md: bae3ce8b93b7fbfc4d3233cb3ed019f6ab09c0b4
|
||||
2026-08-20-canonical-image-admission.zh.md: a8c55383eb0c8b244dc1fefe1186004e2b869d3e
|
||||
2026-08-20-canonical-image-admission.md: a30031ef72942a61865525b9ed22f97afd71e18b
|
||||
2026-08-20-canonical-image-admission.zh.md: d5402a6e7bfd2d8c7de6e2a7ce611c74ec2843d2
|
||||
@@ -10,7 +10,7 @@ Admission used to refuse any image above 2000px per side or 3.5 MiB, because an
|
||||
|
||||
## Decision
|
||||
|
||||
`AttachmentStore.saveImage` resolves `SavedImageAttachment`: the durable `ref` describing stored bytes beside `source` facts of the submitted raster. The local store validates a wide source envelope (32 MiB, 100 MP, 16384px per side) and persists a deterministic canonical encoding: EXIF orientation baked in, metadata stripped, long edge downscaled to `canonicalMaxDimension` (default 2048px), palette PNG for alpha/PNG/GIF lineage and JPEG for photographic sources, stepping a fixed quality ladder (85/75/60/45) until `canonicalMaxBytes` (default 1 MiB) holds. An in-budget PNG/JPEG/WebP source passes through byte-identically, so equal originals keep one content address; GIF always becomes the PNG of its first frame, pinning the first-frame meaning providers apply. Encoder parameters are fixed, not configurable — a parameter change would silently split the content-addressed space — so deployments choose only the source envelope and the canonical budget. The canonical ref keeps the pre-existing field order (`mediaType`, `width`, `height`, `bytes`) so logged references stay byte-identical. `read_image` reports the on-disk dimensions and the coordinate multiplier whenever storage downscaled the file.
|
||||
`AttachmentStore.saveImage` resolves `SavedImageAttachment`: the durable `ref` describing stored bytes beside `source` facts of the submitted raster. The local store validates a wide source envelope (32 MiB, 100 MP, 16384px per side) and persists a deterministic canonical encoding: EXIF orientation baked in, metadata stripped, long edge downscaled to `canonicalMaxDimension` (default 2048px), palette PNG for alpha/PNG/GIF lineage and JPEG for photographic sources, stepping a fixed quality ladder (85/75/60/45) until `canonicalMaxBytes` (default 1 MiB) holds. An in-budget PNG/JPEG/WebP source passes through byte-identically only when it is single-frame and free of EXIF/XMP/IPTC metadata and non-default orientation, so equal originals keep one content address while location and device metadata never survive admission; GIF and every animated or metadata-carrying source re-encodes, and GIF always becomes the PNG of its first frame, pinning the first-frame meaning providers apply. Encoder parameters are fixed, not configurable — a parameter change would silently split the content-addressed space — so deployments choose only the source envelope and the canonical budget. `SourceImageInfo` records orientation-applied dimensions so source and stored raster share axes, and `validateImage` includes a canonical-encoding dry run so a validated batch can never be refused mid-write by the byte target. The canonical ref keeps the pre-existing field order (`mediaType`, `width`, `height`, `bytes`) so logged references stay byte-identical. `read_image` reports the on-disk dimensions and the coordinate multiplier whenever storage downscaled the file, naming per-axis multipliers when integer rounding makes the two ratios differ.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
## 决定
|
||||
|
||||
`AttachmentStore.saveImage` 解析为 `SavedImageAttachment`:描述实际存储字节的持久 `ref`,加上所提交光栅的 `source` 事实。本地存储按宽松的源图上限(32 MiB、1 亿像素、单边 16384px)校验,然后持久保存确定性的规范编码:EXIF 方向落实到像素、剥离元数据、长边等比缩放到 `canonicalMaxDimension`(默认 2048px),带透明通道或源自 PNG/GIF 的图片编码为 palette PNG,摄影类图片编码为 JPEG,并沿固定质量阶梯(85/75/60/45)递降直到满足 `canonicalMaxBytes`(默认 1 MiB)。已在预算内的 PNG/JPEG/WebP 源图按字节原样存储,相同原图保持同一个内容地址;GIF 一律转为首帧 PNG,在准入时固化提供方实际采用的首帧语义。编码器参数固定而不可配置,因为参数变化会悄悄割裂内容寻址空间;部署只选择源图上限与规范预算。规范 ref 保持原有字段顺序(`mediaType`、`width`、`height`、`bytes`),已记录的引用保持字节一致。存储缩小了文件时,`read_image` 会报告磁盘上的原始尺寸和坐标换算倍率。
|
||||
`AttachmentStore.saveImage` 解析为 `SavedImageAttachment`:描述实际存储字节的持久 `ref`,加上所提交光栅的 `source` 事实。本地存储按宽松的源图上限(32 MiB、1 亿像素、单边 16384px)校验,然后持久保存确定性的规范编码:EXIF 方向落实到像素、剥离元数据、长边等比缩放到 `canonicalMaxDimension`(默认 2048px),带透明通道或源自 PNG/GIF 的图片编码为 palette PNG,摄影类图片编码为 JPEG,并沿固定质量阶梯(85/75/60/45)递降直到满足 `canonicalMaxBytes`(默认 1 MiB)。已在预算内的 PNG/JPEG/WebP 源图只有在单帧且不携带 EXIF/XMP/IPTC 元数据、方向为默认值时才按字节原样直通,相同原图保持同一个内容地址,位置与设备元数据绝不越过准入;GIF 以及任何动图或携带元数据的源图都会重编码,GIF 一律转为首帧 PNG,在准入时固化提供方实际采用的首帧语义。编码器参数固定而不可配置,因为参数变化会悄悄割裂内容寻址空间;部署只选择源图上限与规范预算。`SourceImageInfo` 记录应用方向之后的尺寸,使源图与存储光栅共享坐标轴;`validateImage` 包含规范编码干跑,通过校验的批次绝不会在写入中途被字节目标拒绝。规范 ref 保持原有字段顺序(`mediaType`、`width`、`height`、`bytes`),已记录的引用保持字节一致。存储缩小了文件时,`read_image` 会报告磁盘上的原始尺寸和坐标换算倍率,取整使两轴比例不一致时分轴给出。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -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: e8f89906f7bedb20b80a04ab6d2aa4b80c6d746f
|
||||
README.zh.md: 61e1dde94751436bb4d8ff4dde1b68b1b10fc005
|
||||
README.md: afa38ccc125f4fb36d35bb4b94b1aea278107551
|
||||
README.zh.md: 9de7ce65447a91741810bbcd41d397a70275247b
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission fully decodes the raster against a wide source envelope — byte, total-pixel, and per-side caps (defaults 32MiB, 100MP, 16384px) — and then persists a deterministic canonical encoding instead of the submitted bytes: EXIF orientation is baked into pixels, metadata is stripped, the long edge is downscaled to the configured canonical target (default 2048px), sources with alpha or PNG/GIF lineage encode as palette PNG and photographic sources as JPEG, stepping down a fixed quality ladder (85/75/60/45) until the configured canonical byte target holds (default 1MiB). A PNG/JPEG/WebP source already inside the canonical budget is stored byte-identically, so equal originals keep deduplicating to one content address; GIF always re-encodes to the PNG of its first frame, pinning at admission the first-frame meaning providers apply. Encoder parameters are deliberately fixed rather than configurable, because a parameter change would silently split the content-addressed space; the deployment chooses only the source envelope and the canonical budget. An admitted image rides every later request of its session, so canonicalizing at admission is what bounds durable history without refusing ordinary large sources. Reads re-check the digest and logged metadata, and a later policy reduction does not make already-admitted history unreadable.
|
||||
The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission fully decodes the raster against a wide source envelope — byte, total-pixel, and per-side caps (defaults 32MiB, 100MP, 16384px) — and then persists a deterministic canonical encoding instead of the submitted bytes: EXIF orientation is baked into pixels, metadata is stripped, the long edge is downscaled to the configured canonical target (default 2048px), sources with alpha or PNG/GIF lineage encode as palette PNG and photographic sources as JPEG, stepping down a fixed quality ladder (85/75/60/45) until the configured canonical byte target holds (default 1MiB). A PNG/JPEG/WebP source already inside the canonical budget passes through byte-identically only when it is a single frame and carries no EXIF/XMP/IPTC metadata and no non-default orientation, so equal originals keep deduplicating to one content address while location and device metadata never survive admission; GIF and every animated or metadata-carrying source re-encodes, and GIF always becomes the PNG of its first frame, pinning at admission the first-frame meaning providers apply. Encoder parameters are deliberately fixed rather than configurable, because a parameter change would silently split the content-addressed space; the deployment chooses only the source envelope and the canonical budget. An admitted image rides every later request of its session, so canonicalizing at admission is what bounds durable history without refusing ordinary large sources. `validateImage` runs the same policy including a canonical-encoding dry run, so a validated batch can never be refused mid-write by the byte target. Reads re-check the digest and logged metadata, and a later policy reduction does not make already-admitted history unreadable.
|
||||
|
||||
`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入会按宽松的源图上限(字节、总像素、单边,默认 32MiB、1 亿像素、16384px)完整解码光栅图片,然后持久保存确定性的规范编码而不是提交的原始字节:EXIF 方向落实到像素并剥离元数据,长边等比缩放到配置的规范目标(默认 2048px),带透明通道或源自 PNG/GIF 的图片编码为 palette PNG,摄影类图片编码为 JPEG,并沿固定的质量阶梯(85/75/60/45)递降,直到满足配置的规范字节目标(默认 1MiB)。已在规范预算内的 PNG/JPEG/WebP 源图按字节原样存储,因此相同原图始终去重到同一个内容地址;GIF 一律重编码为其首帧的 PNG,在准入时就固化提供方实际采用的首帧语义。编码器参数刻意固定而不可配置,因为参数变化会悄悄割裂内容寻址空间;部署只选择源图上限与规范预算。一张已接纳的图片会随会话之后的每次请求发送,所以在准入时规范化才能在不拒绝普通大图的前提下约束持久历史。读取会重新校验摘要和已记录的元数据,后续收紧限制不会导致已经接纳的历史记录变得不可读。
|
||||
这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `<DSH_HOME>/attachments/v1/objects/<sha256-prefix>/<sha256>`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入会按宽松的源图上限(字节、总像素、单边,默认 32MiB、1 亿像素、16384px)完整解码光栅图片,然后持久保存确定性的规范编码而不是提交的原始字节:EXIF 方向落实到像素并剥离元数据,长边等比缩放到配置的规范目标(默认 2048px),带透明通道或源自 PNG/GIF 的图片编码为 palette PNG,摄影类图片编码为 JPEG,并沿固定的质量阶梯(85/75/60/45)递降,直到满足配置的规范字节目标(默认 1MiB)。已在规范预算内的 PNG/JPEG/WebP 源图只有在单帧且不携带 EXIF/XMP/IPTC 元数据、方向为默认值时才按字节原样直通,因此相同原图始终去重到同一个内容地址,而位置与设备元数据绝不会越过准入;GIF 以及任何动图或携带元数据的源图都会重编码,GIF 一律变为其首帧的 PNG,在准入时就固化提供方实际采用的首帧语义。编码器参数刻意固定而不可配置,因为参数变化会悄悄割裂内容寻址空间;部署只选择源图上限与规范预算。一张已接纳的图片会随会话之后的每次请求发送,所以在准入时规范化才能在不拒绝普通大图的前提下约束持久历史。`validateImage` 执行同一套策略并包含规范编码的干跑,因此通过校验的批次绝不会在写入中途被字节目标拒绝。读取会重新校验摘要和已记录的元数据,后续收紧限制不会导致已经接纳的历史记录变得不可读。
|
||||
|
||||
`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。
|
||||
|
||||
|
||||
@@ -38,18 +38,26 @@ async function encode(pipeline: Sharp, mediaType: 'image/png' | 'image/jpeg'): P
|
||||
|
||||
/**
|
||||
* Whether stored bytes may be the submitted bytes unchanged. Byte-identical
|
||||
* passthrough is preferred whenever the source already fits the budget: it
|
||||
* keeps re-submissions of the same original deduplicating to the same object
|
||||
* and never re-encodes what no policy requires changing. GIF is excluded —
|
||||
* only its first frame is model-visible, so admission pins that meaning into
|
||||
* the stored object instead of letting each provider drop frames differently.
|
||||
* @param detected - verified source format and dimensions.
|
||||
* passthrough is preferred whenever the source already fits the budget and
|
||||
* carries nothing the canonical form forbids: it keeps re-submissions of the
|
||||
* same original deduplicating to the same object and never re-encodes what no
|
||||
* policy requires changing. Excluded from passthrough — and therefore always
|
||||
* re-encoded — are GIF and any animated container (only the first frame is
|
||||
* model-visible, so admission pins that meaning instead of letting each
|
||||
* provider drop frames differently) and any source carrying EXIF/XMP/IPTC
|
||||
* metadata or a non-default orientation (stored objects ride every later
|
||||
* request, so location and device metadata must not survive admission, and a
|
||||
* stored orientation would let the recorded dimensions diverge from the
|
||||
* pixels a model perceives).
|
||||
* @param detected - verified source format, dimensions, and metadata facts.
|
||||
* @param bytes - submitted encoded byte length.
|
||||
* @param policy - resolved canonical budget.
|
||||
* @returns whether the submitted encoding already is canonical.
|
||||
*/
|
||||
export function isCanonical(detected: DetectedImage, bytes: number, policy: CanonicalImagePolicy): boolean {
|
||||
return detected.mediaType !== 'image/gif'
|
||||
&& !detected.animated
|
||||
&& !detected.carriesMetadata
|
||||
&& bytes <= policy.maxBytes
|
||||
&& Math.max(detected.width, detected.height) <= policy.maxDimension
|
||||
}
|
||||
|
||||
@@ -7,8 +7,14 @@ import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
||||
/** Decoded metadata from a supported image. */
|
||||
export interface DetectedImage {
|
||||
mediaType: ImageMediaType
|
||||
/** Intrinsic width with EXIF orientation applied — the width a viewer perceives. */
|
||||
width: number
|
||||
/** Intrinsic height with EXIF orientation applied — the height a viewer perceives. */
|
||||
height: number
|
||||
/** Whether the container carries more than one frame. */
|
||||
animated: boolean
|
||||
/** Whether the bytes carry EXIF/XMP/IPTC metadata or a non-default orientation. */
|
||||
carriesMetadata: boolean
|
||||
}
|
||||
|
||||
const MEDIA_TYPES: Readonly<Record<string, ImageMediaType>> = {
|
||||
@@ -24,7 +30,18 @@ async function imageMetadata(image: Sharp): Promise<DetectedImage> {
|
||||
if (mediaType === undefined) {
|
||||
throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE')
|
||||
}
|
||||
return { mediaType, width: metadata.width, height: metadata.height }
|
||||
// EXIF orientations 5-8 transpose the stored raster; report the perceived
|
||||
// axes so limits, source facts, and coordinate advice all share them.
|
||||
const transposed = metadata.orientation !== undefined && metadata.orientation >= 5
|
||||
return {
|
||||
mediaType,
|
||||
width: transposed ? metadata.height : metadata.width,
|
||||
height: transposed ? metadata.width : metadata.height,
|
||||
animated: (metadata.pages ?? 1) > 1,
|
||||
// orientation is EXIF-derived for every whitelisted format, so exif
|
||||
// presence already covers a non-default orientation.
|
||||
carriesMetadata: metadata.exif !== undefined || metadata.xmp !== undefined || metadata.iptc !== undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -89,7 +89,7 @@ export class LocalAttachmentStore extends AttachmentStore {
|
||||
}
|
||||
|
||||
async validateImage(input: SaveImageAttachment): Promise<void> {
|
||||
await validateImageFile(input, this.imageLimits)
|
||||
await validateImageFile(input, this.imageLimits, this.canonicalPolicy)
|
||||
}
|
||||
|
||||
async saveImage(input: SaveImageAttachment): Promise<SavedImageAttachment> {
|
||||
|
||||
@@ -13,11 +13,13 @@ import type {
|
||||
ImageAttachmentRef,
|
||||
SaveImageAttachment,
|
||||
SavedImageAttachment,
|
||||
SourceImageInfo,
|
||||
StoredImageAttachment,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
import { canonicalizeImage } from './canonical.ts'
|
||||
import type { CanonicalImagePolicy } from './canonical.ts'
|
||||
import { detectImage, probeImage } from './image.ts'
|
||||
import type { DetectedImage } from './image.ts'
|
||||
|
||||
const ID_PATTERN = /^sha256:([a-f0-9]{64})$/
|
||||
const durableHomes = new Set<string>()
|
||||
@@ -50,24 +52,35 @@ async function inspectMetadata(
|
||||
data: Uint8Array,
|
||||
declaredMediaType: ImageAttachmentRef['mediaType'],
|
||||
limits: ImageAttachmentLimits,
|
||||
): Promise<Omit<ImageAttachmentRef, 'attachmentId' | 'name'>> {
|
||||
): Promise<{ detected: DetectedImage; source: SourceImageInfo }> {
|
||||
if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE')
|
||||
const detected = await detectImage(data, { maxPixels: limits.maxImagePixels, maxDimension: limits.maxImageDimension })
|
||||
if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH')
|
||||
return { ...detected, bytes: data.byteLength }
|
||||
return {
|
||||
detected,
|
||||
source: { mediaType: detected.mediaType, bytes: data.byteLength, width: detected.width, height: detected.height },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full admission policy for one image without touching storage.
|
||||
* Run the full admission policy for one image without touching storage,
|
||||
* including a canonical-encoding dry run: a batch whose members all validate
|
||||
* cannot later be refused mid-write by the canonical byte target.
|
||||
* @param input - encoded bytes and declared metadata.
|
||||
* @param limits - resolved storage policy.
|
||||
* @returns completion after the encoded raster has been fully decoded.
|
||||
* @param limits - resolved source admission policy.
|
||||
* @param policy - resolved canonical encoding budget.
|
||||
* @returns completion after the raster has been fully decoded and its canonical encoding proven to fit.
|
||||
*/
|
||||
export async function validateImageFile(input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise<void> {
|
||||
export async function validateImageFile(
|
||||
input: SaveImageAttachment,
|
||||
limits: ImageAttachmentLimits,
|
||||
policy: CanonicalImagePolicy,
|
||||
): Promise<void> {
|
||||
if (input.data.byteLength > limits.maxImageBytes) {
|
||||
throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE')
|
||||
}
|
||||
await inspectMetadata(input.data, input.mediaType, limits)
|
||||
const { detected } = await inspectMetadata(input.data, input.mediaType, limits)
|
||||
await canonicalizeImage(input.data, detected, policy)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,8 +160,8 @@ export async function saveImageFile(
|
||||
policy: CanonicalImagePolicy,
|
||||
): Promise<SavedImageAttachment> {
|
||||
if (input.data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE')
|
||||
const metadata = await inspectMetadata(input.data, input.mediaType, limits)
|
||||
const canonical = await canonicalizeImage(input.data, metadata, policy)
|
||||
const { detected, source } = await inspectMetadata(input.data, input.mediaType, limits)
|
||||
const canonical = await canonicalizeImage(input.data, detected, policy)
|
||||
const sha256 = digest(canonical.data)
|
||||
const bucket = join(root, 'objects', sha256.slice(0, 2))
|
||||
const staging = join(root, 'tmp')
|
||||
@@ -208,7 +221,7 @@ export async function saveImageFile(
|
||||
bytes: canonical.data.byteLength,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
},
|
||||
source: metadata,
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,11 +30,14 @@ async function flatImage(width: number, height: number, format: 'png' | 'jpeg' |
|
||||
}
|
||||
|
||||
describe('isCanonical', () => {
|
||||
it('accepts an in-budget PNG/JPEG/WebP and refuses GIF, oversized edges, and oversized bytes', () => {
|
||||
expect(isCanonical({ mediaType: 'image/png', width: 2048, height: 4 }, 100, POLICY)).toBe(true)
|
||||
expect(isCanonical({ mediaType: 'image/gif', width: 4, height: 4 }, 100, POLICY)).toBe(false)
|
||||
expect(isCanonical({ mediaType: 'image/jpeg', width: 2049, height: 4 }, 100, POLICY)).toBe(false)
|
||||
expect(isCanonical({ mediaType: 'image/webp', width: 4, height: 4 }, POLICY.maxBytes + 1, POLICY)).toBe(false)
|
||||
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 }
|
||||
expect(isCanonical({ mediaType: 'image/png', width: 2048, height: 4, ...clean }, 100, POLICY)).toBe(true)
|
||||
expect(isCanonical({ mediaType: 'image/gif', width: 4, height: 4, ...clean }, 100, POLICY)).toBe(false)
|
||||
expect(isCanonical({ mediaType: 'image/webp', width: 4, height: 4, animated: true, carriesMetadata: false }, 100, POLICY)).toBe(false)
|
||||
expect(isCanonical({ mediaType: 'image/jpeg', width: 4, height: 4, animated: false, carriesMetadata: true }, 100, POLICY)).toBe(false)
|
||||
expect(isCanonical({ mediaType: 'image/jpeg', width: 2049, height: 4, ...clean }, 100, POLICY)).toBe(false)
|
||||
expect(isCanonical({ mediaType: 'image/webp', width: 4, height: 4, ...clean }, POLICY.maxBytes + 1, POLICY)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,7 +59,7 @@ describe('canonicalizeImage', () => {
|
||||
const canonical = await canonicalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes })
|
||||
|
||||
expect(canonical).toMatchObject({ mediaType: 'image/png', width: 5, height: 3 })
|
||||
await expect(detectImage(canonical.data)).resolves.toEqual({ mediaType: 'image/png', width: 5, height: 3 })
|
||||
await expect(detectImage(canonical.data)).resolves.toEqual({ mediaType: 'image/png', width: 5, height: 3, animated: false, carriesMetadata: false })
|
||||
const again = await canonicalizeImage(data, detected, { maxDimension: 5, maxBytes: POLICY.maxBytes })
|
||||
expect(again.data).toEqual(canonical.data)
|
||||
})
|
||||
@@ -77,7 +80,7 @@ describe('canonicalizeImage', () => {
|
||||
const canonical = await canonicalizeImage(data, detected, POLICY)
|
||||
|
||||
expect(canonical.mediaType).toBe('image/png')
|
||||
await expect(detectImage(canonical.data)).resolves.toEqual({ mediaType: 'image/png', width: 6, height: 4 })
|
||||
await expect(detectImage(canonical.data)).resolves.toEqual({ mediaType: 'image/png', width: 6, height: 4, animated: false, carriesMetadata: false })
|
||||
})
|
||||
|
||||
it('keeps alpha sources on PNG when the budget holds', async () => {
|
||||
@@ -132,8 +135,24 @@ describe('canonicalizeImage', () => {
|
||||
.rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' })
|
||||
})
|
||||
|
||||
it('re-encodes an in-budget oriented JPEG, baking rotation and stripping metadata', async () => {
|
||||
const data = new Uint8Array(await sharp({
|
||||
create: { width: 4, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } },
|
||||
}).jpeg().withMetadata({ orientation: 6 }).toBuffer())
|
||||
const detected = await detectImage(data)
|
||||
// Orientation 6 rotates 90°: the perceived source is 2x4.
|
||||
expect(detected).toMatchObject({ width: 2, height: 4, carriesMetadata: true })
|
||||
|
||||
const canonical = await canonicalizeImage(data, detected, POLICY)
|
||||
|
||||
expect(canonical.data).not.toBe(data)
|
||||
expect(canonical).toMatchObject({ width: 2, height: 4 })
|
||||
await expect(detectImage(canonical.data)).resolves.toMatchObject({ width: 2, height: 4, carriesMetadata: false })
|
||||
})
|
||||
|
||||
it('maps an encoder fault on undecodable bytes to a storage failure', async () => {
|
||||
await expect(canonicalizeImage(Uint8Array.of(1, 2, 3), { mediaType: 'image/png', width: 5000, height: 5000 }, POLICY))
|
||||
const detected = { mediaType: 'image/png', width: 5000, height: 5000, animated: false, carriesMetadata: false } as const
|
||||
await expect(canonicalizeImage(Uint8Array.of(1, 2, 3), detected, POLICY))
|
||||
.rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' })
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,7 @@ describe('raster decoding', () => {
|
||||
['gif', 'image/gif'],
|
||||
] as const) {
|
||||
await expect(detectImage(await raster(format)))
|
||||
.resolves.toEqual({ mediaType, width: 3, height: 2 })
|
||||
.resolves.toEqual({ mediaType, width: 3, height: 2, animated: false, carriesMetadata: false })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('raster decoding', () => {
|
||||
await expect(detectImage(await raster('png'), { maxDimension: 2 }))
|
||||
.rejects.toMatchObject({ code: 'IMAGE_DIMENSION_TOO_LARGE' })
|
||||
await expect(detectImage(await raster('png'), { maxDimension: 3 }))
|
||||
.resolves.toEqual({ mediaType: 'image/png', width: 3, height: 2 })
|
||||
.resolves.toEqual({ mediaType: 'image/png', width: 3, height: 2, animated: false, carriesMetadata: false })
|
||||
})
|
||||
|
||||
it('rejects malformed bytes and truncated payloads with readable headers', async () => {
|
||||
@@ -47,6 +47,27 @@ describe('raster decoding', () => {
|
||||
await expect(detectImage(truncated)).rejects.toMatchObject({ code: 'INVALID_IMAGE' })
|
||||
})
|
||||
|
||||
it('reports animation from a multi-frame container and perceived axes from EXIF orientation', async () => {
|
||||
const header = Buffer.from('47494638396101000100800000000000ffffff', 'hex')
|
||||
const frame = Buffer.from('21f90401000000002c0000000001000100000202440100', 'hex')
|
||||
const twoFrameGif = Uint8Array.from(Buffer.concat([header, frame, frame, Buffer.from('3b', 'hex')]))
|
||||
await expect(detectImage(twoFrameGif)).resolves.toMatchObject({ mediaType: 'image/gif', animated: true })
|
||||
|
||||
const oriented = new Uint8Array(await sharp({
|
||||
create: { width: 4, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } },
|
||||
}).jpeg().withMetadata({ orientation: 6 }).toBuffer())
|
||||
await expect(detectImage(oriented)).resolves.toEqual({
|
||||
mediaType: 'image/jpeg', width: 2, height: 4, animated: false, carriesMetadata: true,
|
||||
})
|
||||
|
||||
const flipped = new Uint8Array(await sharp({
|
||||
create: { width: 4, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } },
|
||||
}).jpeg().withMetadata({ orientation: 3 }).toBuffer())
|
||||
await expect(detectImage(flipped)).resolves.toEqual({
|
||||
mediaType: 'image/jpeg', width: 4, height: 2, animated: false, carriesMetadata: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('probes malformed bytes and unsupported formats into the same stable error', async () => {
|
||||
await expect(probeImage(Uint8Array.of(1, 2, 3)))
|
||||
.rejects.toMatchObject({ code: 'INVALID_IMAGE' })
|
||||
|
||||
@@ -47,6 +47,24 @@ describe('local attachment service', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a batch during validation when a member cannot meet the canonical byte target, before any write', async () => {
|
||||
const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-batch-'))
|
||||
try {
|
||||
const service = new LocalAttachmentStore(new Context(), { dshHome, canonicalMaxBytes: 10 })
|
||||
const valid = Uint8Array.from(Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
))
|
||||
await expect(service.saveImages([
|
||||
{ data: valid, mediaType: 'image/png' },
|
||||
{ data: valid, mediaType: 'image/png' },
|
||||
])).rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' })
|
||||
expect(existsSync(service.root)).toBe(false)
|
||||
} finally {
|
||||
await rm(dshHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('validates without persisting: a rejected image leaves no storage root behind', async () => {
|
||||
const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-validate-'))
|
||||
try {
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: 89bc3ca3a288450c43fefb5dde38da7f65218f43
|
||||
README.zh.md: ca13c9e66234b280f7fa9d01fc80bd026604831f
|
||||
README.md: 3b80444804a345bd019fe94f25954933aa549518
|
||||
README.zh.md: 37be4a4a9f54a7e7ddb5fdceb57711378c2f2cfc
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
The durable attachment seam. `ctx.attachments` validates and durably commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events.
|
||||
|
||||
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, validates every member before writing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and resolves `SavedImageAttachment`: an implementation may persist a canonical re-encoding of the submitted raster, so the returned `ref` always describes the stored bytes while `source` (`SourceImageInfo`) preserves the submitted raster's media type, byte length, and dimensions for callers that report or map coordinates against the original. `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure.
|
||||
Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the complete admission policy without persisting, including any canonical-encoding dry run the implementation applies, so batch validation proves every member can also be committed. `saveImages` owns batch count and aggregate-byte limits, validates every member before writing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published and resolves `SavedImageAttachment`: an implementation may persist a canonical re-encoding of the submitted raster, so the returned `ref` always describes the stored bytes while `source` (`SourceImageInfo`) preserves the submitted raster's media type, byte length, and dimensions for callers that report or map coordinates against the original. `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure.
|
||||
|
||||
`admitEncodedImages(attachments, images)` is the shared wire entry used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64 on every member, then delegates batch admission — limits, validation, ordered commit — to `saveImages`. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
持久附件服务边界。`ctx.attachments` 校验并持久提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。
|
||||
|
||||
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,先校验全部成员,再按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并解析为 `SavedImageAttachment`:实现可以持久保存所提交光栅的规范重编码,因此返回的 `ref` 始终描述实际存储的字节,而 `source`(`SourceImageInfo`)保留所提交光栅的媒体类型、字节长度和尺寸,供需要对照原图汇报或换算坐标的调用方使用。`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。
|
||||
未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行完整的准入策略但不执行持久化,包含实现所应用的规范编码干跑,因此批量校验能证明每个成员随后也能提交成功。`saveImages` 负责批次图片数量和总字节限制,先校验全部成员,再按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,并解析为 `SavedImageAttachment`:实现可以持久保存所提交光栅的规范重编码,因此返回的 `ref` 始终描述实际存储的字节,而 `source`(`SourceImageInfo`)保留所提交光栅的媒体类型、字节长度和尺寸,供需要对照原图汇报或换算坐标的调用方使用。`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。
|
||||
|
||||
`admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的 wire 入口:它对每个成员强制执行规范 base64,随后把批量准入——限额、校验、有序提交——委托给 `saveImages`。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。
|
||||
|
||||
|
||||
@@ -65,9 +65,9 @@ export interface SourceImageInfo {
|
||||
mediaType: ImageMediaType
|
||||
/** Exact submitted encoded byte length. */
|
||||
bytes: number
|
||||
/** Intrinsic width of the submitted raster in pixels. */
|
||||
/** Perceived source width in pixels, with any EXIF orientation applied, so it shares axes with the stored raster. */
|
||||
width: number
|
||||
/** Intrinsic height of the submitted raster in pixels. */
|
||||
/** Perceived source height in pixels, with any EXIF orientation applied, so it shares axes with the stored raster. */
|
||||
height: number
|
||||
}
|
||||
|
||||
|
||||
@@ -104,9 +104,17 @@ export function imageRefFromValue(image: ImageReadValue['image']): ImageAttachme
|
||||
* @returns the model-facing envelope; the image itself rides the adjacent image block.
|
||||
*/
|
||||
export function formatImageReadOutput(displayPath: string, image: ImageReadValue['image']): string {
|
||||
const scaled = image.sourceWidth !== undefined && image.sourceHeight !== undefined
|
||||
? ` (downscaled from ${image.sourceWidth}x${image.sourceHeight} px; multiply coordinates by ${(image.sourceWidth / image.width).toFixed(2)} to locate features in the original file)`
|
||||
: ''
|
||||
let scaled = ''
|
||||
if (image.sourceWidth !== undefined && image.sourceHeight !== undefined) {
|
||||
// Integer rounding can give the two axes slightly different ratios, so the
|
||||
// advice names one multiplier only when both round to the same value.
|
||||
const x = (image.sourceWidth / image.width).toFixed(2)
|
||||
const y = (image.sourceHeight / image.height).toFixed(2)
|
||||
const advice = x === y
|
||||
? `multiply coordinates by ${x}`
|
||||
: `multiply x coordinates by ${x} and y coordinates by ${y}`
|
||||
scaled = ` (downscaled from ${image.sourceWidth}x${image.sourceHeight} px; ${advice} to locate features in the original file)`
|
||||
}
|
||||
return `<path>${displayPath}</path>
|
||||
<type>image</type>
|
||||
<content>
|
||||
@@ -219,6 +227,12 @@ export function applyReadImageTool(ctx: Context): void {
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
if (error.code === 'IMAGE_TOO_LARGE') {
|
||||
throw new Error(
|
||||
`cannot read "${target.displayPath}": the image cannot be stored within the deployment's byte limits; downscale the image and read the smaller copy`,
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
if (error.code !== 'IMAGE_TYPE_MISMATCH') throw error
|
||||
const extension = extname(target.displayPath).toLowerCase()
|
||||
throw new Error(
|
||||
|
||||
@@ -438,6 +438,11 @@ describe('image admission failures', () => {
|
||||
expect(storageFault.isError).toBe(true)
|
||||
expect(text(storageFault)).toContain('Unable to persist image attachment.')
|
||||
|
||||
FailingStore.failure = new AttachmentError('Image cannot be encoded within the configured canonical byte target.', 'IMAGE_TOO_LARGE')
|
||||
const overBudget = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model'))
|
||||
expect(overBudget.isError).toBe(true)
|
||||
expect(text(overBudget)).toContain('cannot be stored within the deployment\'s byte limits; downscale the image and read the smaller copy')
|
||||
|
||||
FailingStore.failure = new Error('unrelated infrastructure failure')
|
||||
const unrelated = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model'))
|
||||
expect(unrelated.isError).toBe(true)
|
||||
@@ -529,6 +534,13 @@ describe('image admission failures', () => {
|
||||
expect(result.isError).toBe(false)
|
||||
expect(text(result)).toContain('image/png image, 2x1 px, 7 bytes (downscaled from 4x2 px; multiply coordinates by 2.00 to locate features in the original file)')
|
||||
})
|
||||
|
||||
it('names per-axis multipliers when integer rounding makes the ratios differ', () => {
|
||||
const envelope = formatImageReadOutput('/img/photo.jpg', {
|
||||
attachmentId: 'sha256:feed', mediaType: 'image/jpeg', bytes: 9, width: 2, height: 1, sourceWidth: 5, sourceHeight: 2,
|
||||
})
|
||||
expect(envelope).toContain('downscaled from 5x2 px; multiply x coordinates by 2.50 and y coordinates by 2.00 to locate features in the original file')
|
||||
})
|
||||
})
|
||||
|
||||
describe('registration surface', () => {
|
||||
|
||||
Reference in new issue
Block a user