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: 19 additions & 4 deletions templates/next-image/src/app/api/edit-image/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,32 @@ import { ERROR_MESSAGES } from '@/lib/constants';
export async function handleGoogleEdit(
prompt: string,
imageUrls: string[]
): Promise<Response> {
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<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 All @@ -37,7 +52,7 @@ export async function handleGoogleEdit(
],
});

const imageFile = result.files?.find(file =>
const imageFile = result.files?.find((file) =>
file.mediaType?.startsWith('image/')
);

Expand Down
10 changes: 8 additions & 2 deletions templates/next-image/src/app/api/edit-image/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ import { ERROR_MESSAGES } from '@/lib/constants';
export async function handleOpenAIEdit(
prompt: string,
imageUrls: string[]
): Promise<Response> {
const imageFiles = imageUrls.map((url) => dataUrlToFile(url, 'image.png'));
return handleOpenAIFileEdit(prompt, imageFiles);
}

export async function handleOpenAIFileEdit(
prompt: string,
imageFiles: File[]
): Promise<Response> {
const token = await getEchoToken();

Expand All @@ -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,
Expand Down
65 changes: 39 additions & 26 deletions templates/next-image/src/app/api/edit-image/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);

Expand Down
90 changes: 85 additions & 5 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,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<TData = undefined> {
isValid: true;
data: TData;
}

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

export interface ParsedMultipartEditImageRequest {
prompt: string;
provider: ModelOption;
imageFiles: File[];
}

export function validateEditImageRequest(
body: unknown
): ValidationResult<undefined> {
if (!body || typeof body !== 'object') {
return {
isValid: false,
Expand Down Expand Up @@ -56,5 +78,63 @@ export function validateEditImageRequest(body: unknown): ValidationResult {
};
}

return { isValid: true };
return { isValid: true, data: undefined };
}

export function validateMultipartEditImageRequest(
formData: FormData
): ValidationResult<ParsedMultipartEditImageRequest> {
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,
},
};
}
28 changes: 17 additions & 11 deletions templates/next-image/src/components/image-generator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -77,11 +76,19 @@ async function generateImage(
return response.json();
}

async function editImage(request: EditImageRequest): Promise<ImageResponse> {
async function editImage(request: {
prompt: string;
provider: ModelOption;
imageFiles: File[];
}): Promise<ImageResponse> {
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) {
Expand Down Expand Up @@ -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;
Expand Down