Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions templates/next-image/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
37 changes: 31 additions & 6 deletions templates/next-image/src/app/api/edit-image/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,30 @@ 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<Response> {
try {
const content = [
{
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,
})),
];

Expand Down Expand Up @@ -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<Response> {
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);
}
24 changes: 16 additions & 8 deletions templates/next-image/src/app/api/edit-image/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
const token = await getEchoToken();

Expand All @@ -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,
Expand Down Expand Up @@ -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<Response> {
const imageFiles = imageUrls.map(url => dataUrlToFile(url, 'image.png'));
return handleOpenAIFileEdit(prompt, imageFiles);
}
66 changes: 52 additions & 14 deletions templates/next-image/src/app/api/edit-image/route.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
86 changes: 79 additions & 7 deletions templates/next-image/src/app/api/edit-image/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = undefined> = TData extends undefined
? { isValid: true }
: { isValid: true; data: TData };

export type ValidationResult<TData = undefined> =
| ValidationFailure
| ValidationSuccess<TData>;

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,
Expand All @@ -17,12 +40,11 @@ export function validateEditImageRequest(body: unknown): ValidationResult {

const { prompt, imageUrls, provider } = body as Record<string, unknown>;

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,
},
};
Expand Down Expand Up @@ -58,3 +80,53 @@ export function validateEditImageRequest(body: unknown): ValidationResult {

return { isValid: true };
}

export function validateMultipartEditImageRequest(
formData: FormData
): ValidationResult<ParsedMultipartEditImageRequest> {
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<ParsedMultipartEditImageRequest>;
}
13 changes: 6 additions & 7 deletions templates/next-image/src/app/api/generate-image/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading