import { Config } from "@/config/config" import type { MessageV2 } from "@/session/message-v2" import * as EffectLogger from "@opencode-ai/core/effect/logger" import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" } import { Context, Effect, Layer, Schema } from "effect" import path from "node:path" import { fileURLToPath } from "node:url" const MAX_BASE64_BYTES = 5 * 1024 * 1024 const MAX_WIDTH = 2000 const MAX_HEIGHT = 2000 const AUTO_RESIZE = true const JPEG_QUALITIES = [80, 85, 70, 55, 40] const log = EffectLogger.create({ service: "image" }) export class ResizerUnavailableError extends Schema.TaggedErrorClass()( "ImageResizerUnavailableError", {}, ) { override get message() { return "Image resizer is unavailable" } } export class InvalidDataUrlError extends Schema.TaggedErrorClass()("ImageInvalidDataUrlError", { url: Schema.String, }) { override get message() { return "Image URL must be a base64 data URL" } } export class DecodeError extends Schema.TaggedErrorClass()("ImageDecodeError", {}) { override get message() { return "Image could not be decoded" } } export class SizeError extends Schema.TaggedErrorClass()("ImageSizeError", { bytes: Schema.Number, max: Schema.Number, width: Schema.Number, height: Schema.Number, max_width: Schema.Number, max_height: Schema.Number, }) { override get message() { return `Image ${this.width}x${this.height} with base64 size ${this.bytes} exceeds configured limits and could not be resized below ${this.max_width}x${this.max_height}/${this.max} bytes` } } export type Error = ResizerUnavailableError | InvalidDataUrlError | DecodeError | SizeError export interface Interface { readonly normalize: (input: MessageV2.FilePart) => Effect.Effect } export class Service extends Context.Service()("@opencode/Image") {} export const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service const loadPhoton = yield* Effect.cached( Effect.sync(() => { // Patched photon-node reads this during module init so Bun compiled binaries use the embedded wasm path. ;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH = path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url)) }).pipe( Effect.andThen(() => Effect.tryPromise(() => import("@silvia-odwyer/photon-node"))), Effect.tapError((error) => Effect.sync(() => log.warn("failed to load photon", { error }))), Effect.mapError(() => new ResizerUnavailableError()), ), ) const normalize = Effect.fn("Image.normalize")(function* (input: MessageV2.FilePart) { const image = (yield* config.get()).attachment?.image const info = { autoResize: image?.auto_resize ?? AUTO_RESIZE, maxWidth: image?.max_width ?? MAX_WIDTH, maxHeight: image?.max_height ?? MAX_HEIGHT, maxBase64Bytes: image?.max_base64_bytes ?? MAX_BASE64_BYTES, } if (!input.url.startsWith("data:") || !input.url.includes(";base64,")) return yield* new InvalidDataUrlError({ url: input.url }) const base64 = input.url.slice(input.url.indexOf(";base64,") + ";base64,".length) const bytes = Buffer.byteLength(base64, "utf8") const photon = yield* loadPhoton const decoded = yield* Effect.try({ try: () => photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")), catch: (error) => { log.warn("failed to decode image", { error }) return new DecodeError() }, }) try { const originalWidth = decoded.get_width() const originalHeight = decoded.get_height() if (originalWidth <= info.maxWidth && originalHeight <= info.maxHeight && bytes <= info.maxBase64Bytes) return input if (!info.autoResize) return yield* new SizeError({ bytes, max: info.maxBase64Bytes, width: originalWidth, height: originalHeight, max_width: info.maxWidth, max_height: info.maxHeight, }) const scale = Math.min(1, info.maxWidth / originalWidth, info.maxHeight / originalHeight) for (const size of Array.from({ length: 32 }).reduce>((acc) => { const previous = acc.at(-1) ?? { width: Math.max(1, Math.round(originalWidth * scale)), height: Math.max(1, Math.round(originalHeight * scale)), } const next = acc.length === 0 ? previous : { width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)), height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)), } return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next] }, [])) { const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3) const candidate = [ { data: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" }, ...JPEG_QUALITIES.map((quality) => ({ data: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"), mime: "image/jpeg", })), ] .map((item) => ({ ...item, bytes: Buffer.byteLength(item.data, "utf8") })) .find((item) => item.bytes <= info.maxBase64Bytes) resized.free() if (candidate) { log.info("using resized image", { from_mime: input.mime, to_mime: candidate.mime, from: `${originalWidth}x${originalHeight}`, to: `${size.width}x${size.height}`, }) return { ...input, mime: candidate.mime, url: `data:${candidate.mime};base64,${candidate.data}`, } } } return yield* new SizeError({ bytes, max: info.maxBase64Bytes, width: originalWidth, height: originalHeight, max_width: info.maxWidth, max_height: info.maxHeight, }) } finally { decoded.free() } }) return Service.of({ normalize }) }), ) export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) export * as Image from "./image"