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/route.ts b/templates/next-image/src/app/api/edit-image/route.ts index 11c52b38e..ec92e22be 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,12 @@ const providers = { gemini: handleGoogleEdit, }; -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/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..e4c6b746a 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, fileToCompressedDataUrl } from '@/lib/image-utils'; import type { EditImageRequest, GeneratedImage, @@ -219,10 +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 a compressed data URL for API submission. + // Compression reduces payload size and prevents HTTP 413 errors + // when uploading large images. The server-side limit is also + // raised in next.config.ts as a belt-and-suspenders fix. const response = await fetch(imageFile.url); const blob = await response.blob(); - return await fileToDataUrl( + return await fileToCompressedDataUrl( new File([blob], 'image', { type: imageFile.mediaType }) ); }) 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';