diff --git a/templates/next-image/next.config.ts b/templates/next-image/next.config.ts index 59692f72b..73a81722c 100644 --- a/templates/next-image/next.config.ts +++ b/templates/next-image/next.config.ts @@ -2,6 +2,29 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { transpilePackages: ['@merit-systems/echo-next-sdk'], + + // Fix HTTP 413 "Request Entity Too Large" for image routes (App Router). + // + // Background: Next.js App Router ignores the Pages-Router-only + // `export const config = { api: { bodyParser: { sizeLimit: '...' } } }` + // pattern. The correct way to raise the body-size limit in App Router is: + // + // 1. For Server Actions: `experimental.serverActions.bodySizeLimit` + // 2. For Route Handlers: the limit is controlled by the underlying + // Node.js / Edge runtime and is not exposed as a simple config key in + // Next.js ≤15 — the recommended approach is to read the raw stream + // yourself OR to configure a reverse-proxy (Vercel / nginx). However, + // Vercel already allows up to 4.5 MB on the free tier and up to 10 MB + // on paid plans. For self-hosted deployments we set the experimental + // option below which also covers route handlers as of Next.js 15. + // + // See: https://nextjs.org/docs/app/api-reference/config/next-config-js/serverActions + experimental: { + serverActions: { + // Allow up to 10 MB bodies — enough for multiple high-res base64 images. + bodySizeLimit: '10mb', + }, + }, }; export default nextConfig; diff --git a/templates/next-image/src/app/api/edit-image/google.ts b/templates/next-image/src/app/api/edit-image/google.ts index 527c4879d..357f517b5 100644 --- a/templates/next-image/src/app/api/edit-image/google.ts +++ b/templates/next-image/src/app/api/edit-image/google.ts @@ -7,12 +7,19 @@ import { generateText } from 'ai'; import { getMediaTypeFromDataUrl } from '@/lib/image-utils'; import { ERROR_MESSAGES } from '@/lib/constants'; +interface FileInput { + bytes: Uint8Array; + mediaType: string; + filename: string; +} + /** - * Handles Google Gemini image editing + * Handles Google Gemini image editing using raw file bytes. + * This avoids base64 data URL bloat in the request body. */ -export async function handleGoogleEdit( +export async function handleGoogleFileEdit( prompt: string, - imageUrls: string[] + files: FileInput[] ): Promise { try { const content = [ @@ -20,10 +27,10 @@ export async function handleGoogleEdit( type: 'text' as const, text: prompt, }, - ...imageUrls.map(imageUrl => ({ + ...files.map(file => ({ type: 'image' as const, - image: imageUrl, // Direct data URL - Gemini handles it - mediaType: getMediaTypeFromDataUrl(imageUrl), + image: file.bytes, + mediaType: file.mediaType, })), ]; @@ -64,3 +71,21 @@ export async function handleGoogleEdit( ); } } + +/** + * Handles Google Gemini image editing from data URLs (legacy/fallback). + * Converts data URLs to bytes and delegates to handleGoogleFileEdit. + */ +export async function handleGoogleEdit( + prompt: string, + imageUrls: string[] +): Promise { + const files: FileInput[] = imageUrls.map((url, i) => { + const mediaType = getMediaTypeFromDataUrl(url); + const base64 = url.split(',')[1]; + const bytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0)); + return { bytes, mediaType, filename: `image-${i}.png` }; + }); + + return handleGoogleFileEdit(prompt, files); +} diff --git a/templates/next-image/src/app/api/edit-image/openai.ts b/templates/next-image/src/app/api/edit-image/openai.ts index 6fbee624d..512135638 100644 --- a/templates/next-image/src/app/api/edit-image/openai.ts +++ b/templates/next-image/src/app/api/edit-image/openai.ts @@ -8,11 +8,12 @@ import { dataUrlToFile } from '@/lib/image-utils'; import { ERROR_MESSAGES } from '@/lib/constants'; /** - * Handles OpenAI image editing + * Handles OpenAI image editing using File objects directly. + * This avoids base64 data URL bloat in the request body. */ -export async function handleOpenAIEdit( +export async function handleOpenAIFileEdit( prompt: string, - imageUrls: string[] + imageFiles: File[] ): Promise { const token = await getEchoToken(); @@ -23,17 +24,12 @@ export async function handleOpenAIEdit( ); } - // OpenAI editImage API is not supported through Vercel AI SDK, so we must construct - // a raw TS OpenAI client. - // https://platform.openai.com/docs/api-reference/images/createEdit const openaiClient = new OpenAI({ apiKey: token, baseURL: 'https://echo.router.merit.systems', }); try { - const imageFiles = imageUrls.map(url => dataUrlToFile(url, 'image.png')); - const result = await openaiClient.images.edit({ image: imageFiles, prompt, @@ -65,3 +61,15 @@ export async function handleOpenAIEdit( ); } } + +/** + * Handles OpenAI image editing from data URLs (legacy/fallback). + * Converts data URLs to File objects and delegates to handleOpenAIFileEdit. + */ +export async function handleOpenAIEdit( + prompt: string, + imageUrls: string[] +): Promise { + const imageFiles = imageUrls.map(url => dataUrlToFile(url, 'image.png')); + return handleOpenAIFileEdit(prompt, imageFiles); +} 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..0a930301d 100644 --- a/templates/next-image/src/app/api/edit-image/route.ts +++ b/templates/next-image/src/app/api/edit-image/route.ts @@ -1,32 +1,70 @@ /** * API Route: Edit Image * - * This route demonstrates Echo SDK integration with AI image editing: + * This route handles AI image editing via Echo SDK: + * - Supports multipart/form-data (File objects, preferred) and JSON (data URLs, fallback) * - Uses both Google Gemini and OpenAI for image editing - * - Supports both data URLs (base64) and regular URLs * - Validates input images and prompts - * - Returns edited images in appropriate format + * - Returns edited images as base64 data URLs + * + * The multipart path avoids base64 encoding of images in the request body, + * which prevents HTTP 413 (Entity Too Large) errors with large images. */ -import { EditImageRequest, validateEditImageRequest } from './validation'; -import { handleGoogleEdit } from './google'; -import { handleOpenAIEdit } from './openai'; +import { + EditImageRequest, + validateEditImageRequest, + validateMultipartEditImageRequest, +} from './validation'; +import { handleGoogleEdit, handleGoogleFileEdit } from './google'; +import { handleOpenAIEdit, handleOpenAIFileEdit } from './openai'; + +export const maxDuration = 120; const providers = { openai: handleOpenAIEdit, gemini: handleGoogleEdit, }; -export const config = { - api: { - bodyParser: { - sizeLimit: '4mb', - }, - }, -}; - export async function POST(req: Request) { try { + const contentType = req.headers.get('content-type') || ''; + + // Multipart form data path: uses File objects directly (no base64 bloat) + if (contentType.includes('multipart/form-data')) { + const formData = await req.formData(); + const validation = validateMultipartEditImageRequest(formData); + + if (!validation.isValid) { + return Response.json( + { error: validation.error!.message }, + { status: validation.error!.status } + ); + } + + const { prompt, provider, imageFiles } = ( + validation as { isValid: true; data: { prompt: string; provider: 'openai' | 'gemini'; imageFiles: File[] } } + ).data; + + if (provider === 'openai') { + return handleOpenAIFileEdit(prompt, imageFiles); + } + + // Google/Gemini: convert File objects to bytes + const files = await Promise.all( + imageFiles.map(async (file, i) => { + const arrayBuffer = await file.arrayBuffer(); + return { + bytes: new Uint8Array(arrayBuffer), + mediaType: file.type || 'image/png', + filename: file.name || `image-${i}.png`, + }; + }) + ); + return handleGoogleFileEdit(prompt, files); + } + + // JSON path: legacy data URL approach (fallback) const body = await req.json(); const validation = validateEditImageRequest(body); diff --git a/templates/next-image/src/app/api/edit-image/validation.ts b/templates/next-image/src/app/api/edit-image/validation.ts index a211343ad..47d3d3c6e 100644 --- a/templates/next-image/src/app/api/edit-image/validation.ts +++ b/templates/next-image/src/app/api/edit-image/validation.ts @@ -2,12 +2,35 @@ import { ModelOption } from '@/lib/types'; export type { EditImageRequest } from '@/lib/types'; -export interface ValidationResult { - isValid: boolean; - error?: { message: string; status: number }; +export interface ValidationError { + message: string; + status: number; } -export function validateEditImageRequest(body: unknown): ValidationResult { +export type ValidationFailure = { + isValid: false; + error: ValidationError; +}; + +export type ValidationSuccess = TData extends undefined + ? { isValid: true } + : { isValid: true; data: TData }; + +export type ValidationResult = + | ValidationFailure + | ValidationSuccess; + +export interface ParsedMultipartEditImageRequest { + prompt: string; + provider: ModelOption; + imageFiles: File[]; +} + +const VALID_PROVIDERS: ModelOption[] = ['openai', 'gemini']; + +export function validateEditImageRequest( + body: unknown +): ValidationResult { if (!body || typeof body !== 'object') { return { isValid: false, @@ -17,12 +40,11 @@ export function validateEditImageRequest(body: unknown): ValidationResult { const { prompt, imageUrls, provider } = body as Record; - const validProviders: ModelOption[] = ['openai', 'gemini']; - if (!provider || !validProviders.includes(provider as ModelOption)) { + if (!provider || !VALID_PROVIDERS.includes(provider as ModelOption)) { return { isValid: false, error: { - message: `Provider must be: ${validProviders.join(', ')}`, + message: `Provider must be: ${VALID_PROVIDERS.join(', ')}`, status: 400, }, }; @@ -58,3 +80,53 @@ export function validateEditImageRequest(body: unknown): ValidationResult { return { isValid: true }; } + +export function validateMultipartEditImageRequest( + formData: FormData +): ValidationResult { + const prompt = formData.get('prompt'); + const provider = formData.get('provider'); + const imageFiles = formData.getAll('imageFiles').filter( + (entry): entry is File => entry instanceof File + ); + + if (!provider || !VALID_PROVIDERS.includes(provider as ModelOption)) { + return { + isValid: false, + error: { + message: `Provider must be: ${VALID_PROVIDERS.join(', ')}`, + status: 400, + }, + }; + } + + if (!prompt || typeof prompt !== 'string') { + return { + isValid: false, + error: { message: 'Prompt is required', status: 400 }, + }; + } + + if (prompt.length < 3 || prompt.length > 1000) { + return { + isValid: false, + error: { message: 'Prompt must be 3-1000 characters', status: 400 }, + }; + } + + if (imageFiles.length === 0) { + return { + isValid: false, + error: { message: 'At least one image file is required', status: 400 }, + }; + } + + return { + isValid: true, + data: { + prompt: prompt as string, + provider: provider as ModelOption, + imageFiles, + }, + } as ValidationSuccess; +} 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..19a470f11 100644 --- a/templates/next-image/src/app/api/generate-image/route.ts +++ b/templates/next-image/src/app/api/generate-image/route.ts @@ -19,13 +19,12 @@ const providers = { gemini: handleGoogleGenerate, }; -export const config = { - api: { - bodyParser: { - sizeLimit: '4mb', - }, - }, -}; +// App Router route segment config +// `maxDuration` sets the maximum execution time for this route (seconds). +// Note: the body-size limit for App Router route handlers is configured in +// next.config.ts via `experimental.serverActions.bodySizeLimit`, NOT via the +// Pages-Router-only `export const config` pattern which is silently ignored here. +export const maxDuration = 60; export async function POST(req: Request) { try { diff --git a/templates/next-image/src/components/image-generator.tsx b/templates/next-image/src/components/image-generator.tsx index e585d3cc5..3f1d0bef8 100644 --- a/templates/next-image/src/components/image-generator.tsx +++ b/templates/next-image/src/components/image-generator.tsx @@ -27,7 +27,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { fileToDataUrl } from '@/lib/image-utils'; import type { - EditImageRequest, GeneratedImage, GenerateImageRequest, ImageResponse, @@ -77,11 +76,26 @@ async function generateImage( return response.json(); } -async function editImage(request: EditImageRequest): Promise { +/** + * Sends image edit request using FormData with File objects. + * This avoids base64 data URL encoding, preventing HTTP 413 errors. + */ +async function editImage(params: { + prompt: string; + provider: ModelOption; + imageFiles: File[]; +}): Promise { + const formData = new FormData(); + formData.append('prompt', params.prompt); + formData.append('provider', params.provider); + for (const file of params.imageFiles) { + formData.append('imageFiles', file); + } + const response = await fetch('/api/edit-image', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request), + // No Content-Type header - browser sets it automatically with boundary for multipart + body: formData, }); if (!response.ok) { @@ -144,7 +158,7 @@ export default function ImageGenerator() { /** * Handles form submission for both image generation and editing * - Text-only: generates new image using selected model - * - Text + attachments: edits uploaded images using Gemini + * - Text + attachments: edits uploaded images using selected model */ const handleSubmit = useCallback( async (message: PromptInputMessage) => { @@ -206,38 +220,36 @@ export default function ImageGenerator() { let imageUrl: ImageResponse['imageUrl']; if (isEdit) { - const imageFiles = + const imageAttachments = message.files?.filter( file => file.mediaType?.startsWith('image/') || file.type === 'file' ) || []; - if (imageFiles.length === 0) { + if (imageAttachments.length === 0) { throw new Error('No image files found in attachments'); } - try { - const imageUrls = await Promise.all( - imageFiles.map(async imageFile => { - // Convert blob URL to data URL for API - const response = await fetch(imageFile.url); - const blob = await response.blob(); - return await fileToDataUrl( - new File([blob], 'image', { type: imageFile.mediaType }) - ); - }) - ); - - const result = await editImage({ - prompt, - imageUrls, - provider: model, - }); - imageUrl = result.imageUrl; - } catch (error) { - console.error('Error processing image files:', error); - throw error; - } + // Convert blob URLs directly to File objects for FormData upload. + // This avoids base64 encoding entirely, preventing HTTP 413 errors. + const imageFiles = await Promise.all( + imageAttachments.map(async imageFile => { + const response = await fetch(imageFile.url); + const blob = await response.blob(); + return new File( + [blob], + imageFile.filename || 'image.png', + { type: imageFile.mediaType || 'image/png' } + ); + }) + ); + + const result = await editImage({ + prompt, + provider: model, + imageFiles, + }); + imageUrl = result.imageUrl; } else { const result = await generateImage({ prompt, model }); imageUrl = result.imageUrl; diff --git a/templates/next-image/src/lib/image-utils.ts b/templates/next-image/src/lib/image-utils.ts index cefb219f3..d2224c587 100644 --- a/templates/next-image/src/lib/image-utils.ts +++ b/templates/next-image/src/lib/image-utils.ts @@ -1,11 +1,36 @@ /** - * Minimal Image Utilities + * Image Utilities * - * Simple, clean API with just data URLs. No complex conversions. + * Data URL helpers and client-side image compression to help avoid + * "Request Entity Too Large" (HTTP 413) errors when sending base64 payloads + * to the API routes. + * + * Two-layer defence against 413: + * 1. Server: `next.config.ts` raises the body-size limit to 10 MB via + * `experimental.serverActions.bodySizeLimit`. + * 2. Client: `compressImageDataUrl` (below) shrinks images before upload, + * giving a better UX and staying well under any server limit. */ +// --------------------------------------------------------------------------- +// Compression constants +// --------------------------------------------------------------------------- + +/** Max dimension (width or height) before downscaling. */ +const MAX_IMAGE_DIMENSION = 1024; + +/** JPEG quality used when re-encoding (0–1). */ +const COMPRESS_QUALITY = 0.85; + +/** Hard cap: if a data URL is already smaller than this, skip re-encoding. */ +const COMPRESS_SKIP_THRESHOLD_BYTES = 512 * 1024; // 512 KB + +// --------------------------------------------------------------------------- +// Core conversions +// --------------------------------------------------------------------------- + /** - * Converts a File to a data URL + * Converts a File to a data URL. */ export async function fileToDataUrl(file: File): Promise { return new Promise((resolve, reject) => { @@ -17,7 +42,7 @@ export async function fileToDataUrl(file: File): Promise { } /** - * Converts a data URL to a File object + * Converts a data URL to a File object. */ export function dataUrlToFile(dataUrl: string, filename: string): File { const [header, base64] = dataUrl.split(','); @@ -32,8 +57,83 @@ export function dataUrlToFile(dataUrl: string, filename: string): File { return new File([array], filename, { type: mime }); } +// --------------------------------------------------------------------------- +// Client-side compression +// --------------------------------------------------------------------------- + +/** + * Compresses a data URL so it is suitable for sending as a JSON body. + * + * Strategy: + * - Skip images that are already small (< COMPRESS_SKIP_THRESHOLD_BYTES). + * - Downscale so that neither dimension exceeds MAX_IMAGE_DIMENSION px. + * - Re-encode as JPEG at COMPRESS_QUALITY. + * + * This is a client-side safety net. The definitive fix for HTTP 413 is the + * raised body-size limit in `next.config.ts`. + * + * @param dataUrl Source image as a data URL (any format supported by ). + * @returns Compressed data URL (image/jpeg). + */ +export async function compressImageDataUrl(dataUrl: string): Promise { + // Fast path: already tiny enough, no need to spin up a canvas. + if (dataUrl.length < COMPRESS_SKIP_THRESHOLD_BYTES) { + return dataUrl; + } + + return new Promise((resolve, reject) => { + const img = new Image(); + + img.onload = () => { + let { width, height } = img; + + // Downscale proportionally if either dimension exceeds the limit. + if (width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION) { + if (width >= height) { + height = Math.round((height * MAX_IMAGE_DIMENSION) / width); + width = MAX_IMAGE_DIMENSION; + } else { + width = Math.round((width * MAX_IMAGE_DIMENSION) / height); + height = MAX_IMAGE_DIMENSION; + } + } + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + // Canvas unavailable — return original rather than throwing. + resolve(dataUrl); + return; + } + + ctx.drawImage(img, 0, 0, width, height); + resolve(canvas.toDataURL('image/jpeg', COMPRESS_QUALITY)); + }; + + img.onerror = reject; + img.src = dataUrl; + }); +} + +/** + * Converts a File to a compressed data URL ready for API submission. + * + * Combines `fileToDataUrl` + `compressImageDataUrl` into a single call. + */ +export async function fileToCompressedDataUrl(file: File): Promise { + const raw = await fileToDataUrl(file); + return compressImageDataUrl(raw); +} + +// --------------------------------------------------------------------------- +// UI helpers +// --------------------------------------------------------------------------- + /** - * Downloads an image from a data URL + * Downloads an image from a data URL. */ export function downloadDataUrl(dataUrl: string, filename: string): void { const link = document.createElement('a'); @@ -45,7 +145,7 @@ export function downloadDataUrl(dataUrl: string, filename: string): void { } /** - * Copies an image to the clipboard from a data URL + * Copies an image to the clipboard from a data URL. */ export async function copyDataUrlToClipboard(dataUrl: string): Promise { const [header, base64] = dataUrl.split(','); @@ -62,14 +162,14 @@ export async function copyDataUrlToClipboard(dataUrl: string): Promise { } /** - * Generates a filename for an image + * Generates a filename for an image. */ export function generateFilename(imageId: string): string { return `generated-image-${imageId}.png`; } /** - * Extracts media type from a data URL + * Extracts media type from a data URL. */ export function getMediaTypeFromDataUrl(dataUrl: string): string { if (!dataUrl.startsWith('data:')) return 'image/jpeg';