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..89f7686a9 100644 --- a/templates/next-image/src/app/api/edit-image/google.ts +++ b/templates/next-image/src/app/api/edit-image/google.ts @@ -13,6 +13,21 @@ import { ERROR_MESSAGES } from '@/lib/constants'; export async function handleGoogleEdit( prompt: string, imageUrls: string[] +): Promise { + const files = imageUrls.map((imageUrl, index) => ({ + bytes: Uint8Array.from(atob(imageUrl.split(',')[1] ?? ''), char => + char.charCodeAt(0) + ), + mediaType: getMediaTypeFromDataUrl(imageUrl), + filename: `image-${index}.png`, + })); + + return handleGoogleFileEdit(prompt, files); +} + +export async function handleGoogleFileEdit( + prompt: string, + files: Array<{ bytes: Uint8Array; mediaType: string; filename: string }> ): Promise { try { const content = [ @@ -20,10 +35,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, })), ]; @@ -37,7 +52,7 @@ export async function handleGoogleEdit( ], }); - const imageFile = result.files?.find(file => + const imageFile = result.files?.find((file) => file.mediaType?.startsWith('image/') ); 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..e46ed8c40 100644 --- a/templates/next-image/src/app/api/edit-image/openai.ts +++ b/templates/next-image/src/app/api/edit-image/openai.ts @@ -13,6 +13,14 @@ import { ERROR_MESSAGES } from '@/lib/constants'; export async function handleOpenAIEdit( prompt: string, imageUrls: string[] +): Promise { + const imageFiles = imageUrls.map((url) => dataUrlToFile(url, 'image.png')); + return handleOpenAIFileEdit(prompt, imageFiles); +} + +export async function handleOpenAIFileEdit( + prompt: string, + imageFiles: File[] ): Promise { const token = await getEchoToken(); @@ -32,8 +40,6 @@ export async function handleOpenAIEdit( }); try { - const imageFiles = imageUrls.map(url => dataUrlToFile(url, 'image.png')); - const result = await openaiClient.images.edit({ image: imageFiles, prompt, 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..3a5c6485c 100644 --- a/templates/next-image/src/app/api/edit-image/route.ts +++ b/templates/next-image/src/app/api/edit-image/route.ts @@ -8,25 +8,45 @@ * - Returns edited images in appropriate format */ -import { EditImageRequest, validateEditImageRequest } from './validation'; -import { handleGoogleEdit } from './google'; -import { handleOpenAIEdit } from './openai'; - -const providers = { - openai: handleOpenAIEdit, - gemini: handleGoogleEdit, -}; - -export const config = { - api: { - bodyParser: { - sizeLimit: '4mb', - }, - }, -}; +import { + EditImageRequest, + validateEditImageRequest, + validateMultipartEditImageRequest, +} from './validation'; +import { handleGoogleEdit, handleGoogleFileEdit } from './google'; +import { handleOpenAIEdit, handleOpenAIFileEdit } from './openai'; export async function POST(req: Request) { try { + const contentType = req.headers.get('content-type') ?? ''; + + 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, imageFiles, provider } = validation.data; + + if (provider === 'openai') { + return handleOpenAIFileEdit(prompt, imageFiles); + } + + const googleFiles = await Promise.all( + imageFiles.map(async (file: File, index: number) => ({ + bytes: new Uint8Array(await file.arrayBuffer()), + mediaType: file.type || 'image/png', + filename: file.name || `image-${index}.png`, + })) + ); + + return handleGoogleFileEdit(prompt, googleFiles); + } + const body = await req.json(); const validation = validateEditImageRequest(body); @@ -38,16 +58,9 @@ export async function POST(req: Request) { } const { prompt, imageUrls, provider } = body as EditImageRequest; - const handler = providers[provider]; - - if (!handler) { - return Response.json( - { error: `Unsupported provider: ${provider}` }, - { status: 400 } - ); - } - - return handler(prompt, imageUrls); + return provider === 'openai' + ? handleOpenAIEdit(prompt, imageUrls) + : handleGoogleEdit(prompt, imageUrls); } catch (error) { console.error('Image editing error:', error); 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..aed34a9ab 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,34 @@ 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 interface ValidationFailure { + isValid: false; + error: ValidationError; +} + +export interface ValidationSuccess { + isValid: true; + data: TData; +} + +export type ValidationResult = + | ValidationFailure + | ValidationSuccess; + +export interface ParsedMultipartEditImageRequest { + prompt: string; + provider: ModelOption; + imageFiles: File[]; +} + +export function validateEditImageRequest( + body: unknown +): ValidationResult { if (!body || typeof body !== 'object') { return { isValid: false, @@ -56,5 +78,63 @@ export function validateEditImageRequest(body: unknown): ValidationResult { }; } - return { isValid: true }; + return { isValid: true, data: undefined }; +} + +export function validateMultipartEditImageRequest( + formData: FormData +): ValidationResult { + const prompt = formData.get('prompt'); + const provider = formData.get('provider'); + const imageFiles = formData + .getAll('images') + .filter((entry): entry is File => entry instanceof File); + + const validProviders: ModelOption[] = ['openai', 'gemini']; + if (!provider || typeof provider !== 'string' || !validProviders.includes(provider as ModelOption)) { + return { + isValid: false, + error: { + message: `Provider must be: ${validProviders.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 is required', status: 400 }, + }; + } + + if (imageFiles.some((file) => !file.type.startsWith('image/'))) { + return { + isValid: false, + error: { message: 'All uploaded files must be images', status: 400 }, + }; + } + + return { + isValid: true, + data: { + prompt, + provider: provider as ModelOption, + imageFiles, + }, + }; } diff --git a/templates/next-image/src/components/image-generator.tsx b/templates/next-image/src/components/image-generator.tsx index e585d3cc5..5b1415b8d 100644 --- a/templates/next-image/src/components/image-generator.tsx +++ b/templates/next-image/src/components/image-generator.tsx @@ -27,12 +27,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { fileToDataUrl } from '@/lib/image-utils'; import type { - EditImageRequest, GeneratedImage, GenerateImageRequest, ImageResponse, - ModelConfig, ModelOption, + ModelConfig, } from '@/lib/types'; import { ImageHistory } from './image-history'; @@ -77,11 +76,19 @@ async function generateImage( return response.json(); } -async function editImage(request: EditImageRequest): Promise { +async function editImage(request: { + prompt: string; + provider: ModelOption; + imageFiles: File[]; +}): Promise { + const body = new FormData(); + body.set('prompt', request.prompt); + body.set('provider', request.provider); + request.imageFiles.forEach(image => body.append('images', image)); + const response = await fetch('/api/edit-image', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(request), + body, }); if (!response.ok) { @@ -217,20 +224,19 @@ export default function ImageGenerator() { } try { - const imageUrls = await Promise.all( + const imageUploads = 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 }) - ); + return new File([blob], imageFile.filename || 'image', { + type: imageFile.mediaType || blob.type || 'image/png', + }); }) ); const result = await editImage({ prompt, - imageUrls, + imageFiles: imageUploads, provider: model, }); imageUrl = result.imageUrl;