Spaces:
Running
Running
Minor changes
Browse files- .claude/settings.local.json +0 -24
- app/api/hf-process/route.ts +3 -10
- app/api/process/route.ts +139 -94
- app/nodes.tsx +81 -2
- app/page.tsx +9 -6
.claude/settings.local.json
DELETED
|
@@ -1,24 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"permissions": {
|
| 3 |
-
"allow": [
|
| 4 |
-
"Read(//e/**)",
|
| 5 |
-
"Bash(npm run dev:*)",
|
| 6 |
-
"mcp__puppeteer__puppeteer_navigate",
|
| 7 |
-
"mcp__puppeteer__puppeteer_evaluate",
|
| 8 |
-
"mcp__puppeteer__puppeteer_screenshot",
|
| 9 |
-
"Bash(npm install:*)",
|
| 10 |
-
"Bash(npm run build:*)",
|
| 11 |
-
"Bash(git add:*)",
|
| 12 |
-
"Bash(git commit:*)",
|
| 13 |
-
"Bash(git push:*)",
|
| 14 |
-
"WebSearch",
|
| 15 |
-
"Read(//Users/reubenfernandes/Desktop/**)",
|
| 16 |
-
"mcp__puppeteer__puppeteer_click",
|
| 17 |
-
"mcp__browser-tools__getConsoleErrors",
|
| 18 |
-
"mcp__sequential-thinking__sequentialthinking",
|
| 19 |
-
"WebFetch(domain:developers.googleblog.com)"
|
| 20 |
-
],
|
| 21 |
-
"deny": [],
|
| 22 |
-
"ask": []
|
| 23 |
-
}
|
| 24 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/api/hf-process/route.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
| 11 |
*
|
| 12 |
* IMPORTANT LIMITATIONS:
|
| 13 |
* - These models only accept SINGLE images for editing
|
| 14 |
-
* - MERGE operations require Nano Banana
|
| 15 |
* - Text-to-image (FLUX.1-dev) doesn't require input images
|
| 16 |
*/
|
| 17 |
|
|
@@ -43,13 +43,6 @@ const HF_MODELS = {
|
|
| 43 |
description: "Powerful image editing and manipulation",
|
| 44 |
supportsNodes: ["BACKGROUND", "CLOTHES", "STYLE", "EDIT", "CAMERA", "AGE", "FACE", "LIGHTNING", "POSES"],
|
| 45 |
},
|
| 46 |
-
"FLUX.1-dev": {
|
| 47 |
-
id: "black-forest-labs/FLUX.1-dev",
|
| 48 |
-
name: "FLUX.1 Dev",
|
| 49 |
-
type: "text-to-image",
|
| 50 |
-
description: "High-quality text-to-image generation",
|
| 51 |
-
supportsNodes: ["CHARACTER"], // Only for generating new images
|
| 52 |
-
},
|
| 53 |
};
|
| 54 |
|
| 55 |
/**
|
|
@@ -133,7 +126,7 @@ export async function POST(req: NextRequest) {
|
|
| 133 |
if (body.type === "MERGE") {
|
| 134 |
return NextResponse.json(
|
| 135 |
{
|
| 136 |
-
error: "MERGE operations require Nano Banana
|
| 137 |
requiresNanoBananaPro: true
|
| 138 |
},
|
| 139 |
{ status: 400 }
|
|
@@ -369,6 +362,6 @@ export async function POST(req: NextRequest) {
|
|
| 369 |
export async function GET() {
|
| 370 |
return NextResponse.json({
|
| 371 |
models: HF_MODELS,
|
| 372 |
-
note: "MERGE operations require Nano Banana
|
| 373 |
});
|
| 374 |
}
|
|
|
|
| 11 |
*
|
| 12 |
* IMPORTANT LIMITATIONS:
|
| 13 |
* - These models only accept SINGLE images for editing
|
| 14 |
+
* - MERGE operations require Nano Banana (Gemini API) which accepts multiple images
|
| 15 |
* - Text-to-image (FLUX.1-dev) doesn't require input images
|
| 16 |
*/
|
| 17 |
|
|
|
|
| 43 |
description: "Powerful image editing and manipulation",
|
| 44 |
supportsNodes: ["BACKGROUND", "CLOTHES", "STYLE", "EDIT", "CAMERA", "AGE", "FACE", "LIGHTNING", "POSES"],
|
| 45 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
};
|
| 47 |
|
| 48 |
/**
|
|
|
|
| 126 |
if (body.type === "MERGE") {
|
| 127 |
return NextResponse.json(
|
| 128 |
{
|
| 129 |
+
error: "MERGE operations require Nano Banana (Gemini API). HuggingFace models only accept single images. Please switch to 'Nano Banana' mode and enter your Google Gemini API key to use MERGE functionality.",
|
| 130 |
requiresNanoBananaPro: true
|
| 131 |
},
|
| 132 |
{ status: 400 }
|
|
|
|
| 362 |
export async function GET() {
|
| 363 |
return NextResponse.json({
|
| 364 |
models: HF_MODELS,
|
| 365 |
+
note: "MERGE operations require Nano Banana (Gemini API) as it needs multi-image input which HuggingFace models don't support."
|
| 366 |
});
|
| 367 |
}
|
app/api/process/route.ts
CHANGED
|
@@ -41,7 +41,7 @@ export const maxDuration = 60;
|
|
| 41 |
function parseDataUrl(dataUrl: string): { mimeType: string; data: string } | null {
|
| 42 |
const match = dataUrl.match(/^data:(.*?);base64,(.*)$/); // Regex to capture MIME type and data
|
| 43 |
if (!match) return null; // Invalid format
|
| 44 |
-
return {
|
| 45 |
mimeType: match[1] || "image/png", // Default to PNG if no MIME type
|
| 46 |
data: match[2] // Base64 image data
|
| 47 |
};
|
|
@@ -59,7 +59,7 @@ function parseDataUrl(dataUrl: string): { mimeType: string; data: string } | nul
|
|
| 59 |
export async function POST(req: NextRequest) {
|
| 60 |
try {
|
| 61 |
// Log incoming request size for debugging and monitoring
|
| 62 |
-
|
| 63 |
// Parse and validate the JSON request body
|
| 64 |
let body: any;
|
| 65 |
try {
|
|
@@ -100,7 +100,7 @@ export async function POST(req: NextRequest) {
|
|
| 100 |
|
| 101 |
// Initialize Google AI client with the validated API key
|
| 102 |
const ai = new GoogleGenAI({ apiKey });
|
| 103 |
-
|
| 104 |
/**
|
| 105 |
* Universal image data converter
|
| 106 |
*
|
|
@@ -112,13 +112,13 @@ export async function POST(req: NextRequest) {
|
|
| 112 |
*/
|
| 113 |
const toInlineDataFromAny = async (url: string): Promise<{ mimeType: string; data: string } | null> => {
|
| 114 |
if (!url) return null; // Handle empty/null input
|
| 115 |
-
|
| 116 |
try {
|
| 117 |
// Case 1: Data URL (data:image/png;base64,...)
|
| 118 |
if (url.startsWith('data:')) {
|
| 119 |
return parseDataUrl(url); // Use existing parser for data URLs
|
| 120 |
}
|
| 121 |
-
|
| 122 |
// Case 2: HTTP/HTTPS URL (external image)
|
| 123 |
if (url.startsWith('http')) {
|
| 124 |
const res = await fetch(url); // Fetch external image
|
|
@@ -127,7 +127,7 @@ export async function POST(req: NextRequest) {
|
|
| 127 |
const mimeType = res.headers.get('content-type') || 'image/jpeg'; // Get MIME type from headers
|
| 128 |
return { mimeType, data: base64 };
|
| 129 |
}
|
| 130 |
-
|
| 131 |
// Case 3: Relative path (local image on server)
|
| 132 |
if (url.startsWith('/')) {
|
| 133 |
const host = req.headers.get('host') ?? 'localhost:3000'; // Get current host
|
|
@@ -139,7 +139,7 @@ export async function POST(req: NextRequest) {
|
|
| 139 |
const mimeType = res.headers.get('content-type') || 'image/png'; // Get MIME type
|
| 140 |
return { mimeType, data: base64 };
|
| 141 |
}
|
| 142 |
-
|
| 143 |
return null; // Unsupported URL format
|
| 144 |
} catch {
|
| 145 |
return null; // Handle any conversion errors gracefully
|
|
@@ -149,7 +149,7 @@ export async function POST(req: NextRequest) {
|
|
| 149 |
/* ========================================
|
| 150 |
MERGE OPERATION - MULTI-IMAGE PROCESSING
|
| 151 |
======================================== */
|
| 152 |
-
|
| 153 |
/**
|
| 154 |
* Handle MERGE node type separately from single-image operations
|
| 155 |
*
|
|
@@ -161,7 +161,7 @@ export async function POST(req: NextRequest) {
|
|
| 161 |
*/
|
| 162 |
if (body.type === "MERGE") {
|
| 163 |
const imgs = body.images?.filter(Boolean) ?? []; // Remove any null/undefined images
|
| 164 |
-
|
| 165 |
// Validate minimum input requirement for merge operations
|
| 166 |
if (imgs.length < 2) {
|
| 167 |
return NextResponse.json(
|
|
@@ -172,7 +172,7 @@ export async function POST(req: NextRequest) {
|
|
| 172 |
|
| 173 |
// Determine the AI prompt for merge operation
|
| 174 |
let mergePrompt = body.prompt; // Use custom prompt if provided
|
| 175 |
-
|
| 176 |
if (!mergePrompt) {
|
| 177 |
mergePrompt = `MERGE TASK: Create a natural, cohesive group photo combining ALL subjects from ${imgs.length} provided images.
|
| 178 |
|
|
@@ -208,7 +208,7 @@ The result should look like all subjects were photographed together in the same
|
|
| 208 |
const mergeParts: any[] = [{ text: mergePrompt }];
|
| 209 |
for (let i = 0; i < imgs.length; i++) {
|
| 210 |
const url = imgs[i];
|
| 211 |
-
|
| 212 |
try {
|
| 213 |
const parsed = await toInlineDataFromAny(url);
|
| 214 |
if (!parsed) {
|
|
@@ -220,10 +220,10 @@ The result should look like all subjects were photographed together in the same
|
|
| 220 |
console.error(`[MERGE] Error processing image ${i + 1}:`, error);
|
| 221 |
}
|
| 222 |
}
|
| 223 |
-
|
| 224 |
|
| 225 |
const response = await ai.models.generateContent({
|
| 226 |
-
model: "gemini-2.5-flash-image
|
| 227 |
contents: mergeParts,
|
| 228 |
});
|
| 229 |
|
|
@@ -253,7 +253,7 @@ The result should look like all subjects were photographed together in the same
|
|
| 253 |
if (body.image) {
|
| 254 |
parsed = await toInlineDataFromAny(body.image);
|
| 255 |
}
|
| 256 |
-
|
| 257 |
if (!parsed) {
|
| 258 |
return NextResponse.json({ error: "Invalid or missing image data. Please ensure an input is connected." }, { status: 400 });
|
| 259 |
}
|
|
@@ -266,34 +266,34 @@ The result should look like all subjects were photographed together in the same
|
|
| 266 |
|
| 267 |
// We'll collect additional inline image parts (references)
|
| 268 |
const referenceParts: { inlineData: { mimeType: string; data: string } }[] = [];
|
| 269 |
-
|
| 270 |
// Background modifications
|
| 271 |
if (params.backgroundType) {
|
| 272 |
const bgType = params.backgroundType;
|
| 273 |
-
|
| 274 |
if (bgType === "color") {
|
| 275 |
prompts.push(`Change the background to a solid ${params.backgroundColor || "white"} background with smooth, even color coverage.`);
|
| 276 |
-
|
| 277 |
} else if (bgType === "gradient") {
|
| 278 |
const direction = params.gradientDirection || "to right";
|
| 279 |
const startColor = params.gradientStartColor || "#ff6b6b";
|
| 280 |
const endColor = params.gradientEndColor || "#4ecdc4";
|
| 281 |
-
|
| 282 |
if (direction === "radial") {
|
| 283 |
prompts.push(`Replace the background with a radial gradient that starts with ${startColor} in the center and transitions smoothly to ${endColor} at the edges, creating a circular gradient effect.`);
|
| 284 |
} else {
|
| 285 |
prompts.push(`Replace the background with a linear gradient flowing ${direction}, starting with ${startColor} and smoothly transitioning to ${endColor}.`);
|
| 286 |
}
|
| 287 |
-
|
| 288 |
} else if (bgType === "image") {
|
| 289 |
prompts.push(`Change the background to ${params.backgroundImage || "a beautiful beach scene"}.`);
|
| 290 |
-
|
| 291 |
} else if (bgType === "city") {
|
| 292 |
const sceneType = params.citySceneType || "busy_street";
|
| 293 |
const timeOfDay = params.cityTimeOfDay || "daytime";
|
| 294 |
-
|
| 295 |
let cityDescription = "";
|
| 296 |
-
|
| 297 |
switch (sceneType) {
|
| 298 |
case "busy_street":
|
| 299 |
cityDescription = "a realistic busy city street with people walking at various distances around the main character. Include pedestrians in business attire, casual clothing, carrying bags and phones - some walking close by (appearing similar size to main character), others further in the background (appearing smaller due to distance). Show urban storefronts, traffic lights, street signs, and parked cars with authentic city atmosphere and proper depth perception";
|
|
@@ -331,7 +331,7 @@ The result should look like all subjects were photographed together in the same
|
|
| 331 |
default:
|
| 332 |
cityDescription = "a dynamic city environment with people walking naturally around the main character in an authentic urban setting";
|
| 333 |
}
|
| 334 |
-
|
| 335 |
let timeDescription = "";
|
| 336 |
switch (timeOfDay) {
|
| 337 |
case "golden_hour":
|
|
@@ -355,14 +355,14 @@ The result should look like all subjects were photographed together in the same
|
|
| 355 |
default:
|
| 356 |
timeDescription = "";
|
| 357 |
}
|
| 358 |
-
|
| 359 |
prompts.push(`Replace the background with ${cityDescription}${timeDescription}. CRITICAL SCALE REQUIREMENTS: Keep the main character at their EXACT original size and position - do NOT make them smaller or change their scale. The background people should be appropriately sized relative to their distance from the camera, with people closer to the camera appearing larger and people further away appearing smaller, but the main character must maintain their original proportions. Ensure the main character appears naturally integrated into the scene with proper lighting, shadows, and perspective that matches the environment.`);
|
| 360 |
-
|
| 361 |
} else if (bgType === "photostudio") {
|
| 362 |
const setup = params.studioSetup || "white_seamless";
|
| 363 |
const lighting = params.studioLighting || "key_fill";
|
| 364 |
const faceCamera = params.faceCamera || false;
|
| 365 |
-
|
| 366 |
let setupDescription = "";
|
| 367 |
switch (setup) {
|
| 368 |
case "white_seamless":
|
|
@@ -387,7 +387,7 @@ The result should look like all subjects were photographed together in the same
|
|
| 387 |
default:
|
| 388 |
setupDescription = "a professional studio backdrop";
|
| 389 |
}
|
| 390 |
-
|
| 391 |
let lightingDescription = "";
|
| 392 |
switch (lighting) {
|
| 393 |
case "key_fill":
|
|
@@ -411,44 +411,56 @@ The result should look like all subjects were photographed together in the same
|
|
| 411 |
default:
|
| 412 |
lightingDescription = "professional studio lighting";
|
| 413 |
}
|
| 414 |
-
|
| 415 |
const positioningInstruction = faceCamera ? " Position the person to face directly toward the camera with confident posture." : "";
|
| 416 |
-
|
| 417 |
prompts.push(`Crop the head and create a 2-inch ID photo. Place the person in a professional photo studio with ${setupDescription} and ${lightingDescription}. Create a clean, professional portrait setup with proper studio atmosphere.${positioningInstruction}`);
|
| 418 |
-
|
| 419 |
} else if (bgType === "upload" && params.customBackgroundImage) {
|
| 420 |
prompts.push(`Replace the background using the provided custom background reference image (attached below). Ensure perspective and lighting match.`);
|
| 421 |
const bgRef = await toInlineDataFromAny(params.customBackgroundImage);
|
| 422 |
if (bgRef) referenceParts.push({ inlineData: bgRef });
|
| 423 |
-
|
| 424 |
} else if (bgType === "custom" && params.customPrompt) {
|
| 425 |
prompts.push(`${params.customPrompt}. CRITICAL SCALE REQUIREMENTS: Keep the main character at their EXACT original size and position - do NOT make them smaller or change their scale. Ensure the main character appears naturally integrated into the scene with proper lighting, shadows, and perspective that matches the environment.`);
|
| 426 |
}
|
| 427 |
}
|
| 428 |
-
|
| 429 |
// Clothes modifications
|
| 430 |
-
if (params.clothesImage) {
|
| 431 |
-
|
| 432 |
-
if (params.
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
const clothesRef = await toInlineDataFromAny(params.clothesImage);
|
| 442 |
-
if (clothesRef) {
|
| 443 |
-
referenceParts.push({ inlineData: clothesRef });
|
| 444 |
} else {
|
| 445 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
}
|
| 447 |
-
} catch (error) {
|
| 448 |
-
console.error('[API] Error processing clothes image:', error);
|
| 449 |
}
|
| 450 |
}
|
| 451 |
-
|
| 452 |
// Style application
|
| 453 |
if (params.stylePreset) {
|
| 454 |
const strength = params.styleStrength || 50;
|
|
@@ -464,10 +476,10 @@ The result should look like all subjects were photographed together in the same
|
|
| 464 |
"family-guy": "Convert into Family Guy animation style",
|
| 465 |
"pixar": "Convert into Pixar animation style",
|
| 466 |
"manga": "Convert into Manga style",
|
| 467 |
-
|
| 468 |
-
|
| 469 |
};
|
| 470 |
-
|
| 471 |
const styleDescription = styleMap[params.stylePreset];
|
| 472 |
if (styleDescription) {
|
| 473 |
prompts.push(`${styleDescription}. Apply this style transformation at ${strength}% intensity while preserving the core subject matter.`);
|
|
@@ -475,18 +487,18 @@ The result should look like all subjects were photographed together in the same
|
|
| 475 |
console.error(`[API] Style not found in styleMap: ${params.stylePreset}`);
|
| 476 |
}
|
| 477 |
}
|
| 478 |
-
|
| 479 |
// Edit prompt
|
| 480 |
if (params.editPrompt) {
|
| 481 |
prompts.push(params.editPrompt);
|
| 482 |
}
|
| 483 |
-
|
| 484 |
// Camera settings - Enhanced for Gemini 2.5 Flash Image
|
| 485 |
-
if (params.focalLength || params.aperture || params.shutterSpeed || params.whiteBalance || params.angle ||
|
| 486 |
-
|
| 487 |
// Build cinematic camera prompt for professional, movie-like results
|
| 488 |
let cameraPrompt = "CINEMATIC CAMERA TRANSFORMATION: Transform this image into a professional, cinematic photograph with movie-quality production values";
|
| 489 |
-
|
| 490 |
if (params.focalLength) {
|
| 491 |
if (params.focalLength === "8mm") {
|
| 492 |
cameraPrompt += " shot with an ultra-wide 8mm fisheye lens creating dramatic barrel distortion, immersive perspective, and cinematic edge curvature typical of action sequences";
|
|
@@ -508,7 +520,7 @@ The result should look like all subjects were photographed together in the same
|
|
| 508 |
cameraPrompt += ` shot with professional ${params.focalLength} cinema glass`;
|
| 509 |
}
|
| 510 |
}
|
| 511 |
-
|
| 512 |
if (params.aperture) {
|
| 513 |
if (params.aperture === "f/1.2") {
|
| 514 |
cameraPrompt += `, shot wide open at f/1.2 for extreme shallow depth of field, ethereal bokeh, and cinematic subject isolation with dreamy background blur`;
|
|
@@ -526,7 +538,7 @@ The result should look like all subjects were photographed together in the same
|
|
| 526 |
cameraPrompt += `, professionally exposed at ${params.aperture}`;
|
| 527 |
}
|
| 528 |
}
|
| 529 |
-
|
| 530 |
if (params.iso) {
|
| 531 |
if (params.iso === "ISO 100") {
|
| 532 |
cameraPrompt += ", shot at ISO 100 for pristine image quality, zero noise, and maximum dynamic range typical of high-end cinema cameras";
|
|
@@ -544,7 +556,7 @@ The result should look like all subjects were photographed together in the same
|
|
| 544 |
cameraPrompt += `, shot at ${params.iso} with appropriate noise characteristics`;
|
| 545 |
}
|
| 546 |
}
|
| 547 |
-
|
| 548 |
if (params.lighting) {
|
| 549 |
if (params.lighting === "Golden Hour") {
|
| 550 |
cameraPrompt += ", cinematically lit during golden hour with warm, directional sunlight creating magical rim lighting, long shadows, and that coveted cinematic glow";
|
|
@@ -560,7 +572,7 @@ The result should look like all subjects were photographed together in the same
|
|
| 560 |
cameraPrompt += `, professionally lit with ${params.lighting} lighting setup`;
|
| 561 |
}
|
| 562 |
}
|
| 563 |
-
|
| 564 |
if (params.bokeh) {
|
| 565 |
if (params.bokeh === "Smooth Bokeh") {
|
| 566 |
cameraPrompt += ", featuring silky smooth bokeh with perfectly circular out-of-focus highlights and creamy background transitions";
|
|
@@ -572,7 +584,7 @@ The result should look like all subjects were photographed together in the same
|
|
| 572 |
cameraPrompt += `, featuring ${params.bokeh} quality bokeh rendering in out-of-focus areas`;
|
| 573 |
}
|
| 574 |
}
|
| 575 |
-
|
| 576 |
if (params.motionBlur) {
|
| 577 |
if (params.motionBlur === "Light Motion Blur") {
|
| 578 |
cameraPrompt += ", with subtle motion blur suggesting gentle movement and adding cinematic flow to the image";
|
|
@@ -588,7 +600,7 @@ The result should look like all subjects were photographed together in the same
|
|
| 588 |
cameraPrompt += `, with ${params.motionBlur} motion effect`;
|
| 589 |
}
|
| 590 |
}
|
| 591 |
-
|
| 592 |
if (params.angle) {
|
| 593 |
if (params.angle === "Low Angle") {
|
| 594 |
cameraPrompt += ", shot from a low-angle perspective looking upward for dramatic impact";
|
|
@@ -598,33 +610,33 @@ The result should look like all subjects were photographed together in the same
|
|
| 598 |
cameraPrompt += `, ${params.angle} camera angle`;
|
| 599 |
}
|
| 600 |
}
|
| 601 |
-
|
| 602 |
if (params.filmStyle && params.filmStyle !== "RAW") {
|
| 603 |
cameraPrompt += `, processed with ${params.filmStyle} film aesthetic and color grading`;
|
| 604 |
} else if (params.filmStyle === "RAW") {
|
| 605 |
cameraPrompt += ", with natural RAW processing maintaining realistic colors and contrast";
|
| 606 |
}
|
| 607 |
-
|
| 608 |
cameraPrompt += ". Maintain photorealistic quality with authentic camera characteristics, natural lighting, and professional composition.";
|
| 609 |
-
|
| 610 |
prompts.push(cameraPrompt);
|
| 611 |
}
|
| 612 |
-
|
| 613 |
// Age transformation
|
| 614 |
if (params.targetAge) {
|
| 615 |
prompts.push(`Transform the person to look exactly ${params.targetAge} years old with age-appropriate features.`);
|
| 616 |
}
|
| 617 |
-
|
| 618 |
// Lighting effects
|
| 619 |
if (params.lightingPrompt && params.selectedLighting) {
|
| 620 |
prompts.push(`IMPORTANT: Completely transform the lighting on this person to match this exact description: ${params.lightingPrompt}. The lighting change should be dramatic and clearly visible. Keep their face, clothes, pose, and background exactly the same, but make the lighting transformation very obvious.`);
|
| 621 |
}
|
| 622 |
-
|
| 623 |
// Pose modifications
|
| 624 |
if (params.posePrompt && params.selectedPose) {
|
| 625 |
prompts.push(`IMPORTANT: Completely change the person's body pose to match this exact description: ${params.posePrompt}. The pose change should be dramatic and clearly visible. Keep their face, clothes, and background exactly the same, but make the pose transformation very obvious.`);
|
| 626 |
}
|
| 627 |
-
|
| 628 |
// Face modifications
|
| 629 |
if (params.faceOptions) {
|
| 630 |
const face = params.faceOptions;
|
|
@@ -636,14 +648,14 @@ The result should look like all subjects were photographed together in the same
|
|
| 636 |
if (face.facialExpression) modifications.push(`change facial expression to ${face.facialExpression}`);
|
| 637 |
if (face.beardStyle) modifications.push(`add/change beard to ${face.beardStyle}`);
|
| 638 |
if (face.selectedMakeup) modifications.push(`add a face makeup with red colors on cheeks and and some yellow blue colors around the eye area`);
|
| 639 |
-
|
| 640 |
if (modifications.length > 0) {
|
| 641 |
prompts.push(`Face modifications: ${modifications.join(", ")}`);
|
| 642 |
}
|
| 643 |
}
|
| 644 |
-
|
| 645 |
// Combine all prompts
|
| 646 |
-
let prompt = prompts.length > 0
|
| 647 |
? prompts.join("\n\n") + "\nApply all these modifications while maintaining the person's identity and keeping unspecified aspects unchanged."
|
| 648 |
: "Process this image with high quality output.";
|
| 649 |
|
|
@@ -663,11 +675,11 @@ The result should look like all subjects were photographed together in the same
|
|
| 663 |
...referenceParts,
|
| 664 |
];
|
| 665 |
|
| 666 |
-
|
| 667 |
let response;
|
| 668 |
try {
|
| 669 |
response = await ai.models.generateContent({
|
| 670 |
-
model: "gemini-2.5-flash-image
|
| 671 |
contents: parts,
|
| 672 |
});
|
| 673 |
} catch (geminiError: any) {
|
|
@@ -677,40 +689,73 @@ The result should look like all subjects were photographed together in the same
|
|
| 677 |
status: geminiError.status,
|
| 678 |
code: geminiError.code
|
| 679 |
});
|
| 680 |
-
|
| 681 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 682 |
return NextResponse.json(
|
| 683 |
{ error: "Content was blocked by safety filters. Try using different images or prompts." },
|
| 684 |
{ status: 400 }
|
| 685 |
);
|
| 686 |
}
|
| 687 |
-
|
| 688 |
-
if (geminiError.message?.includes('quota') || geminiError.message?.includes('limit')) {
|
| 689 |
return NextResponse.json(
|
| 690 |
{ error: "API quota exceeded. Please check your Gemini API usage limits." },
|
| 691 |
{ status: 429 }
|
| 692 |
);
|
| 693 |
}
|
| 694 |
-
|
| 695 |
return NextResponse.json(
|
| 696 |
-
{ error:
|
| 697 |
{ status: 500 }
|
| 698 |
);
|
| 699 |
}
|
| 700 |
|
| 701 |
-
|
| 702 |
const outParts = (response as any)?.candidates?.[0]?.content?.parts ?? [];
|
| 703 |
const images: string[] = [];
|
| 704 |
const texts: string[] = [];
|
| 705 |
-
|
| 706 |
-
|
| 707 |
for (let i = 0; i < outParts.length; i++) {
|
| 708 |
const p = outParts[i];
|
| 709 |
-
|
| 710 |
if (p?.inlineData?.data) {
|
| 711 |
images.push(`data:image/png;base64,${p.inlineData.data}`);
|
| 712 |
}
|
| 713 |
-
|
| 714 |
if (p?.text) {
|
| 715 |
texts.push(p.text);
|
| 716 |
}
|
|
@@ -719,8 +764,8 @@ The result should look like all subjects were photographed together in the same
|
|
| 719 |
if (!images.length) {
|
| 720 |
console.error('[API] No images generated by Gemini. Text responses:', texts);
|
| 721 |
return NextResponse.json(
|
| 722 |
-
{
|
| 723 |
-
error: "No image generated. Try adjusting your parameters.",
|
| 724 |
textResponse: texts.join('\n'),
|
| 725 |
debugInfo: {
|
| 726 |
partsCount: outParts.length,
|
|
@@ -743,7 +788,7 @@ The result should look like all subjects were photographed together in the same
|
|
| 743 |
status: err?.status,
|
| 744 |
details: err?.details
|
| 745 |
});
|
| 746 |
-
|
| 747 |
// Provide more specific error messages
|
| 748 |
if (err?.message?.includes('payload size') || err?.code === 413) {
|
| 749 |
return NextResponse.json(
|
|
@@ -751,28 +796,28 @@ The result should look like all subjects were photographed together in the same
|
|
| 751 |
{ status: 413 }
|
| 752 |
);
|
| 753 |
}
|
| 754 |
-
|
| 755 |
if (err?.message?.includes('API key') || err?.message?.includes('authentication')) {
|
| 756 |
return NextResponse.json(
|
| 757 |
{ error: "Invalid API key. Please check your Google Gemini API token." },
|
| 758 |
{ status: 401 }
|
| 759 |
);
|
| 760 |
}
|
| 761 |
-
|
| 762 |
if (err?.message?.includes('quota') || err?.message?.includes('limit')) {
|
| 763 |
return NextResponse.json(
|
| 764 |
{ error: "API quota exceeded. Please check your Google Gemini API usage limits." },
|
| 765 |
{ status: 429 }
|
| 766 |
);
|
| 767 |
}
|
| 768 |
-
|
| 769 |
if (err?.message?.includes('JSON')) {
|
| 770 |
return NextResponse.json(
|
| 771 |
{ error: "Invalid data format. Please ensure images are properly encoded." },
|
| 772 |
{ status: 400 }
|
| 773 |
);
|
| 774 |
}
|
| 775 |
-
|
| 776 |
return NextResponse.json(
|
| 777 |
{ error: `Failed to process image: ${err?.message || 'Unknown error'}` },
|
| 778 |
{ status: 500 }
|
|
|
|
| 41 |
function parseDataUrl(dataUrl: string): { mimeType: string; data: string } | null {
|
| 42 |
const match = dataUrl.match(/^data:(.*?);base64,(.*)$/); // Regex to capture MIME type and data
|
| 43 |
if (!match) return null; // Invalid format
|
| 44 |
+
return {
|
| 45 |
mimeType: match[1] || "image/png", // Default to PNG if no MIME type
|
| 46 |
data: match[2] // Base64 image data
|
| 47 |
};
|
|
|
|
| 59 |
export async function POST(req: NextRequest) {
|
| 60 |
try {
|
| 61 |
// Log incoming request size for debugging and monitoring
|
| 62 |
+
|
| 63 |
// Parse and validate the JSON request body
|
| 64 |
let body: any;
|
| 65 |
try {
|
|
|
|
| 100 |
|
| 101 |
// Initialize Google AI client with the validated API key
|
| 102 |
const ai = new GoogleGenAI({ apiKey });
|
| 103 |
+
|
| 104 |
/**
|
| 105 |
* Universal image data converter
|
| 106 |
*
|
|
|
|
| 112 |
*/
|
| 113 |
const toInlineDataFromAny = async (url: string): Promise<{ mimeType: string; data: string } | null> => {
|
| 114 |
if (!url) return null; // Handle empty/null input
|
| 115 |
+
|
| 116 |
try {
|
| 117 |
// Case 1: Data URL (data:image/png;base64,...)
|
| 118 |
if (url.startsWith('data:')) {
|
| 119 |
return parseDataUrl(url); // Use existing parser for data URLs
|
| 120 |
}
|
| 121 |
+
|
| 122 |
// Case 2: HTTP/HTTPS URL (external image)
|
| 123 |
if (url.startsWith('http')) {
|
| 124 |
const res = await fetch(url); // Fetch external image
|
|
|
|
| 127 |
const mimeType = res.headers.get('content-type') || 'image/jpeg'; // Get MIME type from headers
|
| 128 |
return { mimeType, data: base64 };
|
| 129 |
}
|
| 130 |
+
|
| 131 |
// Case 3: Relative path (local image on server)
|
| 132 |
if (url.startsWith('/')) {
|
| 133 |
const host = req.headers.get('host') ?? 'localhost:3000'; // Get current host
|
|
|
|
| 139 |
const mimeType = res.headers.get('content-type') || 'image/png'; // Get MIME type
|
| 140 |
return { mimeType, data: base64 };
|
| 141 |
}
|
| 142 |
+
|
| 143 |
return null; // Unsupported URL format
|
| 144 |
} catch {
|
| 145 |
return null; // Handle any conversion errors gracefully
|
|
|
|
| 149 |
/* ========================================
|
| 150 |
MERGE OPERATION - MULTI-IMAGE PROCESSING
|
| 151 |
======================================== */
|
| 152 |
+
|
| 153 |
/**
|
| 154 |
* Handle MERGE node type separately from single-image operations
|
| 155 |
*
|
|
|
|
| 161 |
*/
|
| 162 |
if (body.type === "MERGE") {
|
| 163 |
const imgs = body.images?.filter(Boolean) ?? []; // Remove any null/undefined images
|
| 164 |
+
|
| 165 |
// Validate minimum input requirement for merge operations
|
| 166 |
if (imgs.length < 2) {
|
| 167 |
return NextResponse.json(
|
|
|
|
| 172 |
|
| 173 |
// Determine the AI prompt for merge operation
|
| 174 |
let mergePrompt = body.prompt; // Use custom prompt if provided
|
| 175 |
+
|
| 176 |
if (!mergePrompt) {
|
| 177 |
mergePrompt = `MERGE TASK: Create a natural, cohesive group photo combining ALL subjects from ${imgs.length} provided images.
|
| 178 |
|
|
|
|
| 208 |
const mergeParts: any[] = [{ text: mergePrompt }];
|
| 209 |
for (let i = 0; i < imgs.length; i++) {
|
| 210 |
const url = imgs[i];
|
| 211 |
+
|
| 212 |
try {
|
| 213 |
const parsed = await toInlineDataFromAny(url);
|
| 214 |
if (!parsed) {
|
|
|
|
| 220 |
console.error(`[MERGE] Error processing image ${i + 1}:`, error);
|
| 221 |
}
|
| 222 |
}
|
| 223 |
+
|
| 224 |
|
| 225 |
const response = await ai.models.generateContent({
|
| 226 |
+
model: "gemini-2.5-flash-image",
|
| 227 |
contents: mergeParts,
|
| 228 |
});
|
| 229 |
|
|
|
|
| 253 |
if (body.image) {
|
| 254 |
parsed = await toInlineDataFromAny(body.image);
|
| 255 |
}
|
| 256 |
+
|
| 257 |
if (!parsed) {
|
| 258 |
return NextResponse.json({ error: "Invalid or missing image data. Please ensure an input is connected." }, { status: 400 });
|
| 259 |
}
|
|
|
|
| 266 |
|
| 267 |
// We'll collect additional inline image parts (references)
|
| 268 |
const referenceParts: { inlineData: { mimeType: string; data: string } }[] = [];
|
| 269 |
+
|
| 270 |
// Background modifications
|
| 271 |
if (params.backgroundType) {
|
| 272 |
const bgType = params.backgroundType;
|
| 273 |
+
|
| 274 |
if (bgType === "color") {
|
| 275 |
prompts.push(`Change the background to a solid ${params.backgroundColor || "white"} background with smooth, even color coverage.`);
|
| 276 |
+
|
| 277 |
} else if (bgType === "gradient") {
|
| 278 |
const direction = params.gradientDirection || "to right";
|
| 279 |
const startColor = params.gradientStartColor || "#ff6b6b";
|
| 280 |
const endColor = params.gradientEndColor || "#4ecdc4";
|
| 281 |
+
|
| 282 |
if (direction === "radial") {
|
| 283 |
prompts.push(`Replace the background with a radial gradient that starts with ${startColor} in the center and transitions smoothly to ${endColor} at the edges, creating a circular gradient effect.`);
|
| 284 |
} else {
|
| 285 |
prompts.push(`Replace the background with a linear gradient flowing ${direction}, starting with ${startColor} and smoothly transitioning to ${endColor}.`);
|
| 286 |
}
|
| 287 |
+
|
| 288 |
} else if (bgType === "image") {
|
| 289 |
prompts.push(`Change the background to ${params.backgroundImage || "a beautiful beach scene"}.`);
|
| 290 |
+
|
| 291 |
} else if (bgType === "city") {
|
| 292 |
const sceneType = params.citySceneType || "busy_street";
|
| 293 |
const timeOfDay = params.cityTimeOfDay || "daytime";
|
| 294 |
+
|
| 295 |
let cityDescription = "";
|
| 296 |
+
|
| 297 |
switch (sceneType) {
|
| 298 |
case "busy_street":
|
| 299 |
cityDescription = "a realistic busy city street with people walking at various distances around the main character. Include pedestrians in business attire, casual clothing, carrying bags and phones - some walking close by (appearing similar size to main character), others further in the background (appearing smaller due to distance). Show urban storefronts, traffic lights, street signs, and parked cars with authentic city atmosphere and proper depth perception";
|
|
|
|
| 331 |
default:
|
| 332 |
cityDescription = "a dynamic city environment with people walking naturally around the main character in an authentic urban setting";
|
| 333 |
}
|
| 334 |
+
|
| 335 |
let timeDescription = "";
|
| 336 |
switch (timeOfDay) {
|
| 337 |
case "golden_hour":
|
|
|
|
| 355 |
default:
|
| 356 |
timeDescription = "";
|
| 357 |
}
|
| 358 |
+
|
| 359 |
prompts.push(`Replace the background with ${cityDescription}${timeDescription}. CRITICAL SCALE REQUIREMENTS: Keep the main character at their EXACT original size and position - do NOT make them smaller or change their scale. The background people should be appropriately sized relative to their distance from the camera, with people closer to the camera appearing larger and people further away appearing smaller, but the main character must maintain their original proportions. Ensure the main character appears naturally integrated into the scene with proper lighting, shadows, and perspective that matches the environment.`);
|
| 360 |
+
|
| 361 |
} else if (bgType === "photostudio") {
|
| 362 |
const setup = params.studioSetup || "white_seamless";
|
| 363 |
const lighting = params.studioLighting || "key_fill";
|
| 364 |
const faceCamera = params.faceCamera || false;
|
| 365 |
+
|
| 366 |
let setupDescription = "";
|
| 367 |
switch (setup) {
|
| 368 |
case "white_seamless":
|
|
|
|
| 387 |
default:
|
| 388 |
setupDescription = "a professional studio backdrop";
|
| 389 |
}
|
| 390 |
+
|
| 391 |
let lightingDescription = "";
|
| 392 |
switch (lighting) {
|
| 393 |
case "key_fill":
|
|
|
|
| 411 |
default:
|
| 412 |
lightingDescription = "professional studio lighting";
|
| 413 |
}
|
| 414 |
+
|
| 415 |
const positioningInstruction = faceCamera ? " Position the person to face directly toward the camera with confident posture." : "";
|
| 416 |
+
|
| 417 |
prompts.push(`Crop the head and create a 2-inch ID photo. Place the person in a professional photo studio with ${setupDescription} and ${lightingDescription}. Create a clean, professional portrait setup with proper studio atmosphere.${positioningInstruction}`);
|
| 418 |
+
|
| 419 |
} else if (bgType === "upload" && params.customBackgroundImage) {
|
| 420 |
prompts.push(`Replace the background using the provided custom background reference image (attached below). Ensure perspective and lighting match.`);
|
| 421 |
const bgRef = await toInlineDataFromAny(params.customBackgroundImage);
|
| 422 |
if (bgRef) referenceParts.push({ inlineData: bgRef });
|
| 423 |
+
|
| 424 |
} else if (bgType === "custom" && params.customPrompt) {
|
| 425 |
prompts.push(`${params.customPrompt}. CRITICAL SCALE REQUIREMENTS: Keep the main character at their EXACT original size and position - do NOT make them smaller or change their scale. Ensure the main character appears naturally integrated into the scene with proper lighting, shadows, and perspective that matches the environment.`);
|
| 426 |
}
|
| 427 |
}
|
| 428 |
+
|
| 429 |
// Clothes modifications
|
| 430 |
+
if (params.clothesImage || params.clothesPrompt) {
|
| 431 |
+
// Build the prompt based on what's provided
|
| 432 |
+
if (params.clothesImage && params.clothesPrompt) {
|
| 433 |
+
// Both image and text description provided
|
| 434 |
+
prompts.push(`Take the person shown in the first image and replace their entire outfit with clothing matching this description: "${params.clothesPrompt}". Use the second reference image as a visual guide for the clothing style. The person's face, hair, body pose, and background should remain exactly the same. Only the clothing should change. Ensure the new clothes fit naturally on the person's body with realistic proportions, proper fabric draping, and lighting that matches the original photo environment.`);
|
| 435 |
+
} else if (params.clothesImage) {
|
| 436 |
+
// Only image provided
|
| 437 |
+
if (params.selectedPreset === "Sukajan") {
|
| 438 |
+
prompts.push("Replace the person's clothing with a Japanese sukajan jacket (embroidered designs). Use the clothes reference image if provided.");
|
| 439 |
+
} else if (params.selectedPreset === "Blazer") {
|
| 440 |
+
prompts.push("Replace the person's clothing with a professional blazer. Use the clothes reference image if provided.");
|
|
|
|
|
|
|
|
|
|
| 441 |
} else {
|
| 442 |
+
prompts.push(`Take the person shown in the first image and replace their entire outfit with the clothing items shown in the second reference image. The person's face, hair, body pose, and background should remain exactly the same. Only the clothing should change to match the reference clothing image. Ensure the new clothes fit naturally on the person's body with realistic proportions, proper fabric draping, and lighting that matches the original photo environment.`);
|
| 443 |
+
}
|
| 444 |
+
} else if (params.clothesPrompt) {
|
| 445 |
+
// Only text description provided
|
| 446 |
+
prompts.push(`Change the person's clothing to: ${params.clothesPrompt}. The person's face, hair, body pose, and background should remain exactly the same. Only the clothing should change. Ensure the new clothes fit naturally on the person's body with realistic proportions, proper fabric draping, and lighting that matches the original photo environment.`);
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
// Add the reference image if provided
|
| 450 |
+
if (params.clothesImage) {
|
| 451 |
+
try {
|
| 452 |
+
const clothesRef = await toInlineDataFromAny(params.clothesImage);
|
| 453 |
+
if (clothesRef) {
|
| 454 |
+
referenceParts.push({ inlineData: clothesRef });
|
| 455 |
+
} else {
|
| 456 |
+
console.error('[API] Failed to process clothes image - toInlineDataFromAny returned null');
|
| 457 |
+
}
|
| 458 |
+
} catch (error) {
|
| 459 |
+
console.error('[API] Error processing clothes image:', error);
|
| 460 |
}
|
|
|
|
|
|
|
| 461 |
}
|
| 462 |
}
|
| 463 |
+
|
| 464 |
// Style application
|
| 465 |
if (params.stylePreset) {
|
| 466 |
const strength = params.styleStrength || 50;
|
|
|
|
| 476 |
"family-guy": "Convert into Family Guy animation style",
|
| 477 |
"pixar": "Convert into Pixar animation style",
|
| 478 |
"manga": "Convert into Manga style",
|
| 479 |
+
|
| 480 |
+
|
| 481 |
};
|
| 482 |
+
|
| 483 |
const styleDescription = styleMap[params.stylePreset];
|
| 484 |
if (styleDescription) {
|
| 485 |
prompts.push(`${styleDescription}. Apply this style transformation at ${strength}% intensity while preserving the core subject matter.`);
|
|
|
|
| 487 |
console.error(`[API] Style not found in styleMap: ${params.stylePreset}`);
|
| 488 |
}
|
| 489 |
}
|
| 490 |
+
|
| 491 |
// Edit prompt
|
| 492 |
if (params.editPrompt) {
|
| 493 |
prompts.push(params.editPrompt);
|
| 494 |
}
|
| 495 |
+
|
| 496 |
// Camera settings - Enhanced for Gemini 2.5 Flash Image
|
| 497 |
+
if (params.focalLength || params.aperture || params.shutterSpeed || params.whiteBalance || params.angle ||
|
| 498 |
+
params.iso || params.filmStyle || params.lighting || params.bokeh || params.composition || params.motionBlur) {
|
| 499 |
// Build cinematic camera prompt for professional, movie-like results
|
| 500 |
let cameraPrompt = "CINEMATIC CAMERA TRANSFORMATION: Transform this image into a professional, cinematic photograph with movie-quality production values";
|
| 501 |
+
|
| 502 |
if (params.focalLength) {
|
| 503 |
if (params.focalLength === "8mm") {
|
| 504 |
cameraPrompt += " shot with an ultra-wide 8mm fisheye lens creating dramatic barrel distortion, immersive perspective, and cinematic edge curvature typical of action sequences";
|
|
|
|
| 520 |
cameraPrompt += ` shot with professional ${params.focalLength} cinema glass`;
|
| 521 |
}
|
| 522 |
}
|
| 523 |
+
|
| 524 |
if (params.aperture) {
|
| 525 |
if (params.aperture === "f/1.2") {
|
| 526 |
cameraPrompt += `, shot wide open at f/1.2 for extreme shallow depth of field, ethereal bokeh, and cinematic subject isolation with dreamy background blur`;
|
|
|
|
| 538 |
cameraPrompt += `, professionally exposed at ${params.aperture}`;
|
| 539 |
}
|
| 540 |
}
|
| 541 |
+
|
| 542 |
if (params.iso) {
|
| 543 |
if (params.iso === "ISO 100") {
|
| 544 |
cameraPrompt += ", shot at ISO 100 for pristine image quality, zero noise, and maximum dynamic range typical of high-end cinema cameras";
|
|
|
|
| 556 |
cameraPrompt += `, shot at ${params.iso} with appropriate noise characteristics`;
|
| 557 |
}
|
| 558 |
}
|
| 559 |
+
|
| 560 |
if (params.lighting) {
|
| 561 |
if (params.lighting === "Golden Hour") {
|
| 562 |
cameraPrompt += ", cinematically lit during golden hour with warm, directional sunlight creating magical rim lighting, long shadows, and that coveted cinematic glow";
|
|
|
|
| 572 |
cameraPrompt += `, professionally lit with ${params.lighting} lighting setup`;
|
| 573 |
}
|
| 574 |
}
|
| 575 |
+
|
| 576 |
if (params.bokeh) {
|
| 577 |
if (params.bokeh === "Smooth Bokeh") {
|
| 578 |
cameraPrompt += ", featuring silky smooth bokeh with perfectly circular out-of-focus highlights and creamy background transitions";
|
|
|
|
| 584 |
cameraPrompt += `, featuring ${params.bokeh} quality bokeh rendering in out-of-focus areas`;
|
| 585 |
}
|
| 586 |
}
|
| 587 |
+
|
| 588 |
if (params.motionBlur) {
|
| 589 |
if (params.motionBlur === "Light Motion Blur") {
|
| 590 |
cameraPrompt += ", with subtle motion blur suggesting gentle movement and adding cinematic flow to the image";
|
|
|
|
| 600 |
cameraPrompt += `, with ${params.motionBlur} motion effect`;
|
| 601 |
}
|
| 602 |
}
|
| 603 |
+
|
| 604 |
if (params.angle) {
|
| 605 |
if (params.angle === "Low Angle") {
|
| 606 |
cameraPrompt += ", shot from a low-angle perspective looking upward for dramatic impact";
|
|
|
|
| 610 |
cameraPrompt += `, ${params.angle} camera angle`;
|
| 611 |
}
|
| 612 |
}
|
| 613 |
+
|
| 614 |
if (params.filmStyle && params.filmStyle !== "RAW") {
|
| 615 |
cameraPrompt += `, processed with ${params.filmStyle} film aesthetic and color grading`;
|
| 616 |
} else if (params.filmStyle === "RAW") {
|
| 617 |
cameraPrompt += ", with natural RAW processing maintaining realistic colors and contrast";
|
| 618 |
}
|
| 619 |
+
|
| 620 |
cameraPrompt += ". Maintain photorealistic quality with authentic camera characteristics, natural lighting, and professional composition.";
|
| 621 |
+
|
| 622 |
prompts.push(cameraPrompt);
|
| 623 |
}
|
| 624 |
+
|
| 625 |
// Age transformation
|
| 626 |
if (params.targetAge) {
|
| 627 |
prompts.push(`Transform the person to look exactly ${params.targetAge} years old with age-appropriate features.`);
|
| 628 |
}
|
| 629 |
+
|
| 630 |
// Lighting effects
|
| 631 |
if (params.lightingPrompt && params.selectedLighting) {
|
| 632 |
prompts.push(`IMPORTANT: Completely transform the lighting on this person to match this exact description: ${params.lightingPrompt}. The lighting change should be dramatic and clearly visible. Keep their face, clothes, pose, and background exactly the same, but make the lighting transformation very obvious.`);
|
| 633 |
}
|
| 634 |
+
|
| 635 |
// Pose modifications
|
| 636 |
if (params.posePrompt && params.selectedPose) {
|
| 637 |
prompts.push(`IMPORTANT: Completely change the person's body pose to match this exact description: ${params.posePrompt}. The pose change should be dramatic and clearly visible. Keep their face, clothes, and background exactly the same, but make the pose transformation very obvious.`);
|
| 638 |
}
|
| 639 |
+
|
| 640 |
// Face modifications
|
| 641 |
if (params.faceOptions) {
|
| 642 |
const face = params.faceOptions;
|
|
|
|
| 648 |
if (face.facialExpression) modifications.push(`change facial expression to ${face.facialExpression}`);
|
| 649 |
if (face.beardStyle) modifications.push(`add/change beard to ${face.beardStyle}`);
|
| 650 |
if (face.selectedMakeup) modifications.push(`add a face makeup with red colors on cheeks and and some yellow blue colors around the eye area`);
|
| 651 |
+
|
| 652 |
if (modifications.length > 0) {
|
| 653 |
prompts.push(`Face modifications: ${modifications.join(", ")}`);
|
| 654 |
}
|
| 655 |
}
|
| 656 |
+
|
| 657 |
// Combine all prompts
|
| 658 |
+
let prompt = prompts.length > 0
|
| 659 |
? prompts.join("\n\n") + "\nApply all these modifications while maintaining the person's identity and keeping unspecified aspects unchanged."
|
| 660 |
: "Process this image with high quality output.";
|
| 661 |
|
|
|
|
| 675 |
...referenceParts,
|
| 676 |
];
|
| 677 |
|
| 678 |
+
|
| 679 |
let response;
|
| 680 |
try {
|
| 681 |
response = await ai.models.generateContent({
|
| 682 |
+
model: "gemini-2.5-flash-image",
|
| 683 |
contents: parts,
|
| 684 |
});
|
| 685 |
} catch (geminiError: any) {
|
|
|
|
| 689 |
status: geminiError.status,
|
| 690 |
code: geminiError.code
|
| 691 |
});
|
| 692 |
+
|
| 693 |
+
// Try to extract a clean error message from the error
|
| 694 |
+
let errorMessage = 'Unknown error occurred';
|
| 695 |
+
|
| 696 |
+
// Check if the error message contains JSON
|
| 697 |
+
if (geminiError.message) {
|
| 698 |
+
try {
|
| 699 |
+
// Try to parse JSON from the error message
|
| 700 |
+
const jsonMatch = geminiError.message.match(/\{[\s\S]*\}/);
|
| 701 |
+
if (jsonMatch) {
|
| 702 |
+
const errorJson = JSON.parse(jsonMatch[0]);
|
| 703 |
+
// Extract the message from the parsed JSON
|
| 704 |
+
if (errorJson.error?.message) {
|
| 705 |
+
errorMessage = errorJson.error.message;
|
| 706 |
+
} else if (errorJson.message) {
|
| 707 |
+
errorMessage = errorJson.message;
|
| 708 |
+
}
|
| 709 |
+
} else {
|
| 710 |
+
errorMessage = geminiError.message;
|
| 711 |
+
}
|
| 712 |
+
} catch {
|
| 713 |
+
// If JSON parsing fails, use the original message
|
| 714 |
+
errorMessage = geminiError.message;
|
| 715 |
+
}
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
// Check for specific error types and provide user-friendly messages
|
| 719 |
+
if (errorMessage.includes('API key not valid') || errorMessage.includes('API_KEY_INVALID')) {
|
| 720 |
+
return NextResponse.json(
|
| 721 |
+
{ error: "Invalid API key. Please check your Google Gemini API key and try again." },
|
| 722 |
+
{ status: 401 }
|
| 723 |
+
);
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
if (geminiError.message?.includes('safety') || errorMessage.includes('safety')) {
|
| 727 |
return NextResponse.json(
|
| 728 |
{ error: "Content was blocked by safety filters. Try using different images or prompts." },
|
| 729 |
{ status: 400 }
|
| 730 |
);
|
| 731 |
}
|
| 732 |
+
|
| 733 |
+
if (geminiError.message?.includes('quota') || geminiError.message?.includes('limit') || errorMessage.includes('quota') || errorMessage.includes('limit')) {
|
| 734 |
return NextResponse.json(
|
| 735 |
{ error: "API quota exceeded. Please check your Gemini API usage limits." },
|
| 736 |
{ status: 429 }
|
| 737 |
);
|
| 738 |
}
|
| 739 |
+
|
| 740 |
return NextResponse.json(
|
| 741 |
+
{ error: errorMessage },
|
| 742 |
{ status: 500 }
|
| 743 |
);
|
| 744 |
}
|
| 745 |
|
| 746 |
+
|
| 747 |
const outParts = (response as any)?.candidates?.[0]?.content?.parts ?? [];
|
| 748 |
const images: string[] = [];
|
| 749 |
const texts: string[] = [];
|
| 750 |
+
|
| 751 |
+
|
| 752 |
for (let i = 0; i < outParts.length; i++) {
|
| 753 |
const p = outParts[i];
|
| 754 |
+
|
| 755 |
if (p?.inlineData?.data) {
|
| 756 |
images.push(`data:image/png;base64,${p.inlineData.data}`);
|
| 757 |
}
|
| 758 |
+
|
| 759 |
if (p?.text) {
|
| 760 |
texts.push(p.text);
|
| 761 |
}
|
|
|
|
| 764 |
if (!images.length) {
|
| 765 |
console.error('[API] No images generated by Gemini. Text responses:', texts);
|
| 766 |
return NextResponse.json(
|
| 767 |
+
{
|
| 768 |
+
error: "No image generated. Try adjusting your parameters.",
|
| 769 |
textResponse: texts.join('\n'),
|
| 770 |
debugInfo: {
|
| 771 |
partsCount: outParts.length,
|
|
|
|
| 788 |
status: err?.status,
|
| 789 |
details: err?.details
|
| 790 |
});
|
| 791 |
+
|
| 792 |
// Provide more specific error messages
|
| 793 |
if (err?.message?.includes('payload size') || err?.code === 413) {
|
| 794 |
return NextResponse.json(
|
|
|
|
| 796 |
{ status: 413 }
|
| 797 |
);
|
| 798 |
}
|
| 799 |
+
|
| 800 |
if (err?.message?.includes('API key') || err?.message?.includes('authentication')) {
|
| 801 |
return NextResponse.json(
|
| 802 |
{ error: "Invalid API key. Please check your Google Gemini API token." },
|
| 803 |
{ status: 401 }
|
| 804 |
);
|
| 805 |
}
|
| 806 |
+
|
| 807 |
if (err?.message?.includes('quota') || err?.message?.includes('limit')) {
|
| 808 |
return NextResponse.json(
|
| 809 |
{ error: "API quota exceeded. Please check your Google Gemini API usage limits." },
|
| 810 |
{ status: 429 }
|
| 811 |
);
|
| 812 |
}
|
| 813 |
+
|
| 814 |
if (err?.message?.includes('JSON')) {
|
| 815 |
return NextResponse.json(
|
| 816 |
{ error: "Invalid data format. Please ensure images are properly encoded." },
|
| 817 |
{ status: 400 }
|
| 818 |
);
|
| 819 |
}
|
| 820 |
+
|
| 821 |
return NextResponse.json(
|
| 822 |
{ error: `Failed to process image: ${err?.message || 'Unknown error'}` },
|
| 823 |
{ status: 500 }
|
app/nodes.tsx
CHANGED
|
@@ -869,10 +869,57 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
|
|
| 869 |
// Handle node dragging functionality
|
| 870 |
const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
|
| 871 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 872 |
return (
|
| 873 |
<div
|
| 874 |
className="nb-node absolute w-[320px]"
|
| 875 |
style={{ left: localPos.x, top: localPos.y }}
|
|
|
|
|
|
|
|
|
|
| 876 |
>
|
| 877 |
<NodeTimer startTime={node.startTime} executionTime={node.executionTime} isRunning={node.isRunning} />
|
| 878 |
<div
|
|
@@ -918,6 +965,38 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
|
|
| 918 |
</Button>
|
| 919 |
</div>
|
| 920 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 921 |
<div className="text-xs text-muted-foreground">Clothing Description</div>
|
| 922 |
|
| 923 |
<Textarea
|
|
@@ -931,8 +1010,8 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
|
|
| 931 |
<Button
|
| 932 |
className="w-full"
|
| 933 |
onClick={() => onProcess(node.id)}
|
| 934 |
-
disabled={node.isRunning || !node.clothesPrompt}
|
| 935 |
-
title={!node.input ? "Connect an input first" : !node.clothesPrompt ? "Enter a clothing description" : "Apply Clothing"}
|
| 936 |
>
|
| 937 |
{node.isRunning ? "Processing..." : "Apply Clothes"}
|
| 938 |
</Button>
|
|
|
|
| 869 |
// Handle node dragging functionality
|
| 870 |
const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
|
| 871 |
|
| 872 |
+
// Handle image upload via file input
|
| 873 |
+
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 874 |
+
const file = e.target.files?.[0];
|
| 875 |
+
if (file) {
|
| 876 |
+
const reader = new FileReader();
|
| 877 |
+
reader.onload = () => {
|
| 878 |
+
onUpdate(node.id, { clothesImage: reader.result });
|
| 879 |
+
};
|
| 880 |
+
reader.readAsDataURL(file);
|
| 881 |
+
}
|
| 882 |
+
};
|
| 883 |
+
|
| 884 |
+
// Handle image paste from clipboard
|
| 885 |
+
const handleImagePaste = async (e: React.ClipboardEvent) => {
|
| 886 |
+
const items = e.clipboardData.items;
|
| 887 |
+
for (let i = 0; i < items.length; i++) {
|
| 888 |
+
const item = items[i];
|
| 889 |
+
if (item.type.startsWith('image/')) {
|
| 890 |
+
const file = item.getAsFile();
|
| 891 |
+
if (file) {
|
| 892 |
+
const reader = new FileReader();
|
| 893 |
+
reader.onload = () => {
|
| 894 |
+
onUpdate(node.id, { clothesImage: reader.result });
|
| 895 |
+
};
|
| 896 |
+
reader.readAsDataURL(file);
|
| 897 |
+
return;
|
| 898 |
+
}
|
| 899 |
+
}
|
| 900 |
+
}
|
| 901 |
+
};
|
| 902 |
+
|
| 903 |
+
// Handle drag and drop
|
| 904 |
+
const handleDrop = (e: React.DragEvent) => {
|
| 905 |
+
e.preventDefault();
|
| 906 |
+
const files = e.dataTransfer.files;
|
| 907 |
+
if (files && files.length) {
|
| 908 |
+
const reader = new FileReader();
|
| 909 |
+
reader.onload = () => {
|
| 910 |
+
onUpdate(node.id, { clothesImage: reader.result });
|
| 911 |
+
};
|
| 912 |
+
reader.readAsDataURL(files[0]);
|
| 913 |
+
}
|
| 914 |
+
};
|
| 915 |
+
|
| 916 |
return (
|
| 917 |
<div
|
| 918 |
className="nb-node absolute w-[320px]"
|
| 919 |
style={{ left: localPos.x, top: localPos.y }}
|
| 920 |
+
onDrop={handleDrop}
|
| 921 |
+
onDragOver={(e) => e.preventDefault()}
|
| 922 |
+
onPaste={handleImagePaste}
|
| 923 |
>
|
| 924 |
<NodeTimer startTime={node.startTime} executionTime={node.executionTime} isRunning={node.isRunning} />
|
| 925 |
<div
|
|
|
|
| 965 |
</Button>
|
| 966 |
</div>
|
| 967 |
)}
|
| 968 |
+
|
| 969 |
+
{/* Clothing Reference Image Upload Section */}
|
| 970 |
+
<div className="text-xs text-muted-foreground">Reference Clothing Image (Optional)</div>
|
| 971 |
+
<div className="space-y-2">
|
| 972 |
+
{node.clothesImage ? (
|
| 973 |
+
<div className="relative">
|
| 974 |
+
<img src={node.clothesImage} className="w-full rounded max-h-40 object-contain bg-muted/30" alt="Clothing Reference" />
|
| 975 |
+
<Button
|
| 976 |
+
variant="destructive"
|
| 977 |
+
size="sm"
|
| 978 |
+
className="absolute top-2 right-2"
|
| 979 |
+
onClick={() => onUpdate(node.id, { clothesImage: null })}
|
| 980 |
+
>
|
| 981 |
+
Remove
|
| 982 |
+
</Button>
|
| 983 |
+
</div>
|
| 984 |
+
) : (
|
| 985 |
+
<label className="block">
|
| 986 |
+
<input
|
| 987 |
+
type="file"
|
| 988 |
+
accept="image/*"
|
| 989 |
+
className="hidden"
|
| 990 |
+
onChange={handleImageUpload}
|
| 991 |
+
/>
|
| 992 |
+
<div className="border-2 border-dashed border-white/20 rounded-lg p-4 text-center cursor-pointer hover:border-white/40 transition-colors">
|
| 993 |
+
<p className="text-xs text-white/60">Drop, upload, or paste clothing image</p>
|
| 994 |
+
<p className="text-xs text-white/40 mt-1">JPG, PNG, WEBP</p>
|
| 995 |
+
</div>
|
| 996 |
+
</label>
|
| 997 |
+
)}
|
| 998 |
+
</div>
|
| 999 |
+
|
| 1000 |
<div className="text-xs text-muted-foreground">Clothing Description</div>
|
| 1001 |
|
| 1002 |
<Textarea
|
|
|
|
| 1010 |
<Button
|
| 1011 |
className="w-full"
|
| 1012 |
onClick={() => onProcess(node.id)}
|
| 1013 |
+
disabled={node.isRunning || (!node.clothesPrompt && !node.clothesImage)}
|
| 1014 |
+
title={!node.input ? "Connect an input first" : (!node.clothesPrompt && !node.clothesImage) ? "Enter a clothing description or upload an image" : "Apply Clothing"}
|
| 1015 |
>
|
| 1016 |
{node.isRunning ? "Processing..." : "Apply Clothes"}
|
| 1017 |
</Button>
|
app/page.tsx
CHANGED
|
@@ -1270,6 +1270,9 @@ export default function EditorPage() {
|
|
| 1270 |
if ((node as ClothesNode).clothesPrompt) {
|
| 1271 |
config.clothesPrompt = (node as ClothesNode).clothesPrompt;
|
| 1272 |
}
|
|
|
|
|
|
|
|
|
|
| 1273 |
break;
|
| 1274 |
case "STYLE":
|
| 1275 |
if ((node as StyleNode).stylePreset) {
|
|
@@ -1590,7 +1593,7 @@ export default function EditorPage() {
|
|
| 1590 |
}),
|
| 1591 |
});
|
| 1592 |
} else {
|
| 1593 |
-
// Use Nano Banana
|
| 1594 |
res = await fetch("/api/process", {
|
| 1595 |
method: "POST",
|
| 1596 |
headers: { "Content-Type": "application/json" },
|
|
@@ -1812,7 +1815,7 @@ export default function EditorPage() {
|
|
| 1812 |
if (processingMode === 'huggingface') {
|
| 1813 |
setNodes((prev) => prev.map((n) => (n.id === mergeId && n.type === "MERGE" ? {
|
| 1814 |
...n,
|
| 1815 |
-
error: "MERGE requires Nano Banana
|
| 1816 |
} : n)));
|
| 1817 |
return;
|
| 1818 |
}
|
|
@@ -2163,7 +2166,7 @@ export default function EditorPage() {
|
|
| 2163 |
onClick={() => setProcessingMode('nanobananapro')}
|
| 2164 |
title="Use Google Gemini API - supports all features including MERGE"
|
| 2165 |
>
|
| 2166 |
-
π Nano Banana
|
| 2167 |
</button>
|
| 2168 |
<button
|
| 2169 |
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${processingMode === 'huggingface'
|
|
@@ -2293,7 +2296,7 @@ export default function EditorPage() {
|
|
| 2293 |
<h3 className="font-semibold mb-3 text-foreground">βοΈ Processing Modes</h3>
|
| 2294 |
<div className="text-sm text-muted-foreground space-y-3">
|
| 2295 |
<div className="p-3 bg-primary/10 border border-primary/20 rounded-lg">
|
| 2296 |
-
<p className="font-medium text-primary mb-2">π Nano Banana
|
| 2297 |
<p>Uses Google's Gemini API. <strong>Supports ALL nodes</strong> including MERGE for combining multiple images into group photos.</p>
|
| 2298 |
<p className="mt-1 text-xs">Requires a Google Gemini API key from <a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">AI Studio</a>.</p>
|
| 2299 |
</div>
|
|
@@ -2309,7 +2312,7 @@ export default function EditorPage() {
|
|
| 2309 |
<div className="p-4 bg-destructive/10 border border-destructive/30 rounded-lg">
|
| 2310 |
<h4 className="font-semibold text-destructive mb-2">β οΈ MERGE Node Limitation</h4>
|
| 2311 |
<p className="text-sm text-muted-foreground">
|
| 2312 |
-
The <strong>MERGE</strong> node requires <strong>Nano Banana
|
| 2313 |
</p>
|
| 2314 |
</div>
|
| 2315 |
|
|
@@ -2338,7 +2341,7 @@ export default function EditorPage() {
|
|
| 2338 |
<div className="text-sm text-muted-foreground space-y-2">
|
| 2339 |
<p>β’ <strong>Adding Nodes:</strong> Right-click on the canvas to add nodes</p>
|
| 2340 |
<p>β’ <strong>Character Nodes:</strong> Upload or drag images as starting points</p>
|
| 2341 |
-
<p>β’ <strong>Merge Nodes:</strong> Connect multiple characters (Nano Banana
|
| 2342 |
<p>β’ <strong>Editing Nodes:</strong> Background, Style, Face, Age, Camera, etc.</p>
|
| 2343 |
<p>β’ <strong>Connecting:</strong> Drag from output port to input port</p>
|
| 2344 |
</div>
|
|
|
|
| 1270 |
if ((node as ClothesNode).clothesPrompt) {
|
| 1271 |
config.clothesPrompt = (node as ClothesNode).clothesPrompt;
|
| 1272 |
}
|
| 1273 |
+
if ((node as ClothesNode).clothesImage) {
|
| 1274 |
+
config.clothesImage = (node as ClothesNode).clothesImage;
|
| 1275 |
+
}
|
| 1276 |
break;
|
| 1277 |
case "STYLE":
|
| 1278 |
if ((node as StyleNode).stylePreset) {
|
|
|
|
| 1593 |
}),
|
| 1594 |
});
|
| 1595 |
} else {
|
| 1596 |
+
// Use Nano Banana (Gemini API)
|
| 1597 |
res = await fetch("/api/process", {
|
| 1598 |
method: "POST",
|
| 1599 |
headers: { "Content-Type": "application/json" },
|
|
|
|
| 1815 |
if (processingMode === 'huggingface') {
|
| 1816 |
setNodes((prev) => prev.map((n) => (n.id === mergeId && n.type === "MERGE" ? {
|
| 1817 |
...n,
|
| 1818 |
+
error: "MERGE requires Nano Banana mode. HuggingFace models only accept single images. Please switch to 'π Nano Banana' in the header and enter your Gemini API key."
|
| 1819 |
} : n)));
|
| 1820 |
return;
|
| 1821 |
}
|
|
|
|
| 2166 |
onClick={() => setProcessingMode('nanobananapro')}
|
| 2167 |
title="Use Google Gemini API - supports all features including MERGE"
|
| 2168 |
>
|
| 2169 |
+
π Nano Banana
|
| 2170 |
</button>
|
| 2171 |
<button
|
| 2172 |
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${processingMode === 'huggingface'
|
|
|
|
| 2296 |
<h3 className="font-semibold mb-3 text-foreground">βοΈ Processing Modes</h3>
|
| 2297 |
<div className="text-sm text-muted-foreground space-y-3">
|
| 2298 |
<div className="p-3 bg-primary/10 border border-primary/20 rounded-lg">
|
| 2299 |
+
<p className="font-medium text-primary mb-2">π Nano Banana (Gemini API)</p>
|
| 2300 |
<p>Uses Google's Gemini API. <strong>Supports ALL nodes</strong> including MERGE for combining multiple images into group photos.</p>
|
| 2301 |
<p className="mt-1 text-xs">Requires a Google Gemini API key from <a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">AI Studio</a>.</p>
|
| 2302 |
</div>
|
|
|
|
| 2312 |
<div className="p-4 bg-destructive/10 border border-destructive/30 rounded-lg">
|
| 2313 |
<h4 className="font-semibold text-destructive mb-2">β οΈ MERGE Node Limitation</h4>
|
| 2314 |
<p className="text-sm text-muted-foreground">
|
| 2315 |
+
The <strong>MERGE</strong> node requires <strong>Nano Banana</strong> because it combines multiple images into one cohesive group photo. HuggingFace models only accept single images, so MERGE won't work in HuggingFace mode.
|
| 2316 |
</p>
|
| 2317 |
</div>
|
| 2318 |
|
|
|
|
| 2341 |
<div className="text-sm text-muted-foreground space-y-2">
|
| 2342 |
<p>β’ <strong>Adding Nodes:</strong> Right-click on the canvas to add nodes</p>
|
| 2343 |
<p>β’ <strong>Character Nodes:</strong> Upload or drag images as starting points</p>
|
| 2344 |
+
<p>β’ <strong>Merge Nodes:</strong> Connect multiple characters (Nano Banana only)</p>
|
| 2345 |
<p>β’ <strong>Editing Nodes:</strong> Background, Style, Face, Age, Camera, etc.</p>
|
| 2346 |
<p>β’ <strong>Connecting:</strong> Drag from output port to input port</p>
|
| 2347 |
</div>
|