Skip to content

Commit f2d7b1b

Browse files
authored
fix(core): isolate image normalization (anomalyco#31165)
1 parent ac64a21 commit f2d7b1b

5 files changed

Lines changed: 230 additions & 135 deletions

File tree

packages/core/src/image.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
export * as Image from "./image"
2+
3+
import { Context, Effect, Layer, Schema } from "effect"
4+
import { Config } from "./config"
5+
import { FileSystem } from "./filesystem"
6+
7+
export class ResizerUnavailableError extends Schema.TaggedErrorClass<ResizerUnavailableError>()(
8+
"Image.ResizerUnavailableError",
9+
{},
10+
) {}
11+
12+
export class DecodeError extends Schema.TaggedErrorClass<DecodeError>()("Image.DecodeError", {
13+
resource: Schema.String,
14+
}) {
15+
override get message() {
16+
return `Image could not be decoded: ${this.resource}`
17+
}
18+
}
19+
20+
export class SizeError extends Schema.TaggedErrorClass<SizeError>()("Image.SizeError", {
21+
resource: Schema.String,
22+
width: Schema.Number,
23+
height: Schema.Number,
24+
bytes: Schema.Number,
25+
maxWidth: Schema.Number,
26+
maxHeight: Schema.Number,
27+
maxBytes: Schema.Number,
28+
}) {
29+
override get message() {
30+
return `Image ${this.resource} is ${this.width}x${this.height} with base64 size ${this.bytes}, exceeding configured limits ${this.maxWidth}x${this.maxHeight}/${this.maxBytes} bytes`
31+
}
32+
}
33+
34+
export interface Interface {
35+
readonly normalize: (
36+
resource: string,
37+
content: FileSystem.BinaryContent,
38+
) => Effect.Effect<FileSystem.BinaryContent, ResizerUnavailableError | DecodeError | SizeError>
39+
}
40+
41+
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
42+
43+
export const layer = Layer.effect(
44+
Service,
45+
Effect.gen(function* () {
46+
const config = yield* Config.Service
47+
const loadAdapter = yield* Effect.cached(
48+
Effect.tryPromise({
49+
try: () => import("./image/photon"),
50+
catch: () => new ResizerUnavailableError(),
51+
}).pipe(Effect.flatMap((adapter) => adapter.make)),
52+
)
53+
const normalize = Effect.fn("Image.normalize")(function* (resource: string, content: FileSystem.BinaryContent) {
54+
const image = Object.assign(
55+
{},
56+
...(yield* config.entries()).flatMap((entry) =>
57+
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
58+
),
59+
)
60+
const normalize = yield* loadAdapter
61+
return yield* normalize(resource, content, {
62+
autoResize: image.auto_resize ?? true,
63+
maxWidth: image.max_width ?? 2_000,
64+
maxHeight: image.max_height ?? 2_000,
65+
maxBase64Bytes: image.max_base64_bytes ?? 5 * 1024 * 1024,
66+
})
67+
})
68+
return Service.of({ normalize })
69+
}),
70+
)
71+
72+
export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer))

packages/core/src/image/photon.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// @ts-ignore Bun's static file import is embedded by `bun build --compile`; some consumers also declare *.wasm.
2+
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
3+
import { Effect } from "effect"
4+
import path from "node:path"
5+
import { fileURLToPath } from "node:url"
6+
import { FileSystem } from "../filesystem"
7+
import { DecodeError, ResizerUnavailableError, SizeError } from "../image"
8+
9+
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
10+
11+
export const make = Effect.gen(function* () {
12+
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
13+
path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url))
14+
const loadPhoton = yield* Effect.cached(
15+
Effect.tryPromise({
16+
try: () => import("@silvia-odwyer/photon-node"),
17+
catch: () => new ResizerUnavailableError(),
18+
}),
19+
)
20+
return Effect.fn("Image.Photon.normalize")(function* (
21+
resource: string,
22+
content: FileSystem.BinaryContent,
23+
limits: {
24+
readonly autoResize: boolean
25+
readonly maxWidth: number
26+
readonly maxHeight: number
27+
readonly maxBase64Bytes: number
28+
},
29+
) {
30+
const photon = yield* loadPhoton
31+
const decoded = yield* Effect.try({
32+
try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(content.content, "base64")),
33+
catch: () => new DecodeError({ resource }),
34+
})
35+
try {
36+
const width = decoded.get_width()
37+
const height = decoded.get_height()
38+
const bytes = Buffer.byteLength(content.content, "utf-8")
39+
if (width <= limits.maxWidth && height <= limits.maxHeight && bytes <= limits.maxBase64Bytes) return content
40+
if (!limits.autoResize)
41+
return yield* new SizeError({
42+
resource,
43+
width,
44+
height,
45+
bytes,
46+
maxWidth: limits.maxWidth,
47+
maxHeight: limits.maxHeight,
48+
maxBytes: limits.maxBase64Bytes,
49+
})
50+
const scale = Math.min(1, limits.maxWidth / width, limits.maxHeight / height)
51+
const sizes = Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
52+
const previous = acc.at(-1) ?? {
53+
width: Math.max(1, Math.round(width * scale)),
54+
height: Math.max(1, Math.round(height * scale)),
55+
}
56+
const next =
57+
acc.length === 0
58+
? previous
59+
: {
60+
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
61+
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
62+
}
63+
return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next]
64+
}, [])
65+
for (const size of sizes) {
66+
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
67+
try {
68+
const encoders: Array<readonly [mime: string, encode: () => Uint8Array]> = [
69+
["image/png", () => resized.get_bytes()],
70+
...JPEG_QUALITIES.map((quality) => ["image/jpeg", () => resized.get_bytes_jpeg(quality)] as const),
71+
]
72+
for (const [mime, encode] of encoders) {
73+
const candidate = Buffer.from(encode()).toString("base64")
74+
if (Buffer.byteLength(candidate, "utf-8") <= limits.maxBase64Bytes)
75+
return new FileSystem.BinaryContent({ type: "binary", content: candidate, encoding: "base64", mime })
76+
}
77+
} finally {
78+
resized.free()
79+
}
80+
}
81+
return yield* new SizeError({
82+
resource,
83+
width,
84+
height,
85+
bytes,
86+
maxWidth: limits.maxWidth,
87+
maxHeight: limits.maxHeight,
88+
maxBytes: limits.maxBase64Bytes,
89+
})
90+
} finally {
91+
decoded.free()
92+
}
93+
})
94+
})

