From ad62897a9ba569fafd17fda5d45641fdbf4bf1af Mon Sep 17 00:00:00 2001 From: ALF Operator Date: Sun, 15 Mar 2026 07:53:31 +0100 Subject: [PATCH] fix: resolve HTTP 413 in next-image template via client-side compression - Remove dead Pages Router bodyParser config from App Router handlers - Add compressImage() utility for client-side image resizing - Compress images before sending to edit API Fixes Merit-Systems/echo#561 --- .../src/app/api/edit-image/route.ts | 9 +--- .../src/app/api/generate-image/route.ts | 9 +--- .../src/components/image-generator.tsx | 7 +-- templates/next-image/src/lib/image-utils.ts | 48 +++++++++++++++++++ 4 files changed, 54 insertions(+), 19 deletions(-) diff --git a/templates/next-image/src/app/api/edit-image/route.ts b/templates/next-image/src/app/api/edit-image/route.ts index 11c52b38e..254c2ff2d 100644 --- a/templates/next-image/src/app/api/edit-image/route.ts +++ b/templates/next-image/src/app/api/edit-image/route.ts @@ -4,6 +4,7 @@ * This route demonstrates Echo SDK integration with AI image editing: * - Uses both Google Gemini and OpenAI for image editing * - Supports both data URLs (base64) and regular URLs + * - Images should be compressed client-side to avoid 413 errors * - Validates input images and prompts * - Returns edited images in appropriate format */ @@ -17,14 +18,6 @@ const providers = { gemini: handleGoogleEdit, }; -export const config = { - api: { - bodyParser: { - sizeLimit: '4mb', - }, - }, -}; - export async function POST(req: Request) { try { const body = await req.json(); diff --git a/templates/next-image/src/app/api/generate-image/route.ts b/templates/next-image/src/app/api/generate-image/route.ts index 15bf30c3a..1add94329 100644 --- a/templates/next-image/src/app/api/generate-image/route.ts +++ b/templates/next-image/src/app/api/generate-image/route.ts @@ -5,6 +5,7 @@ * - Supports both OpenAI and Gemini models * - Handles text-to-image generation * - Returns base64 encoded images for consistent handling + * - Request payloads are kept small (generation only sends text prompts) */ import { @@ -19,14 +20,6 @@ const providers = { gemini: handleGoogleGenerate, }; -export const config = { - api: { - bodyParser: { - sizeLimit: '4mb', - }, - }, -}; - export async function POST(req: Request) { try { const body = await req.json(); diff --git a/templates/next-image/src/components/image-generator.tsx b/templates/next-image/src/components/image-generator.tsx index e585d3cc5..18cbddebe 100644 --- a/templates/next-image/src/components/image-generator.tsx +++ b/templates/next-image/src/components/image-generator.tsx @@ -25,7 +25,7 @@ import { Button } from '@/components/ui/button'; import { X } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; -import { fileToDataUrl } from '@/lib/image-utils'; +import { fileToDataUrl, compressImage } from '@/lib/image-utils'; import type { EditImageRequest, GeneratedImage, @@ -219,12 +219,13 @@ export default function ImageGenerator() { try { const imageUrls = await Promise.all( imageFiles.map(async imageFile => { - // Convert blob URL to data URL for API + // Convert blob URL to data URL, then compress to avoid 413 errors const response = await fetch(imageFile.url); const blob = await response.blob(); - return await fileToDataUrl( + const dataUrl = await fileToDataUrl( new File([blob], 'image', { type: imageFile.mediaType }) ); + return await compressImage(dataUrl); }) ); diff --git a/templates/next-image/src/lib/image-utils.ts b/templates/next-image/src/lib/image-utils.ts index cefb219f3..50cb0a827 100644 --- a/templates/next-image/src/lib/image-utils.ts +++ b/templates/next-image/src/lib/image-utils.ts @@ -75,3 +75,51 @@ export function getMediaTypeFromDataUrl(dataUrl: string): string { if (!dataUrl.startsWith('data:')) return 'image/jpeg'; return dataUrl.match(/^data:([^;]+);base64,/)?.[1] || 'image/jpeg'; } + +/** + * Compresses an image to reduce payload size before sending to the API. + * Prevents HTTP 413 (Request Entity Too Large) when images are sent as + * base64 data URLs in JSON request bodies. + * + * @param dataUrl - The source image as a data URL + * @param maxDimension - Maximum width or height in pixels (default: 2048) + * @param quality - JPEG compression quality 0-1 (default: 0.85) + * @returns Compressed image as a data URL + */ +export async function compressImage( + dataUrl: string, + maxDimension = 2048, + quality = 0.85 +): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + let { width, height } = img; + + // Scale down if exceeding max dimension, preserving aspect ratio + if (width > maxDimension || height > maxDimension) { + const scale = maxDimension / Math.max(width, height); + width = Math.round(width * scale); + height = Math.round(height * scale); + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) { + resolve(dataUrl); + return; + } + ctx.drawImage(img, 0, 0, width, height); + + // Keep PNG for images that may have transparency, use JPEG otherwise + const mediaType = getMediaTypeFromDataUrl(dataUrl); + const outputType = mediaType === 'image/png' ? 'image/png' : 'image/jpeg'; + resolve(canvas.toDataURL(outputType, quality)); + }; + img.onerror = () => + reject(new Error('Failed to load image for compression')); + img.src = dataUrl; + }); +}