Reubencf commited on
Commit
1ccbe14
Β·
1 Parent(s): 44e0ddc

Minor changes

Browse files
.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 Pro (Gemini API) which accepts multiple images
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 Pro (Gemini API). HuggingFace models only accept single images. Please switch to 'Nano Banana Pro' mode and enter your Google Gemini API key to use MERGE functionality.",
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 Pro (Gemini API) as it needs multi-image input which HuggingFace models don't support."
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-preview",
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.selectedPreset === "Sukajan") {
433
- prompts.push("Replace the person's clothing with a Japanese sukajan jacket (embroidered designs). Use the clothes reference image if provided.");
434
- } else if (params.selectedPreset === "Blazer") {
435
- prompts.push("Replace the person's clothing with a professional blazer. Use the clothes reference image if provided.");
436
- } else {
437
- 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.`);
438
- }
439
-
440
- try {
441
- const clothesRef = await toInlineDataFromAny(params.clothesImage);
442
- if (clothesRef) {
443
- referenceParts.push({ inlineData: clothesRef });
444
  } else {
445
- console.error('[API] Failed to process clothes image - toInlineDataFromAny returned null');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- params.iso || params.filmStyle || params.lighting || params.bokeh || params.composition || params.motionBlur) {
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-preview",
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
- if (geminiError.message?.includes('safety')) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: `Gemini API error: ${geminiError.message || 'Unknown 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 Pro (Gemini API)
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 Pro mode. HuggingFace models only accept single images. Please switch to '🍌 Nano Banana Pro' in the header and enter your Gemini API key."
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 Pro
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 Pro (Gemini API)</p>
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 Pro</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.
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 Pro only)</p>
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>