2026-06-09 19:31:31 +00:00
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
2026-05-10 06:48:19 +00:00
import { Config } from "@/config/config"
2026-06-03 02:42:13 +00:00
import { SessionV1 } from "@opencode-ai/core/v1/session"
2026-05-10 06:48:19 +00:00
import type { MessageV2 } from "@/session/message-v2"
2026-05-14 04:06:07 +00:00
import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type : "file" }
2026-05-10 06:48:19 +00:00
import { Context , Effect , Layer , Schema } from "effect"
2026-05-14 04:06:07 +00:00
import path from "node:path"
import { fileURLToPath } from "node:url"
2026-05-10 06:48:19 +00:00
2026-05-14 04:06:07 +00:00
const MAX_BASE64_BYTES = 5 * 1024 * 1024
2026-05-10 06:48:19 +00:00
const MAX_WIDTH = 2000
const MAX_HEIGHT = 2000
const AUTO_RESIZE = true
const JPEG_QUALITIES = [ 80 , 85 , 70 , 55 , 40 ]
2026-05-14 04:06:07 +00:00
export class ResizerUnavailableError extends Schema . TaggedErrorClass < ResizerUnavailableError > ( ) (
"ImageResizerUnavailableError" ,
2026-05-10 06:48:19 +00:00
{ } ,
) {
override get message() {
2026-05-14 04:06:07 +00:00
return "Image resizer is unavailable"
2026-05-10 06:48:19 +00:00
}
}
export class InvalidDataUrlError extends Schema . TaggedErrorClass < InvalidDataUrlError > ( ) ( "ImageInvalidDataUrlError" , {
url : Schema.String ,
} ) {
override get message() {
return "Image URL must be a base64 data URL"
}
}
export class DecodeError extends Schema . TaggedErrorClass < DecodeError > ( ) ( "ImageDecodeError" , { } ) {
override get message() {
return "Image could not be decoded"
}
}
export class SizeError extends Schema . TaggedErrorClass < SizeError > ( ) ( "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 `
}
}
2026-05-14 04:06:07 +00:00
export type Error = ResizerUnavailableError | InvalidDataUrlError | DecodeError | SizeError
2026-05-10 06:48:19 +00:00
export interface Interface {
2026-06-03 02:42:13 +00:00
readonly normalize : ( input : SessionV1.FilePart ) = > Effect . Effect < SessionV1.FilePart , Error >
2026-05-10 06:48:19 +00:00
}
export class Service extends Context . Service < Service , Interface > ( ) ( "@opencode/Image" ) { }
export const layer = Layer . effect (
Service ,
Effect . gen ( function * ( ) {
const config = yield * Config . Service
const loadPhoton = yield * Effect . cached (
2026-05-14 04:06:07 +00:00
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" ) ) ) ,
2026-06-08 19:41:56 +00:00
Effect . tapError ( ( error ) = > Effect . logWarning ( "failed to load photon" , { error } ) ) ,
2026-05-14 04:06:07 +00:00
Effect . mapError ( ( ) = > new ResizerUnavailableError ( ) ) ,
) ,
2026-05-10 06:48:19 +00:00
)
2026-06-03 02:42:13 +00:00
const normalize = Effect . fn ( "Image.normalize" ) ( function * ( input : SessionV1.FilePart ) {
2026-05-10 06:48:19 +00:00
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 )
2026-05-14 04:06:07 +00:00
const bytes = Buffer . byteLength ( base64 , "utf8" )
2026-05-10 06:48:19 +00:00
const photon = yield * loadPhoton
2026-05-14 04:06:07 +00:00
const decoded = yield * Effect . try ( {
try : ( ) = > photon . PhotonImage . new_from_byteslice ( Buffer . from ( base64 , "base64" ) ) ,
2026-06-08 19:41:56 +00:00
catch : ( ) = > new DecodeError ( ) ,
} ) . pipe ( Effect . tapError ( ( error ) = > Effect . logWarning ( "failed to decode image" , { error } ) ) )
2026-05-10 06:48:19 +00:00
try {
const originalWidth = decoded . get_width ( )
const originalHeight = decoded . get_height ( )
2026-05-14 04:06:07 +00:00
if ( originalWidth <= info . maxWidth && originalHeight <= info . maxHeight && bytes <= info . maxBase64Bytes )
2026-05-10 06:48:19 +00:00
return input
if ( ! info . autoResize )
return yield * new SizeError ( {
2026-05-14 04:06:07 +00:00
bytes ,
2026-05-10 06:48:19 +00:00
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 < Array < { width : number ; height : number } > > ( ( 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 ) {
2026-06-08 19:41:56 +00:00
yield * Effect . logInfo ( "using resized image" , {
2026-05-10 06:48:19 +00:00
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 ( {
2026-05-14 04:06:07 +00:00
bytes ,
2026-05-10 06:48:19 +00:00
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 ) )
2026-06-09 19:31:31 +00:00
export const node = LayerNode . make ( layer , [ Config . node ] )
2026-05-10 06:48:19 +00:00
export * as Image from "./image"