packages/core/src/location-layer.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { Pty } from "./pty"
2828
import { SkillV2 } from "./skill"
2929
import { SkillGuidance } from "./skill/guidance"
3030
import { BuiltInTools } from "./tool/builtins"
31+
import { Image } from "./image"
3132
import { ToolRegistry } from "./tool/registry"
3233
import { ApplicationTools } from "./tool/application-tools"
3334
import { ToolOutputStore } from "./tool-output-store"
@@ -71,6 +72,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
7172
Layer.provide(base),
7273
)
7374
const services = Layer.mergeAll(base, resources, permissionsAndTools)
75+
const image = Image.layer.pipe(Layer.provide(services))
7476
const mutation = FileMutation.locationLayer.pipe(Layer.provide(services))
7577
const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services))
7678
const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services))
@@ -83,16 +85,26 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
8385
Layer.provide(resources),
8486
Layer.provide(todos),
8587
Layer.provide(questions),
88+
Layer.provide(image),
8689
)
8790
const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services))
8891
const runner = SessionRunnerLLM.defaultLayer.pipe(
8992
Layer.provide(services),
9093
Layer.provide(model),
9194
Layer.provide(skillGuidance),
9295
)
93-
return Layer.mergeAll(services, mutation, searches, resources, todos, questions, model, runner, builtInTools).pipe(
94-
Layer.fresh,
95-
)
96+
return Layer.mergeAll(
97+
services,
98+
image,
99+
mutation,
100+
searches,
101+
resources,
102+
todos,
103+
questions,
104+
model,
105+
runner,
106+
builtInTools,
107+
).pipe(Layer.fresh)
96108
},
97109
idleTimeToLive: "60 minutes",
98110
dependencies: [

packages/core/src/tool/read.ts

Lines changed: 7 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,15 @@
11
export * as ReadTool from "./read"
22

33
import { ToolFailure } from "@opencode-ai/llm"
4-
// @ts-ignore Bun's static file import is embedded by `bun build --compile`; some consumers also declare *.wasm.
5-
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" }
64
import { Effect, Layer, Schema } from "effect"
7-
import path from "node:path"
8-
import { fileURLToPath } from "node:url"
9-
import { Config } from "../config"
105
import { FileSystem } from "../filesystem"
6+
import { Image } from "../image"
117
import { PermissionV2 } from "../permission"
128
import { Tool } from "./tool"
139
import { Tools } from "./tools"
1410

1511
export const name = "read"
1612
const SUPPORTED_IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"])
17-
const MAX_IMAGE_BASE64_BYTES = 5 * 1024 * 1024
18-
const MAX_IMAGE_WIDTH = 2_000
19-
const MAX_IMAGE_HEIGHT = 2_000
20-
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
21-
22-
class ImageDecodeError extends Error {
23-
constructor(readonly resource: string) {
24-
super(`Image could not be decoded: ${resource}`)
25-
this.name = "ImageDecodeError"
26-
}
27-
}
28-
29-
class ImageSizeError extends Error {
30-
constructor(
31-
readonly resource: string,
32-
readonly width: number,
33-
readonly height: number,
34-
readonly bytes: number,
35-
readonly maxWidth: number,
36-
readonly maxHeight: number,
37-
readonly maxBytes: number,
38-
) {
39-
super(
40-
`Image ${resource} is ${width}x${height} with base64 size ${bytes}, exceeding configured limits ${maxWidth}x${maxHeight}/${maxBytes} bytes`,
41-
)
42-
this.name = "ImageSizeError"
43-
}
44-
}
4513
const LocationInput = Schema.Struct({
4614
...FileSystem.ReadInput.fields,
4715
offset: FileSystem.ListPageInput.fields.offset.annotate({
@@ -58,14 +26,8 @@ export const layer = Layer.effectDiscard(
5826
Effect.gen(function* () {
5927
const tools = yield* Tools.Service
6028
const filesystem = yield* FileSystem.Service
61-
const config = yield* Config.Service
29+
const image = yield* Image.Service
6230
const permission = yield* PermissionV2.Service
63-
const loadPhoton = yield* Effect.cached(
64-
Effect.sync(() => {
65-
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH =
66-
path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url))
67-
}).pipe(Effect.andThen(() => Effect.promise(() => import("@silvia-odwyer/photon-node")))),
68-
)
6931

7032
yield* tools
7133
.register({
@@ -98,95 +60,9 @@ export const layer = Layer.effectDiscard(
9860
limit: input.limit,
9961
})
10062
if (content.type === "binary" && SUPPORTED_IMAGE_MIMES.has(content.mime)) {
101-
const mime = content.mime
102-
const base64 = content.content
103-
const image = Object.assign(
104-
{},
105-
...(yield* config.entries()).flatMap((entry) =>
106-
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
107-
),
108-
)
109-
const limits = {
110-
autoResize: image.auto_resize ?? true,
111-
maxWidth: image.max_width ?? MAX_IMAGE_WIDTH,
112-
maxHeight: image.max_height ?? MAX_IMAGE_HEIGHT,
113-
maxBase64Bytes: image.max_base64_bytes ?? MAX_IMAGE_BASE64_BYTES,
114-
}
115-
const photon = yield* loadPhoton
116-
const decoded = yield* Effect.try({
117-
try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")),
118-
catch: () => new ImageDecodeError(resolved.resource),
119-
})
120-
try {
121-
const width = decoded.get_width()
122-
const height = decoded.get_height()
123-
const bytes = Buffer.byteLength(base64, "utf-8")
124-
if (width <= limits.maxWidth && height <= limits.maxHeight && bytes <= limits.maxBase64Bytes)
125-
return new FileSystem.BinaryContent({ type: "binary", content: base64, encoding: "base64", mime })
126-
if (!limits.autoResize)
127-
return yield* Effect.fail(
128-
new ImageSizeError(
129-
resolved.resource,
130-
width,
131-
height,
132-
bytes,
133-
limits.maxWidth,
134-
limits.maxHeight,
135-
limits.maxBase64Bytes,
136-
),
137-
)
138-
const scale = Math.min(1, limits.maxWidth / width, limits.maxHeight / height)
139-
const sizes = Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
140-
const previous = acc.at(-1) ?? {
141-
width: Math.max(1, Math.round(width * scale)),
142-
height: Math.max(1, Math.round(height * scale)),
143-
}
144-
const next =
145-
acc.length === 0
146-
? previous
147-
: {
148-
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
149-
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
150-
}
151-
return acc.some((item) => item.width === next.width && item.height === next.height)
152-
? acc
153-
: [...acc, next]
154-
}, [])
155-
for (const size of sizes) {
156-
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
157-
try {
158-
const candidate = [
159-
{ content: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
160-
...JPEG_QUALITIES.map((quality) => ({
161-
content: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
162-
mime: "image/jpeg",
163-
})),
164-
].find((item) => Buffer.byteLength(item.content, "utf-8") <= limits.maxBase64Bytes)
165-
if (candidate)
166-
return new FileSystem.BinaryContent({
167-
type: "binary",
168-
content: candidate.content,
169-
encoding: "base64",
170-
mime: candidate.mime,
171-
})
172-
} finally {
173-
resized.free()
174-
}
175-
}
176-
return yield* Effect.fail(
177-
new ImageSizeError(
178-
resolved.resource,
179-
width,
180-
height,
181-
bytes,
182-
limits.maxWidth,
183-
limits.maxHeight,
184-
limits.maxBase64Bytes,
185-
),
186-
)
187-
} finally {
188-
decoded.free()
189-
}
63+
return yield* image
64+
.normalize(resolved.resource, content)
65+
.pipe(Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)))
19066
}
19167
if (content.type === "binary")
19268
return yield* Effect.fail(new FileSystem.BinaryFileError(resolved.resource))
@@ -196,8 +72,8 @@ export const layer = Layer.effectDiscard(
19672
const message =
19773
error instanceof FileSystem.BinaryFileError ||
19874
error instanceof FileSystem.MediaIngestLimitError ||
199-
error instanceof ImageDecodeError ||
200-
error instanceof ImageSizeError
75+
error instanceof Image.DecodeError ||
76+
error instanceof Image.SizeError
20177
? error.message
20278
: `Unable to read ${input.path}`
20379
return new ToolFailure({ message })

0 commit comments

Comments
 (0)