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..26d2e5b9c 100644 --- a/templates/next-image/src/app/api/edit-image/route.ts +++ b/templates/next-image/src/app/api/edit-image/route.ts @@ -17,13 +17,8 @@ const providers = { gemini: handleGoogleEdit, }; -export const config = { - api: { - bodyParser: { - sizeLimit: '4mb', - }, - }, -}; +// App Router route segment config +export const maxDuration = 60; export async function POST(req: Request) { try { 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..10a17af47 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,8 @@ const providers = { gemini: handleGoogleGenerate, }; -export const config = { - api: { - bodyParser: { - sizeLimit: '4mb', - }, - }, -}; +// App Router route segment config +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..bfc42f958 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, compressImageDataUrl } from '@/lib/image-utils'; import type { EditImageRequest, GeneratedImage, @@ -92,6 +92,20 @@ async function editImage(request: EditImageRequest): Promise { return response.json(); } +/** + * Helper: convert a PromptInput file attachment to a compressed data URL + */ +async function attachmentToCompressedDataUrl( + fileRef: { url: string; mediaType?: string; filename?: string } +): Promise { + const response = await fetch(fileRef.url); + const blob = await response.blob(); + const raw = await fileToDataUrl( + new File([blob], fileRef.filename || 'image', { type: fileRef.mediaType }) + ); + return compressImageDataUrl(raw); +} + /** * Main ImageGenerator component * @@ -170,13 +184,7 @@ export default function ImageGenerator() { .filter(f => f.mediaType?.startsWith('image/')) .map(async f => { try { - const response = await fetch(f.url); - const blob = await response.blob(); - return await fileToDataUrl( - new File([blob], f.filename || 'image', { - type: f.mediaType, - }) - ); + return await attachmentToCompressedDataUrl(f); } catch (error) { console.error( 'Failed to convert attachment to data URL:', @@ -217,15 +225,9 @@ export default function ImageGenerator() { } try { + // Compress images before sending to avoid 413 errors 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 }) - ); - }) + imageFiles.map(f => attachmentToCompressedDataUrl(f)) ); const result = await editImage({ diff --git a/templates/next-image/src/lib/image-utils.ts b/templates/next-image/src/lib/image-utils.ts index cefb219f3..46352b192 100644 --- a/templates/next-image/src/lib/image-utils.ts +++ b/templates/next-image/src/lib/image-utils.ts @@ -1,9 +1,19 @@ /** - * Minimal Image Utilities + * Image Utilities * - * Simple, clean API with just data URLs. No complex conversions. + * Data URL helpers and client-side image compression to avoid + * "Request Entity Too Large" (HTTP 413) when sending base64 payloads. */ +/** Max dimension (width or height) for images sent to the API */ +const MAX_IMAGE_DIMENSION = 1024; + +/** JPEG quality used when compressing (0-1) */ +const COMPRESS_QUALITY = 0.85; + +/** Target max size in bytes for a single compressed image (~3MB) */ +const MAX_IMAGE_BYTES = 3 * 1024 * 1024; + /** * Converts a File to a data URL */ @@ -16,6 +26,65 @@ export async function fileToDataUrl(file: File): Promise { }); } +/** + * Compresses an image data URL by resizing and converting to JPEG. + * Falls back to the original if compression fails (e.g. in non-browser envs). + */ +export async function compressImageDataUrl( + dataUrl: string, + maxDimension: number = MAX_IMAGE_DIMENSION, + quality: number = COMPRESS_QUALITY +): Promise { + if (typeof document === 'undefined') return dataUrl; + + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + let { width, height } = img; + + // Scale down if either dimension exceeds the limit + 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); + + // Try JPEG first for smaller size, iteratively reduce quality if needed + let compressed = canvas.toDataURL('image/jpeg', quality); + let currentQuality = quality; + + while (estimateBase64Bytes(compressed) > MAX_IMAGE_BYTES && currentQuality > 0.3) { + currentQuality -= 0.1; + compressed = canvas.toDataURL('image/jpeg', currentQuality); + } + + resolve(compressed); + }; + img.onerror = () => resolve(dataUrl); + img.src = dataUrl; + }); +} + +/** + * Rough estimate of the decoded byte size of a base64 data URL + */ +function estimateBase64Bytes(dataUrl: string): number { + const base64 = dataUrl.split(',')[1] || ''; + return Math.floor(base64.length * 0.75); +} + /** * Converts a data URL to a File object */