diff --git a/backend/internal/cfai/image.go b/backend/internal/cfai/image.go index 264a435..bd09738 100644 --- a/backend/internal/cfai/image.go +++ b/backend/internal/cfai/image.go @@ -29,8 +29,11 @@ import ( "encoding/json" "fmt" "image" + "image/draw" + "image/jpeg" _ "image/jpeg" // register JPEG decoder - _ "image/png" // register PNG decoder + "image/png" + _ "image/png" // register PNG decoder "io" "net/http" "time" @@ -189,6 +192,11 @@ func (c *imageGenHTTPClient) GenerateImage(ctx context.Context, req ImageRequest return c.callImageAPI(ctx, req.Model, body) } +// refImageMaxDim is the maximum dimension (width or height) for reference images +// sent to Cloudflare Workers AI. CF's JSON body limit is ~4 MB; a 768px JPEG +// stays well under that while preserving enough detail for img2img guidance. +const refImageMaxDim = 768 + // GenerateImageFromReference generates an image from a text prompt + reference image. func (c *imageGenHTTPClient) GenerateImageFromReference(ctx context.Context, req ImageRequest, refImage []byte) ([]byte, error) { if len(refImage) == 0 { @@ -196,6 +204,10 @@ func (c *imageGenHTTPClient) GenerateImageFromReference(ctx context.Context, req } req = applyImageDefaults(req) + // Shrink the reference image if it exceeds the safe payload size. + // This avoids CF's 4 MB JSON body limit and reduces latency. + refImage = resizeRefImage(refImage, refImageMaxDim) + var body map[string]any if req.Model == ImageModelSD15Img2Img { pixels, err := decodeImageToRGBA(refImage) @@ -286,6 +298,60 @@ func applyImageDefaults(req ImageRequest) ImageRequest { return req } +// resizeRefImage down-scales an image so that its longest side is at most maxDim +// pixels, then re-encodes it as JPEG (quality 85). If the image is already small +// enough, or if decoding fails, the original bytes are returned unchanged. +// This keeps the JSON payload well under Cloudflare Workers AI's 4 MB body limit. +func resizeRefImage(data []byte, maxDim int) []byte { + src, format, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return data + } + b := src.Bounds() + w, h := b.Dx(), b.Dy() + + longest := w + if h > longest { + longest = h + } + if longest <= maxDim { + return data // already fits + } + + // Compute target dimensions preserving aspect ratio. + scale := float64(maxDim) / float64(longest) + newW := int(float64(w)*scale + 0.5) + newH := int(float64(h)*scale + 0.5) + if newW < 1 { + newW = 1 + } + if newH < 1 { + newH = 1 + } + + // Nearest-neighbour downsample (no extra deps, sufficient for reference guidance). + dst := image.NewRGBA(image.Rect(0, 0, newW, newH)) + for y := 0; y < newH; y++ { + for x := 0; x < newW; x++ { + srcX := b.Min.X + int(float64(x)/scale) + srcY := b.Min.Y + int(float64(y)/scale) + draw.Draw(dst, image.Rect(x, y, x+1, y+1), src, image.Pt(srcX, srcY), draw.Src) + } + } + + var buf bytes.Buffer + if format == "jpeg" { + if encErr := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 85}); encErr != nil { + return data + } + } else { + if encErr := png.Encode(&buf, dst); encErr != nil { + return data + } + } + return buf.Bytes() +} + // decodeImageToRGBA decodes PNG/JPEG bytes to a flat []uint8 RGBA pixel array // required by the stable-diffusion-v1-5-img2img model. func decodeImageToRGBA(data []byte) ([]uint8, error) {