Reubencf commited on
Commit
c01791d
·
1 Parent(s): 0cda8bd

the site is done i improved the UI/UX

Browse files
app/api/merge/route.ts CHANGED
@@ -10,6 +10,26 @@ function parseDataUrl(dataUrl: string): { mimeType: string; data: string } | nul
10
  return { mimeType: match[1] || "image/png", data: match[2] };
11
  }
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  export async function POST(req: NextRequest) {
14
  try {
15
  const body = (await req.json()) as {
@@ -36,29 +56,52 @@ export async function POST(req: NextRequest) {
36
  const ai = new GoogleGenAI({ apiKey });
37
 
38
  // Build parts array: first the text prompt, then image inlineData parts
39
- // Use provided prompt or generate a default one
40
- const prompt = body.prompt ||
41
- `You are provided with ${imgs.length} images. Each image may contain one or more people.
42
-
43
- Your task: Create a single new photorealistic image that combines ALL people from ALL ${imgs.length} provided images into one cohesive group photo.
44
-
45
- Requirements:
46
- - Include EVERY person from EVERY input image (if an image has multiple people, include all of them)
47
- - Combine all people into a single scene where they appear together
48
- - Arrange them naturally (standing side by side, in rows, or in a natural group formation)
49
- - Ensure all people are clearly visible and recognizable
50
- - Match the lighting, shadows, and proportions to look realistic
51
- - Preserve each person's original appearance, clothing, and characteristics
52
- - The final composition should look like a genuine group photograph
53
-
54
- Output: One photorealistic image containing ALL people from ALL input images combined together.`;
55
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  const parts: any[] = [{ text: prompt }];
57
  for (const url of imgs) {
58
- const parsed = parseDataUrl(url);
59
- if (!parsed) continue;
 
 
 
60
  parts.push({ inlineData: { mimeType: parsed.mimeType, data: parsed.data } });
61
  }
 
 
 
62
 
63
  const response = await ai.models.generateContent({
64
  model: "gemini-2.5-flash-image-preview",
 
10
  return { mimeType: match[1] || "image/png", data: match[2] };
11
  }
12
 
13
+ async function toInlineData(url: string): Promise<{ mimeType: string; data: string } | null> {
14
+ try {
15
+ if (url.startsWith('data:')) {
16
+ return parseDataUrl(url);
17
+ }
18
+ if (url.startsWith('http')) {
19
+ // Fetch HTTP URL and convert to base64
20
+ const res = await fetch(url);
21
+ const buf = await res.arrayBuffer();
22
+ const base64 = Buffer.from(buf).toString('base64');
23
+ const mimeType = res.headers.get('content-type') || 'image/jpeg';
24
+ return { mimeType, data: base64 };
25
+ }
26
+ return null;
27
+ } catch (e) {
28
+ console.error('Failed to process image URL:', url.substring(0, 100), e);
29
+ return null;
30
+ }
31
+ }
32
+
33
  export async function POST(req: NextRequest) {
34
  try {
35
  const body = (await req.json()) as {
 
56
  const ai = new GoogleGenAI({ apiKey });
57
 
58
  // Build parts array: first the text prompt, then image inlineData parts
59
+ // If no custom prompt, use default extraction-focused prompt
60
+ let prompt = body.prompt;
61
+
62
+ if (!prompt) {
63
+ prompt = `MERGE TASK: You are provided with exactly ${imgs.length} source images.
 
 
 
 
 
 
 
 
 
 
 
64
 
65
+ INSTRUCTIONS:
66
+ 1. EXTRACT the exact people/subjects from EACH provided image
67
+ 2. DO NOT generate new people - use ONLY the people visible in the provided images
68
+ 3. COMBINE all extracted people into ONE single group photo
69
+ 4. The output must contain ALL people from ALL ${imgs.length} input images together
70
+
71
+ Requirements:
72
+ - Use the ACTUAL people from the provided images (do not create new ones)
73
+ - If an image has multiple people, include ALL of them
74
+ - Arrange everyone naturally in the same scene
75
+ - Match lighting and proportions realistically
76
+ - Output exactly ONE image with everyone combined
77
+
78
+ DO NOT create artistic interpretations or new people. EXTRACT and COMBINE the actual subjects from the provided photographs.`;
79
+ } else {
80
+ // Even with custom prompt, append extraction requirements
81
+ const enforcement = `\n\nIMPORTANT: Extract and use the EXACT people from the provided images. Do not generate new people or artistic interpretations. Combine the actual subjects from all ${imgs.length} images into one output.`;
82
+ prompt = `${prompt}${enforcement}`;
83
+ }
84
+
85
+ // Debug: Log what we're receiving
86
+ console.log(`[MERGE API] Received ${imgs.length} images to merge`);
87
+ console.log(`[MERGE API] Image types:`, imgs.map(img => {
88
+ if (img.startsWith('data:')) return 'data URL';
89
+ if (img.startsWith('http')) return 'HTTP URL';
90
+ return 'unknown';
91
+ }));
92
+
93
  const parts: any[] = [{ text: prompt }];
94
  for (const url of imgs) {
95
+ const parsed = await toInlineData(url);
96
+ if (!parsed) {
97
+ console.error('[MERGE API] Failed to parse image:', url.substring(0, 100));
98
+ continue;
99
+ }
100
  parts.push({ inlineData: { mimeType: parsed.mimeType, data: parsed.data } });
101
  }
102
+
103
+ console.log(`[MERGE API] Sending ${parts.length - 1} images to model (prompt + images)`);
104
+ console.log(`[MERGE API] Prompt preview:`, prompt.substring(0, 200));
105
 
106
  const response = await ai.models.generateContent({
107
  model: "gemini-2.5-flash-image-preview",
app/api/process/route.ts CHANGED
@@ -28,24 +28,40 @@ export async function POST(req: NextRequest) {
28
 
29
  const ai = new GoogleGenAI({ apiKey });
30
 
31
- // Parse input image
32
- let parsed = null;
33
- if (body.image) {
34
- if (body.image.startsWith('data:')) {
35
- // It's already a data URL
36
- parsed = parseDataUrl(body.image);
37
- } else if (body.image.startsWith('http')) {
38
- // It's an HTTP URL, we need to fetch and convert it
39
- try {
40
- const imageResponse = await fetch(body.image);
41
- const arrayBuffer = await imageResponse.arrayBuffer();
42
- const base64 = Buffer.from(arrayBuffer).toString('base64');
43
- const mimeType = imageResponse.headers.get('content-type') || 'image/jpeg';
44
- parsed = { mimeType, data: base64 };
45
- } catch (e) {
46
- return NextResponse.json({ error: "Failed to fetch image from URL" }, { status: 400 });
47
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  }
 
 
 
 
 
 
49
  }
50
 
51
  if (!parsed) {
@@ -55,6 +71,9 @@ export async function POST(req: NextRequest) {
55
  // Build combined prompt from all accumulated parameters
56
  const prompts: string[] = [];
57
  const params = body.params || {};
 
 
 
58
 
59
  // Background modifications
60
  if (params.backgroundType) {
@@ -64,7 +83,9 @@ export async function POST(req: NextRequest) {
64
  } else if (bgType === "image") {
65
  prompts.push(`Change the background to ${params.backgroundImage || "a beautiful beach scene"}.`);
66
  } else if (bgType === "upload" && params.customBackgroundImage) {
67
- prompts.push(`Replace the background with the uploaded custom background image, ensuring proper lighting and perspective matching.`);
 
 
68
  } else if (params.customPrompt) {
69
  prompts.push(params.customPrompt);
70
  }
@@ -72,21 +93,23 @@ export async function POST(req: NextRequest) {
72
 
73
  // Clothes modifications
74
  if (params.clothesImage) {
75
- // If clothesImage is provided, we need to handle it differently
76
- // For now, we'll create a descriptive prompt
77
  if (params.selectedPreset === "Sukajan") {
78
- prompts.push("Change the person's clothes to a Japanese sukajan jacket with embroidered designs.");
79
  } else if (params.selectedPreset === "Blazer") {
80
- prompts.push("Change the person's clothes to a professional blazer.");
81
- } else if (params.clothesImage.startsWith('data:') || params.clothesImage.startsWith('http')) {
82
- prompts.push("Change the person's clothes to match the provided reference image style.");
83
  }
 
 
84
  }
85
 
86
  // Style blending
87
  if (params.styleImage) {
88
  const strength = params.blendStrength || 50;
89
- prompts.push(`Apply artistic style blending at ${strength}% strength.`);
 
 
90
  }
91
 
92
  // Edit prompt
@@ -96,7 +119,7 @@ export async function POST(req: NextRequest) {
96
 
97
  // Camera settings
98
  if (params.focalLength || params.aperture || params.shutterSpeed || params.whiteBalance || params.angle ||
99
- params.iso || params.filmStyle || params.lighting || params.bokeh || params.composition) {
100
  const cameraSettings: string[] = [];
101
  if (params.focalLength) {
102
  if (params.focalLength === "8mm fisheye") {
@@ -114,6 +137,7 @@ export async function POST(req: NextRequest) {
114
  if (params.lighting) cameraSettings.push(`Lighting: ${params.lighting}`);
115
  if (params.bokeh) cameraSettings.push(`Bokeh effect: ${params.bokeh}`);
116
  if (params.composition) cameraSettings.push(`Composition: ${params.composition}`);
 
117
 
118
  if (cameraSettings.length > 0) {
119
  prompts.push(`Apply professional photography settings: ${cameraSettings.join(", ")}`);
@@ -154,7 +178,10 @@ export async function POST(req: NextRequest) {
154
  // Generate with Gemini
155
  const parts = [
156
  { text: prompt },
157
- { inlineData: { mimeType: parsed.mimeType, data: parsed.data } }
 
 
 
158
  ];
159
 
160
  const response = await ai.models.generateContent({
 
28
 
29
  const ai = new GoogleGenAI({ apiKey });
30
 
31
+ // Helpers
32
+ const toInlineDataFromAny = async (url: string): Promise<{ mimeType: string; data: string } | null> => {
33
+ if (!url) return null;
34
+ try {
35
+ if (url.startsWith('data:')) {
36
+ return parseDataUrl(url);
37
+ }
38
+ if (url.startsWith('http')) {
39
+ const res = await fetch(url);
40
+ const buf = await res.arrayBuffer();
41
+ const base64 = Buffer.from(buf).toString('base64');
42
+ const mimeType = res.headers.get('content-type') || 'image/jpeg';
43
+ return { mimeType, data: base64 };
 
 
 
44
  }
45
+ if (url.startsWith('/')) {
46
+ const host = req.headers.get('host') ?? 'localhost:3000';
47
+ const proto = req.headers.get('x-forwarded-proto') ?? 'http';
48
+ const absolute = `${proto}://${host}${url}`;
49
+ const res = await fetch(absolute);
50
+ const buf = await res.arrayBuffer();
51
+ const base64 = Buffer.from(buf).toString('base64');
52
+ const mimeType = res.headers.get('content-type') || 'image/png';
53
+ return { mimeType, data: base64 };
54
+ }
55
+ return null;
56
+ } catch {
57
+ return null;
58
  }
59
+ };
60
+
61
+ // Parse input image
62
+ let parsed = null as null | { mimeType: string; data: string };
63
+ if (body.image) {
64
+ parsed = await toInlineDataFromAny(body.image);
65
  }
66
 
67
  if (!parsed) {
 
71
  // Build combined prompt from all accumulated parameters
72
  const prompts: string[] = [];
73
  const params = body.params || {};
74
+
75
+ // We'll collect additional inline image parts (references)
76
+ const referenceParts: { inlineData: { mimeType: string; data: string } }[] = [];
77
 
78
  // Background modifications
79
  if (params.backgroundType) {
 
83
  } else if (bgType === "image") {
84
  prompts.push(`Change the background to ${params.backgroundImage || "a beautiful beach scene"}.`);
85
  } else if (bgType === "upload" && params.customBackgroundImage) {
86
+ prompts.push(`Replace the background using the provided custom background reference image (attached below). Ensure perspective and lighting match.`);
87
+ const bgRef = await toInlineDataFromAny(params.customBackgroundImage);
88
+ if (bgRef) referenceParts.push({ inlineData: bgRef });
89
  } else if (params.customPrompt) {
90
  prompts.push(params.customPrompt);
91
  }
 
93
 
94
  // Clothes modifications
95
  if (params.clothesImage) {
 
 
96
  if (params.selectedPreset === "Sukajan") {
97
+ prompts.push("Replace the person's clothing with a Japanese sukajan jacket (embroidered designs). Use the clothes reference image if provided.");
98
  } else if (params.selectedPreset === "Blazer") {
99
+ prompts.push("Replace the person's clothing with a professional blazer. Use the clothes reference image if provided.");
100
+ } else {
101
+ prompts.push("Replace the person's clothing to match the provided clothes reference image (attached below). Preserve body pose and identity.");
102
  }
103
+ const clothesRef = await toInlineDataFromAny(params.clothesImage);
104
+ if (clothesRef) referenceParts.push({ inlineData: clothesRef });
105
  }
106
 
107
  // Style blending
108
  if (params.styleImage) {
109
  const strength = params.blendStrength || 50;
110
+ prompts.push(`Apply artistic style blending using the provided style reference image (attached below) at ${strength}% strength.`);
111
+ const styleRef = await toInlineDataFromAny(params.styleImage);
112
+ if (styleRef) referenceParts.push({ inlineData: styleRef });
113
  }
114
 
115
  // Edit prompt
 
119
 
120
  // Camera settings
121
  if (params.focalLength || params.aperture || params.shutterSpeed || params.whiteBalance || params.angle ||
122
+ params.iso || params.filmStyle || params.lighting || params.bokeh || params.composition || params.aspectRatio) {
123
  const cameraSettings: string[] = [];
124
  if (params.focalLength) {
125
  if (params.focalLength === "8mm fisheye") {
 
137
  if (params.lighting) cameraSettings.push(`Lighting: ${params.lighting}`);
138
  if (params.bokeh) cameraSettings.push(`Bokeh effect: ${params.bokeh}`);
139
  if (params.composition) cameraSettings.push(`Composition: ${params.composition}`);
140
+ if (params.aspectRatio) cameraSettings.push(`Aspect Ratio: ${params.aspectRatio}`);
141
 
142
  if (cameraSettings.length > 0) {
143
  prompts.push(`Apply professional photography settings: ${cameraSettings.join(", ")}`);
 
178
  // Generate with Gemini
179
  const parts = [
180
  { text: prompt },
181
+ // Primary subject image (input)
182
+ { inlineData: { mimeType: parsed.mimeType, data: parsed.data } },
183
+ // Additional reference images to guide modifications
184
+ ...referenceParts,
185
  ];
186
 
187
  const response = await ai.models.generateContent({
app/editor/nodes.tsx CHANGED
@@ -1,6 +1,13 @@
1
  "use client";
2
 
3
  import React, { useState, useRef, useEffect } from "react";
 
 
 
 
 
 
 
4
 
5
  // Helper function to download image
6
  function downloadImage(dataUrl: string, filename: string) {
@@ -170,36 +177,44 @@ export function BackgroundNodeView({
170
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
171
  <div className="font-semibold text-sm flex-1 text-center">BACKGROUND</div>
172
  <div className="flex items-center gap-2">
173
- <button className="text-2xl text-red-400 opacity-50 hover:opacity-100" onClick={() => onDelete(node.id)}>×</button>
 
 
 
 
 
 
 
 
 
174
  <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
175
  </div>
176
  </div>
177
  <div className="p-3 space-y-3">
178
- <select
179
- className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
180
  value={node.backgroundType || "color"}
181
- onChange={(e) => onUpdate(node.id, { backgroundType: e.target.value })}
182
  >
183
  <option value="color">Solid Color</option>
184
  <option value="image">Preset Background</option>
185
  <option value="upload">Upload Image</option>
186
  <option value="custom">Custom Prompt</option>
187
- </select>
188
 
189
  {node.backgroundType === "color" && (
190
- <input
191
- type="color"
192
- className="w-full h-10 rounded"
193
  value={node.backgroundColor || "#ffffff"}
194
- onChange={(e) => onUpdate(node.id, { backgroundColor: e.target.value })}
195
  />
196
  )}
197
 
198
  {node.backgroundType === "image" && (
199
- <select
200
- className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
201
  value={node.backgroundImage || ""}
202
- onChange={(e) => onUpdate(node.id, { backgroundImage: e.target.value })}
203
  >
204
  <option value="">Select Background</option>
205
  <option value="beach">Beach</option>
@@ -207,7 +222,7 @@ export function BackgroundNodeView({
207
  <option value="studio">Studio</option>
208
  <option value="nature">Nature</option>
209
  <option value="city">City Skyline</option>
210
- </select>
211
  )}
212
 
213
  {node.backgroundType === "upload" && (
@@ -215,12 +230,14 @@ export function BackgroundNodeView({
215
  {node.customBackgroundImage ? (
216
  <div className="relative">
217
  <img src={node.customBackgroundImage} className="w-full rounded" alt="Custom Background" />
218
- <button
219
- className="absolute top-2 right-2 bg-red-500/80 text-white text-xs px-2 py-1 rounded"
 
 
220
  onClick={() => onUpdate(node.id, { customBackgroundImage: null })}
221
  >
222
  Remove
223
- </button>
224
  </div>
225
  ) : (
226
  <label className="block">
@@ -240,33 +257,34 @@ export function BackgroundNodeView({
240
  )}
241
 
242
  {node.backgroundType === "custom" && (
243
- <textarea
244
- className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
245
  placeholder="Describe the background..."
246
  value={node.customPrompt || ""}
247
- onChange={(e) => onUpdate(node.id, { customPrompt: e.target.value })}
248
  rows={2}
249
  />
250
  )}
251
 
252
- <button
253
- className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1 transition-all"
254
  onClick={() => onProcess(node.id)}
255
  disabled={node.isRunning}
256
  title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
257
  >
258
  {node.isRunning ? "Processing..." : "Apply Background"}
259
- </button>
260
 
261
  {node.output && (
262
  <div className="space-y-2">
263
  <img src={node.output} className="w-full rounded" alt="Output" />
264
- <button
265
- className="w-full text-xs bg-green-600 hover:bg-green-500 rounded px-3 py-1 transition-all"
 
266
  onClick={() => downloadImage(node.output, `background-${Date.now()}.png`)}
267
  >
268
  📥 Download Output
269
- </button>
270
  </div>
271
  )}
272
  {node.error && (
@@ -337,11 +355,32 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
337
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
338
  <div className="font-semibold text-sm flex-1 text-center">CLOTHES</div>
339
  <div className="flex items-center gap-2">
340
- <button className="text-2xl text-red-400 opacity-50 hover:opacity-100" onClick={() => onDelete(node.id)}>×</button>
 
 
 
 
 
 
 
 
 
341
  <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
342
  </div>
343
  </div>
344
  <div className="p-3 space-y-3">
 
 
 
 
 
 
 
 
 
 
 
 
345
  {hasConfig && (
346
  <div className="text-xs bg-yellow-500/20 border border-yellow-500/50 rounded px-2 py-1 text-yellow-300">
347
  ⚡ Config pending - will apply when downstream node processes
@@ -373,12 +412,14 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
373
  {node.clothesImage && !node.selectedPreset ? (
374
  <div className="relative">
375
  <img src={node.clothesImage} className="w-full rounded" alt="Clothes" />
376
- <button
377
- className="absolute top-2 right-2 bg-red-500/80 text-white text-xs px-2 py-1 rounded"
 
 
378
  onClick={() => onUpdate(node.id, { clothesImage: null, selectedPreset: null })}
379
  >
380
  Remove
381
- </button>
382
  </div>
383
  ) : !node.selectedPreset ? (
384
  <label className="block">
@@ -400,23 +441,24 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
400
  </label>
401
  ) : null}
402
 
403
- <button
404
- className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1 transition-all"
405
  onClick={() => onProcess(node.id)}
406
  disabled={node.isRunning || !node.clothesImage}
407
  title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
408
  >
409
  {node.isRunning ? "Processing..." : "Apply Clothes"}
410
- </button>
411
  {node.output && (
412
  <div className="space-y-2">
413
  <img src={node.output} className="w-full rounded" alt="Output" />
414
- <button
415
- className="w-full text-xs bg-green-600 hover:bg-green-500 rounded px-3 py-1 transition-all"
 
416
  onClick={() => downloadImage(node.output, `clothes-${Date.now()}.png`)}
417
  >
418
  📥 Download Output
419
- </button>
420
  </div>
421
  )}
422
  {node.error && (
@@ -442,42 +484,60 @@ export function AgeNodeView({ node, onDelete, onUpdate, onStartConnection, onEnd
442
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
443
  <div className="font-semibold text-sm flex-1 text-center">AGE</div>
444
  <div className="flex items-center gap-2">
445
- <button className="text-2xl text-red-400 opacity-50 hover:opacity-100" onClick={() => onDelete(node.id)}>×</button>
 
 
 
 
 
 
 
 
 
446
  <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
447
  </div>
448
  </div>
449
  <div className="p-3 space-y-3">
 
 
 
 
 
 
 
 
 
 
 
 
450
  <div>
451
- <label className="flex items-center justify-between text-xs text-white/70 mb-1">
452
- <span>Target Age</span>
453
- <span>{node.targetAge || 30} years</span>
454
- </label>
455
- <input
456
- type="range"
457
  min={18}
458
  max={100}
459
  value={node.targetAge || 30}
460
- onChange={(e) => onUpdate(node.id, { targetAge: parseInt(e.target.value) })}
461
- className="w-full"
462
  />
463
  </div>
464
- <button
465
- className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1 transition-all"
466
  onClick={() => onProcess(node.id)}
467
  disabled={node.isRunning}
468
  title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
469
  >
470
  {node.isRunning ? "Processing..." : "Apply Age"}
471
- </button>
472
  {node.output && (
473
  <div className="space-y-2">
474
  <img src={node.output} className="w-full rounded" alt="Output" />
475
- <button
476
- className="w-full text-xs bg-green-600 hover:bg-green-500 rounded px-3 py-1 transition-all"
 
477
  onClick={() => downloadImage(node.output, `age-${Date.now()}.png`)}
478
  >
479
  📥 Download Output
480
- </button>
481
  </div>
482
  )}
483
  {node.error && (
@@ -500,6 +560,7 @@ export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, on
500
  const lightingTypes = ["None", "Natural Light", "Golden Hour", "Blue Hour", "Studio Lighting", "Rembrandt", "Split Lighting", "Butterfly Lighting", "Loop Lighting", "Rim Lighting", "Silhouette", "High Key", "Low Key"];
501
  const bokehStyles = ["None", "Smooth Bokeh", "Swirly Bokeh", "Hexagonal Bokeh", "Cat Eye Bokeh", "Bubble Bokeh", "Creamy Bokeh"];
502
  const compositions = ["None", "Rule of Thirds", "Golden Ratio", "Symmetrical", "Leading Lines", "Frame in Frame", "Fill the Frame", "Negative Space", "Patterns", "Diagonal"];
 
503
 
504
  return (
505
  <div className="nb-node absolute text-white w-[360px]" style={{ left: localPos.x, top: localPos.y }}>
@@ -512,53 +573,74 @@ export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, on
512
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
513
  <div className="font-semibold text-sm flex-1 text-center">CAMERA</div>
514
  <div className="flex items-center gap-2">
515
- <button className="text-2xl text-red-400 opacity-50 hover:opacity-100" onClick={() => onDelete(node.id)}>×</button>
 
 
 
 
 
 
 
 
 
516
  <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
517
  </div>
518
  </div>
519
- <div className="p-3 space-y-2 max-h-[500px] overflow-y-auto">
 
 
 
 
 
 
 
 
 
 
 
 
520
  {/* Basic Camera Settings */}
521
  <div className="text-xs text-white/50 font-semibold mb-1">Basic Settings</div>
522
  <div className="grid grid-cols-2 gap-2">
523
  <div>
524
  <label className="text-xs text-white/70">Focal Length</label>
525
- <select
526
- className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
527
  value={node.focalLength || "None"}
528
- onChange={(e) => onUpdate(node.id, { focalLength: e.target.value })}
529
  >
530
  {focalLengths.map(f => <option key={f} value={f}>{f}</option>)}
531
- </select>
532
  </div>
533
  <div>
534
  <label className="text-xs text-white/70">Aperture</label>
535
- <select
536
- className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
537
  value={node.aperture || "None"}
538
- onChange={(e) => onUpdate(node.id, { aperture: e.target.value })}
539
  >
540
  {apertures.map(a => <option key={a} value={a}>{a}</option>)}
541
- </select>
542
  </div>
543
  <div>
544
  <label className="text-xs text-white/70">Shutter Speed</label>
545
- <select
546
- className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
547
  value={node.shutterSpeed || "None"}
548
- onChange={(e) => onUpdate(node.id, { shutterSpeed: e.target.value })}
549
  >
550
  {shutterSpeeds.map(s => <option key={s} value={s}>{s}</option>)}
551
- </select>
552
  </div>
553
  <div>
554
  <label className="text-xs text-white/70">ISO</label>
555
- <select
556
- className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
557
  value={node.iso || "None"}
558
- onChange={(e) => onUpdate(node.id, { iso: e.target.value })}
559
  >
560
  {isoValues.map(i => <option key={i} value={i}>{i}</option>)}
561
- </select>
562
  </div>
563
  </div>
564
 
@@ -567,43 +649,43 @@ export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, on
567
  <div className="grid grid-cols-2 gap-2">
568
  <div>
569
  <label className="text-xs text-white/70">White Balance</label>
570
- <select
571
- className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
572
  value={node.whiteBalance || "None"}
573
- onChange={(e) => onUpdate(node.id, { whiteBalance: e.target.value })}
574
  >
575
  {whiteBalances.map(w => <option key={w} value={w}>{w}</option>)}
576
- </select>
577
  </div>
578
  <div>
579
  <label className="text-xs text-white/70">Film Style</label>
580
- <select
581
- className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
582
  value={node.filmStyle || "None"}
583
- onChange={(e) => onUpdate(node.id, { filmStyle: e.target.value })}
584
  >
585
  {filmStyles.map(f => <option key={f} value={f}>{f}</option>)}
586
- </select>
587
  </div>
588
  <div>
589
  <label className="text-xs text-white/70">Lighting</label>
590
- <select
591
- className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
592
  value={node.lighting || "None"}
593
- onChange={(e) => onUpdate(node.id, { lighting: e.target.value })}
594
  >
595
  {lightingTypes.map(l => <option key={l} value={l}>{l}</option>)}
596
- </select>
597
  </div>
598
  <div>
599
  <label className="text-xs text-white/70">Bokeh Style</label>
600
- <select
601
- className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
602
  value={node.bokeh || "None"}
603
- onChange={(e) => onUpdate(node.id, { bokeh: e.target.value })}
604
  >
605
  {bokehStyles.map(b => <option key={b} value={b}>{b}</option>)}
606
- </select>
607
  </div>
608
  </div>
609
 
@@ -612,42 +694,53 @@ export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, on
612
  <div className="grid grid-cols-2 gap-2">
613
  <div>
614
  <label className="text-xs text-white/70">Camera Angle</label>
615
- <select
616
- className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
617
  value={node.angle || "None"}
618
- onChange={(e) => onUpdate(node.id, { angle: e.target.value })}
619
  >
620
  {angles.map(a => <option key={a} value={a}>{a}</option>)}
621
- </select>
622
  </div>
623
  <div>
624
  <label className="text-xs text-white/70">Composition</label>
625
- <select
626
- className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
627
  value={node.composition || "None"}
628
- onChange={(e) => onUpdate(node.id, { composition: e.target.value })}
629
  >
630
  {compositions.map(c => <option key={c} value={c}>{c}</option>)}
631
- </select>
 
 
 
 
 
 
 
 
 
 
632
  </div>
633
  </div>
634
- <button
635
- className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1 transition-all"
636
  onClick={() => onProcess(node.id)}
637
  disabled={node.isRunning}
638
  title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
639
  >
640
  {node.isRunning ? "Processing..." : "Apply Camera Settings"}
641
- </button>
642
  {node.output && (
643
  <div className="space-y-2 mt-2">
644
  <img src={node.output} className="w-full rounded" alt="Output" />
645
- <button
646
- className="w-full text-xs bg-green-600 hover:bg-green-500 rounded px-3 py-1 transition-all"
 
647
  onClick={() => downloadImage(node.output, `camera-${Date.now()}.png`)}
648
  >
649
  📥 Download Output
650
- </button>
651
  </div>
652
  )}
653
  {node.error && (
@@ -675,38 +768,56 @@ export function FaceNodeView({ node, onDelete, onUpdate, onStartConnection, onEn
675
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
676
  <div className="font-semibold text-sm flex-1 text-center">FACE</div>
677
  <div className="flex items-center gap-2">
678
- <button className="text-2xl text-red-400 opacity-50 hover:opacity-100" onClick={() => onDelete(node.id)}>×</button>
 
 
 
 
 
 
 
 
 
679
  <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
680
  </div>
681
  </div>
682
  <div className="p-3 space-y-2">
 
 
 
 
 
 
 
 
 
 
 
 
683
  <div className="space-y-2">
684
  <label className="flex items-center gap-2 text-xs">
685
- <input
686
- type="checkbox"
687
  checked={node.faceOptions?.removePimples || false}
688
  onChange={(e) => onUpdate(node.id, {
689
- faceOptions: { ...node.faceOptions, removePimples: e.target.checked }
690
  })}
691
  />
692
  Remove pimples
693
  </label>
694
  <label className="flex items-center gap-2 text-xs">
695
- <input
696
- type="checkbox"
697
  checked={node.faceOptions?.addSunglasses || false}
698
  onChange={(e) => onUpdate(node.id, {
699
- faceOptions: { ...node.faceOptions, addSunglasses: e.target.checked }
700
  })}
701
  />
702
  Add sunglasses
703
  </label>
704
  <label className="flex items-center gap-2 text-xs">
705
- <input
706
- type="checkbox"
707
  checked={node.faceOptions?.addHat || false}
708
  onChange={(e) => onUpdate(node.id, {
709
- faceOptions: { ...node.faceOptions, addHat: e.target.checked }
710
  })}
711
  />
712
  Add hat
@@ -715,60 +826,61 @@ export function FaceNodeView({ node, onDelete, onUpdate, onStartConnection, onEn
715
 
716
  <div>
717
  <label className="text-xs text-white/70">Hairstyle</label>
718
- <select
719
- className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
720
  value={node.faceOptions?.changeHairstyle || "None"}
721
  onChange={(e) => onUpdate(node.id, {
722
- faceOptions: { ...node.faceOptions, changeHairstyle: e.target.value }
723
  })}
724
  >
725
  {hairstyles.map(h => <option key={h} value={h}>{h}</option>)}
726
- </select>
727
  </div>
728
 
729
  <div>
730
  <label className="text-xs text-white/70">Expression</label>
731
- <select
732
- className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
733
  value={node.faceOptions?.facialExpression || "None"}
734
  onChange={(e) => onUpdate(node.id, {
735
- faceOptions: { ...node.faceOptions, facialExpression: e.target.value }
736
  })}
737
  >
738
  {expressions.map(e => <option key={e} value={e}>{e}</option>)}
739
- </select>
740
  </div>
741
 
742
  <div>
743
  <label className="text-xs text-white/70">Beard</label>
744
- <select
745
- className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
746
  value={node.faceOptions?.beardStyle || "None"}
747
  onChange={(e) => onUpdate(node.id, {
748
- faceOptions: { ...node.faceOptions, beardStyle: e.target.value }
749
  })}
750
  >
751
  {beardStyles.map(b => <option key={b} value={b}>{b}</option>)}
752
- </select>
753
  </div>
754
 
755
- <button
756
- className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1 transition-all"
757
  onClick={() => onProcess(node.id)}
758
  disabled={node.isRunning}
759
  title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
760
  >
761
  {node.isRunning ? "Processing..." : "Apply Face Changes"}
762
- </button>
763
  {node.output && (
764
  <div className="space-y-2 mt-2">
765
  <img src={node.output} className="w-full rounded" alt="Output" />
766
- <button
767
- className="w-full text-xs bg-green-600 hover:bg-green-500 rounded px-3 py-1 transition-all"
 
768
  onClick={() => downloadImage(node.output, `face-${Date.now()}.png`)}
769
  >
770
  📥 Download Output
771
- </button>
772
  </div>
773
  )}
774
  {node.error && (
@@ -828,12 +940,34 @@ export function BlendNodeView({ node, onDelete, onUpdate, onStartConnection, onE
828
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
829
  <div className="font-semibold text-sm flex-1 text-center">BLEND</div>
830
  <div className="flex items-center gap-2">
831
- <button className="text-2xl text-red-400 opacity-50 hover:opacity-100" onClick={() => onDelete(node.id)}>×</button>
 
 
 
 
 
 
 
 
 
832
  <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
833
  </div>
834
  </div>
835
  <div className="p-3 space-y-3">
 
 
 
 
 
 
 
 
 
 
 
 
836
  <div className="text-xs text-white/70">Style Reference Image</div>
 
837
  {node.styleImage ? (
838
  <div className="relative">
839
  <img src={node.styleImage} className="w-full rounded" alt="Style" />
@@ -860,40 +994,38 @@ export function BlendNodeView({ node, onDelete, onUpdate, onStartConnection, onE
860
  />
861
  <div className="border-2 border-dashed border-white/20 rounded-lg p-4 text-center cursor-pointer hover:border-white/40">
862
  <p className="text-xs text-white/60">Drop, upload, or paste style image</p>
 
863
  </div>
864
  </label>
865
  )}
866
  <div>
867
- <label className="flex items-center justify-between text-xs text-white/70 mb-1">
868
- <span>Blend Strength</span>
869
- <span>{node.blendStrength || 50}%</span>
870
- </label>
871
- <input
872
- type="range"
873
  min={0}
874
  max={100}
875
  value={node.blendStrength || 50}
876
- onChange={(e) => onUpdate(node.id, { blendStrength: parseInt(e.target.value) })}
877
- className="w-full"
878
  />
879
  </div>
880
- <button
881
- className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1 transition-all"
882
  onClick={() => onProcess(node.id)}
883
  disabled={node.isRunning || !node.styleImage}
884
- title={!node.input ? "Connect an input first" : !node.styleImage ? "Add a style image first" : "Process all unprocessed nodes in chain"}
885
  >
886
- {node.isRunning ? "Processing..." : "Apply Style"}
887
- </button>
888
  {node.output && (
889
  <div className="space-y-2">
890
  <img src={node.output} className="w-full rounded" alt="Output" />
891
- <button
892
- className="w-full text-xs bg-green-600 hover:bg-green-500 rounded px-3 py-1 transition-all"
 
893
  onClick={() => downloadImage(node.output, `blend-${Date.now()}.png`)}
894
  >
895
  📥 Download Output
896
- </button>
897
  </div>
898
  )}
899
  {node.error && (
@@ -918,35 +1050,57 @@ export function EditNodeView({ node, onDelete, onUpdate, onStartConnection, onEn
918
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
919
  <div className="font-semibold text-sm flex-1 text-center">EDIT</div>
920
  <div className="flex items-center gap-2">
921
- <button className="text-2xl text-red-400 opacity-50 hover:opacity-100" onClick={() => onDelete(node.id)}>×</button>
 
 
 
 
 
 
 
 
 
922
  <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
923
  </div>
924
  </div>
925
  <div className="p-3 space-y-3">
926
- <textarea
927
- className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
 
 
 
 
 
 
 
 
 
 
 
 
928
  placeholder="Describe what to edit (e.g., 'make it brighter', 'add more contrast', 'make it look vintage')"
929
  value={node.editPrompt || ""}
930
- onChange={(e) => onUpdate(node.id, { editPrompt: e.target.value })}
931
  rows={3}
932
  />
933
- <button
934
- className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1 transition-all"
935
  onClick={() => onProcess(node.id)}
936
  disabled={node.isRunning}
937
  title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
938
  >
939
  {node.isRunning ? "Processing..." : "Apply Edit"}
940
- </button>
941
  {node.output && (
942
  <div className="space-y-2">
943
  <img src={node.output} className="w-full rounded" alt="Output" />
944
- <button
945
- className="w-full text-xs bg-green-600 hover:bg-green-500 rounded px-3 py-1 transition-all"
 
946
  onClick={() => downloadImage(node.output, `edit-${Date.now()}.png`)}
947
  >
948
  📥 Download Output
949
- </button>
950
  </div>
951
  )}
952
  </div>
 
1
  "use client";
2
 
3
  import React, { useState, useRef, useEffect } from "react";
4
+ import { Button } from "../../components/ui/button";
5
+ import { Select } from "../../components/ui/select";
6
+ import { Textarea } from "../../components/ui/textarea";
7
+ import { Label } from "../../components/ui/label";
8
+ import { Slider } from "../../components/ui/slider";
9
+ import { ColorPicker } from "../../components/ui/color-picker";
10
+ import { Checkbox } from "../../components/ui/checkbox";
11
 
12
  // Helper function to download image
13
  function downloadImage(dataUrl: string, filename: string) {
 
177
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
178
  <div className="font-semibold text-sm flex-1 text-center">BACKGROUND</div>
179
  <div className="flex items-center gap-2">
180
+ <Button
181
+ variant="ghost"
182
+ size="icon"
183
+ className="text-destructive"
184
+ onClick={() => onDelete(node.id)}
185
+ title="Delete node"
186
+ aria-label="Delete node"
187
+ >
188
+ ×
189
+ </Button>
190
  <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
191
  </div>
192
  </div>
193
  <div className="p-3 space-y-3">
194
+ <Select
195
+ className="w-full"
196
  value={node.backgroundType || "color"}
197
+ onChange={(e) => onUpdate(node.id, { backgroundType: (e.target as HTMLSelectElement).value })}
198
  >
199
  <option value="color">Solid Color</option>
200
  <option value="image">Preset Background</option>
201
  <option value="upload">Upload Image</option>
202
  <option value="custom">Custom Prompt</option>
203
+ </Select>
204
 
205
  {node.backgroundType === "color" && (
206
+ <ColorPicker
207
+ className="w-full"
 
208
  value={node.backgroundColor || "#ffffff"}
209
+ onChange={(e) => onUpdate(node.id, { backgroundColor: (e.target as HTMLInputElement).value })}
210
  />
211
  )}
212
 
213
  {node.backgroundType === "image" && (
214
+ <Select
215
+ className="w-full"
216
  value={node.backgroundImage || ""}
217
+ onChange={(e) => onUpdate(node.id, { backgroundImage: (e.target as HTMLSelectElement).value })}
218
  >
219
  <option value="">Select Background</option>
220
  <option value="beach">Beach</option>
 
222
  <option value="studio">Studio</option>
223
  <option value="nature">Nature</option>
224
  <option value="city">City Skyline</option>
225
+ </Select>
226
  )}
227
 
228
  {node.backgroundType === "upload" && (
 
230
  {node.customBackgroundImage ? (
231
  <div className="relative">
232
  <img src={node.customBackgroundImage} className="w-full rounded" alt="Custom Background" />
233
+ <Button
234
+ variant="destructive"
235
+ size="sm"
236
+ className="absolute top-2 right-2"
237
  onClick={() => onUpdate(node.id, { customBackgroundImage: null })}
238
  >
239
  Remove
240
+ </Button>
241
  </div>
242
  ) : (
243
  <label className="block">
 
257
  )}
258
 
259
  {node.backgroundType === "custom" && (
260
+ <Textarea
261
+ className="w-full"
262
  placeholder="Describe the background..."
263
  value={node.customPrompt || ""}
264
+ onChange={(e) => onUpdate(node.id, { customPrompt: (e.target as HTMLTextAreaElement).value })}
265
  rows={2}
266
  />
267
  )}
268
 
269
+ <Button
270
+ className="w-full"
271
  onClick={() => onProcess(node.id)}
272
  disabled={node.isRunning}
273
  title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
274
  >
275
  {node.isRunning ? "Processing..." : "Apply Background"}
276
+ </Button>
277
 
278
  {node.output && (
279
  <div className="space-y-2">
280
  <img src={node.output} className="w-full rounded" alt="Output" />
281
+ <Button
282
+ className="w-full"
283
+ variant="secondary"
284
  onClick={() => downloadImage(node.output, `background-${Date.now()}.png`)}
285
  >
286
  📥 Download Output
287
+ </Button>
288
  </div>
289
  )}
290
  {node.error && (
 
355
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
356
  <div className="font-semibold text-sm flex-1 text-center">CLOTHES</div>
357
  <div className="flex items-center gap-2">
358
+ <Button
359
+ variant="ghost"
360
+ size="icon"
361
+ className="text-destructive"
362
+ onClick={() => onDelete(node.id)}
363
+ title="Delete node"
364
+ aria-label="Delete node"
365
+ >
366
+ ×
367
+ </Button>
368
  <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
369
  </div>
370
  </div>
371
  <div className="p-3 space-y-3">
372
+ {node.input && (
373
+ <div className="flex justify-end mb-2">
374
+ <Button
375
+ variant="ghost"
376
+ size="sm"
377
+ onClick={() => onUpdate(node.id, { input: undefined })}
378
+ className="text-xs"
379
+ >
380
+ Clear Connection
381
+ </Button>
382
+ </div>
383
+ )}
384
  {hasConfig && (
385
  <div className="text-xs bg-yellow-500/20 border border-yellow-500/50 rounded px-2 py-1 text-yellow-300">
386
  ⚡ Config pending - will apply when downstream node processes
 
412
  {node.clothesImage && !node.selectedPreset ? (
413
  <div className="relative">
414
  <img src={node.clothesImage} className="w-full rounded" alt="Clothes" />
415
+ <Button
416
+ variant="destructive"
417
+ size="sm"
418
+ className="absolute top-2 right-2"
419
  onClick={() => onUpdate(node.id, { clothesImage: null, selectedPreset: null })}
420
  >
421
  Remove
422
+ </Button>
423
  </div>
424
  ) : !node.selectedPreset ? (
425
  <label className="block">
 
441
  </label>
442
  ) : null}
443
 
444
+ <Button
445
+ className="w-full"
446
  onClick={() => onProcess(node.id)}
447
  disabled={node.isRunning || !node.clothesImage}
448
  title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
449
  >
450
  {node.isRunning ? "Processing..." : "Apply Clothes"}
451
+ </Button>
452
  {node.output && (
453
  <div className="space-y-2">
454
  <img src={node.output} className="w-full rounded" alt="Output" />
455
+ <Button
456
+ className="w-full"
457
+ variant="secondary"
458
  onClick={() => downloadImage(node.output, `clothes-${Date.now()}.png`)}
459
  >
460
  📥 Download Output
461
+ </Button>
462
  </div>
463
  )}
464
  {node.error && (
 
484
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
485
  <div className="font-semibold text-sm flex-1 text-center">AGE</div>
486
  <div className="flex items-center gap-2">
487
+ <Button
488
+ variant="ghost"
489
+ size="icon"
490
+ className="text-destructive"
491
+ onClick={() => onDelete(node.id)}
492
+ title="Delete node"
493
+ aria-label="Delete node"
494
+ >
495
+ ×
496
+ </Button>
497
  <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
498
  </div>
499
  </div>
500
  <div className="p-3 space-y-3">
501
+ {node.input && (
502
+ <div className="flex justify-end mb-2">
503
+ <Button
504
+ variant="ghost"
505
+ size="sm"
506
+ onClick={() => onUpdate(node.id, { input: undefined })}
507
+ className="text-xs"
508
+ >
509
+ Clear Connection
510
+ </Button>
511
+ </div>
512
+ )}
513
  <div>
514
+ <Slider
515
+ label="Target Age"
516
+ valueLabel={`${node.targetAge || 30} years`}
 
 
 
517
  min={18}
518
  max={100}
519
  value={node.targetAge || 30}
520
+ onChange={(e) => onUpdate(node.id, { targetAge: parseInt((e.target as HTMLInputElement).value) })}
 
521
  />
522
  </div>
523
+ <Button
524
+ className="w-full"
525
  onClick={() => onProcess(node.id)}
526
  disabled={node.isRunning}
527
  title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
528
  >
529
  {node.isRunning ? "Processing..." : "Apply Age"}
530
+ </Button>
531
  {node.output && (
532
  <div className="space-y-2">
533
  <img src={node.output} className="w-full rounded" alt="Output" />
534
+ <Button
535
+ className="w-full"
536
+ variant="secondary"
537
  onClick={() => downloadImage(node.output, `age-${Date.now()}.png`)}
538
  >
539
  📥 Download Output
540
+ </Button>
541
  </div>
542
  )}
543
  {node.error && (
 
560
  const lightingTypes = ["None", "Natural Light", "Golden Hour", "Blue Hour", "Studio Lighting", "Rembrandt", "Split Lighting", "Butterfly Lighting", "Loop Lighting", "Rim Lighting", "Silhouette", "High Key", "Low Key"];
561
  const bokehStyles = ["None", "Smooth Bokeh", "Swirly Bokeh", "Hexagonal Bokeh", "Cat Eye Bokeh", "Bubble Bokeh", "Creamy Bokeh"];
562
  const compositions = ["None", "Rule of Thirds", "Golden Ratio", "Symmetrical", "Leading Lines", "Frame in Frame", "Fill the Frame", "Negative Space", "Patterns", "Diagonal"];
563
+ const aspectRatios = ["None", "1:1 Square", "3:2 Standard", "4:3 Classic", "16:9 Widescreen", "21:9 Cinematic", "9:16 Portrait", "4:5 Instagram", "2:3 Portrait"];
564
 
565
  return (
566
  <div className="nb-node absolute text-white w-[360px]" style={{ left: localPos.x, top: localPos.y }}>
 
573
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
574
  <div className="font-semibold text-sm flex-1 text-center">CAMERA</div>
575
  <div className="flex items-center gap-2">
576
+ <Button
577
+ variant="ghost"
578
+ size="icon"
579
+ className="text-destructive"
580
+ onClick={() => onDelete(node.id)}
581
+ title="Delete node"
582
+ aria-label="Delete node"
583
+ >
584
+ ×
585
+ </Button>
586
  <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
587
  </div>
588
  </div>
589
+ <div className="p-3 space-y-2 max-h-[500px] overflow-y-auto scrollbar-thin">
590
+ {node.input && (
591
+ <div className="flex justify-end mb-2">
592
+ <Button
593
+ variant="ghost"
594
+ size="sm"
595
+ onClick={() => onUpdate(node.id, { input: undefined })}
596
+ className="text-xs"
597
+ >
598
+ Clear Connection
599
+ </Button>
600
+ </div>
601
+ )}
602
  {/* Basic Camera Settings */}
603
  <div className="text-xs text-white/50 font-semibold mb-1">Basic Settings</div>
604
  <div className="grid grid-cols-2 gap-2">
605
  <div>
606
  <label className="text-xs text-white/70">Focal Length</label>
607
+ <Select
608
+ className="w-full"
609
  value={node.focalLength || "None"}
610
+ onChange={(e) => onUpdate(node.id, { focalLength: (e.target as HTMLSelectElement).value })}
611
  >
612
  {focalLengths.map(f => <option key={f} value={f}>{f}</option>)}
613
+ </Select>
614
  </div>
615
  <div>
616
  <label className="text-xs text-white/70">Aperture</label>
617
+ <Select
618
+ className="w-full"
619
  value={node.aperture || "None"}
620
+ onChange={(e) => onUpdate(node.id, { aperture: (e.target as HTMLSelectElement).value })}
621
  >
622
  {apertures.map(a => <option key={a} value={a}>{a}</option>)}
623
+ </Select>
624
  </div>
625
  <div>
626
  <label className="text-xs text-white/70">Shutter Speed</label>
627
+ <Select
628
+ className="w-full"
629
  value={node.shutterSpeed || "None"}
630
+ onChange={(e) => onUpdate(node.id, { shutterSpeed: (e.target as HTMLSelectElement).value })}
631
  >
632
  {shutterSpeeds.map(s => <option key={s} value={s}>{s}</option>)}
633
+ </Select>
634
  </div>
635
  <div>
636
  <label className="text-xs text-white/70">ISO</label>
637
+ <Select
638
+ className="w-full"
639
  value={node.iso || "None"}
640
+ onChange={(e) => onUpdate(node.id, { iso: (e.target as HTMLSelectElement).value })}
641
  >
642
  {isoValues.map(i => <option key={i} value={i}>{i}</option>)}
643
+ </Select>
644
  </div>
645
  </div>
646
 
 
649
  <div className="grid grid-cols-2 gap-2">
650
  <div>
651
  <label className="text-xs text-white/70">White Balance</label>
652
+ <Select
653
+ className="w-full"
654
  value={node.whiteBalance || "None"}
655
+ onChange={(e) => onUpdate(node.id, { whiteBalance: (e.target as HTMLSelectElement).value })}
656
  >
657
  {whiteBalances.map(w => <option key={w} value={w}>{w}</option>)}
658
+ </Select>
659
  </div>
660
  <div>
661
  <label className="text-xs text-white/70">Film Style</label>
662
+ <Select
663
+ className="w-full"
664
  value={node.filmStyle || "None"}
665
+ onChange={(e) => onUpdate(node.id, { filmStyle: (e.target as HTMLSelectElement).value })}
666
  >
667
  {filmStyles.map(f => <option key={f} value={f}>{f}</option>)}
668
+ </Select>
669
  </div>
670
  <div>
671
  <label className="text-xs text-white/70">Lighting</label>
672
+ <Select
673
+ className="w-full"
674
  value={node.lighting || "None"}
675
+ onChange={(e) => onUpdate(node.id, { lighting: (e.target as HTMLSelectElement).value })}
676
  >
677
  {lightingTypes.map(l => <option key={l} value={l}>{l}</option>)}
678
+ </Select>
679
  </div>
680
  <div>
681
  <label className="text-xs text-white/70">Bokeh Style</label>
682
+ <Select
683
+ className="w-full"
684
  value={node.bokeh || "None"}
685
+ onChange={(e) => onUpdate(node.id, { bokeh: (e.target as HTMLSelectElement).value })}
686
  >
687
  {bokehStyles.map(b => <option key={b} value={b}>{b}</option>)}
688
+ </Select>
689
  </div>
690
  </div>
691
 
 
694
  <div className="grid grid-cols-2 gap-2">
695
  <div>
696
  <label className="text-xs text-white/70">Camera Angle</label>
697
+ <Select
698
+ className="w-full"
699
  value={node.angle || "None"}
700
+ onChange={(e) => onUpdate(node.id, { angle: (e.target as HTMLSelectElement).value })}
701
  >
702
  {angles.map(a => <option key={a} value={a}>{a}</option>)}
703
+ </Select>
704
  </div>
705
  <div>
706
  <label className="text-xs text-white/70">Composition</label>
707
+ <Select
708
+ className="w-full"
709
  value={node.composition || "None"}
710
+ onChange={(e) => onUpdate(node.id, { composition: (e.target as HTMLSelectElement).value })}
711
  >
712
  {compositions.map(c => <option key={c} value={c}>{c}</option>)}
713
+ </Select>
714
+ </div>
715
+ <div>
716
+ <label className="text-xs text-white/70">Aspect Ratio</label>
717
+ <Select
718
+ className="w-full"
719
+ value={node.aspectRatio || "None"}
720
+ onChange={(e) => onUpdate(node.id, { aspectRatio: (e.target as HTMLSelectElement).value })}
721
+ >
722
+ {aspectRatios.map(a => <option key={a} value={a}>{a}</option>)}
723
+ </Select>
724
  </div>
725
  </div>
726
+ <Button
727
+ className="w-full"
728
  onClick={() => onProcess(node.id)}
729
  disabled={node.isRunning}
730
  title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
731
  >
732
  {node.isRunning ? "Processing..." : "Apply Camera Settings"}
733
+ </Button>
734
  {node.output && (
735
  <div className="space-y-2 mt-2">
736
  <img src={node.output} className="w-full rounded" alt="Output" />
737
+ <Button
738
+ className="w-full"
739
+ variant="secondary"
740
  onClick={() => downloadImage(node.output, `camera-${Date.now()}.png`)}
741
  >
742
  📥 Download Output
743
+ </Button>
744
  </div>
745
  )}
746
  {node.error && (
 
768
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
769
  <div className="font-semibold text-sm flex-1 text-center">FACE</div>
770
  <div className="flex items-center gap-2">
771
+ <Button
772
+ variant="ghost"
773
+ size="icon"
774
+ className="text-destructive"
775
+ onClick={() => onDelete(node.id)}
776
+ title="Delete node"
777
+ aria-label="Delete node"
778
+ >
779
+ ×
780
+ </Button>
781
  <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
782
  </div>
783
  </div>
784
  <div className="p-3 space-y-2">
785
+ {node.input && (
786
+ <div className="flex justify-end mb-2">
787
+ <Button
788
+ variant="ghost"
789
+ size="sm"
790
+ onClick={() => onUpdate(node.id, { input: undefined })}
791
+ className="text-xs"
792
+ >
793
+ Clear Connection
794
+ </Button>
795
+ </div>
796
+ )}
797
  <div className="space-y-2">
798
  <label className="flex items-center gap-2 text-xs">
799
+ <Checkbox
 
800
  checked={node.faceOptions?.removePimples || false}
801
  onChange={(e) => onUpdate(node.id, {
802
+ faceOptions: { ...node.faceOptions, removePimples: (e.target as HTMLInputElement).checked }
803
  })}
804
  />
805
  Remove pimples
806
  </label>
807
  <label className="flex items-center gap-2 text-xs">
808
+ <Checkbox
 
809
  checked={node.faceOptions?.addSunglasses || false}
810
  onChange={(e) => onUpdate(node.id, {
811
+ faceOptions: { ...node.faceOptions, addSunglasses: (e.target as HTMLInputElement).checked }
812
  })}
813
  />
814
  Add sunglasses
815
  </label>
816
  <label className="flex items-center gap-2 text-xs">
817
+ <Checkbox
 
818
  checked={node.faceOptions?.addHat || false}
819
  onChange={(e) => onUpdate(node.id, {
820
+ faceOptions: { ...node.faceOptions, addHat: (e.target as HTMLInputElement).checked }
821
  })}
822
  />
823
  Add hat
 
826
 
827
  <div>
828
  <label className="text-xs text-white/70">Hairstyle</label>
829
+ <Select
830
+ className="w-full"
831
  value={node.faceOptions?.changeHairstyle || "None"}
832
  onChange={(e) => onUpdate(node.id, {
833
+ faceOptions: { ...node.faceOptions, changeHairstyle: (e.target as HTMLSelectElement).value }
834
  })}
835
  >
836
  {hairstyles.map(h => <option key={h} value={h}>{h}</option>)}
837
+ </Select>
838
  </div>
839
 
840
  <div>
841
  <label className="text-xs text-white/70">Expression</label>
842
+ <Select
843
+ className="w-full"
844
  value={node.faceOptions?.facialExpression || "None"}
845
  onChange={(e) => onUpdate(node.id, {
846
+ faceOptions: { ...node.faceOptions, facialExpression: (e.target as HTMLSelectElement).value }
847
  })}
848
  >
849
  {expressions.map(e => <option key={e} value={e}>{e}</option>)}
850
+ </Select>
851
  </div>
852
 
853
  <div>
854
  <label className="text-xs text-white/70">Beard</label>
855
+ <Select
856
+ className="w-full"
857
  value={node.faceOptions?.beardStyle || "None"}
858
  onChange={(e) => onUpdate(node.id, {
859
+ faceOptions: { ...node.faceOptions, beardStyle: (e.target as HTMLSelectElement).value }
860
  })}
861
  >
862
  {beardStyles.map(b => <option key={b} value={b}>{b}</option>)}
863
+ </Select>
864
  </div>
865
 
866
+ <Button
867
+ className="w-full"
868
  onClick={() => onProcess(node.id)}
869
  disabled={node.isRunning}
870
  title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
871
  >
872
  {node.isRunning ? "Processing..." : "Apply Face Changes"}
873
+ </Button>
874
  {node.output && (
875
  <div className="space-y-2 mt-2">
876
  <img src={node.output} className="w-full rounded" alt="Output" />
877
+ <Button
878
+ className="w-full"
879
+ variant="secondary"
880
  onClick={() => downloadImage(node.output, `face-${Date.now()}.png`)}
881
  >
882
  📥 Download Output
883
+ </Button>
884
  </div>
885
  )}
886
  {node.error && (
 
940
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
941
  <div className="font-semibold text-sm flex-1 text-center">BLEND</div>
942
  <div className="flex items-center gap-2">
943
+ <Button
944
+ variant="ghost"
945
+ size="icon"
946
+ className="text-destructive"
947
+ onClick={() => onDelete(node.id)}
948
+ title="Delete node"
949
+ aria-label="Delete node"
950
+ >
951
+ ×
952
+ </Button>
953
  <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
954
  </div>
955
  </div>
956
  <div className="p-3 space-y-3">
957
+ {node.input && (
958
+ <div className="flex justify-end mb-2">
959
+ <Button
960
+ variant="ghost"
961
+ size="sm"
962
+ onClick={() => onUpdate(node.id, { input: undefined })}
963
+ className="text-xs"
964
+ >
965
+ Clear Connection
966
+ </Button>
967
+ </div>
968
+ )}
969
  <div className="text-xs text-white/70">Style Reference Image</div>
970
+ <div className="text-xs text-white/50">Upload an artistic style image to blend with your input</div>
971
  {node.styleImage ? (
972
  <div className="relative">
973
  <img src={node.styleImage} className="w-full rounded" alt="Style" />
 
994
  />
995
  <div className="border-2 border-dashed border-white/20 rounded-lg p-4 text-center cursor-pointer hover:border-white/40">
996
  <p className="text-xs text-white/60">Drop, upload, or paste style image</p>
997
+ <p className="text-xs text-white/40 mt-1">Art style will be applied to input</p>
998
  </div>
999
  </label>
1000
  )}
1001
  <div>
1002
+ <Slider
1003
+ label="Blend Strength"
1004
+ valueLabel={`${node.blendStrength || 50}%`}
 
 
 
1005
  min={0}
1006
  max={100}
1007
  value={node.blendStrength || 50}
1008
+ onChange={(e) => onUpdate(node.id, { blendStrength: parseInt((e.target as HTMLInputElement).value) })}
 
1009
  />
1010
  </div>
1011
+ <Button
1012
+ className="w-full"
1013
  onClick={() => onProcess(node.id)}
1014
  disabled={node.isRunning || !node.styleImage}
1015
+ title={!node.input ? "Connect an input first" : !node.styleImage ? "Add a style image first" : "Blend the style with your input image"}
1016
  >
1017
+ {node.isRunning ? "Blending..." : "Blend Style Transfer"}
1018
+ </Button>
1019
  {node.output && (
1020
  <div className="space-y-2">
1021
  <img src={node.output} className="w-full rounded" alt="Output" />
1022
+ <Button
1023
+ className="w-full"
1024
+ variant="secondary"
1025
  onClick={() => downloadImage(node.output, `blend-${Date.now()}.png`)}
1026
  >
1027
  📥 Download Output
1028
+ </Button>
1029
  </div>
1030
  )}
1031
  {node.error && (
 
1050
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
1051
  <div className="font-semibold text-sm flex-1 text-center">EDIT</div>
1052
  <div className="flex items-center gap-2">
1053
+ <Button
1054
+ variant="ghost"
1055
+ size="icon"
1056
+ className="text-destructive"
1057
+ onClick={() => onDelete(node.id)}
1058
+ title="Delete node"
1059
+ aria-label="Delete node"
1060
+ >
1061
+ ×
1062
+ </Button>
1063
  <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
1064
  </div>
1065
  </div>
1066
  <div className="p-3 space-y-3">
1067
+ {node.input && (
1068
+ <div className="flex justify-end mb-2">
1069
+ <Button
1070
+ variant="ghost"
1071
+ size="sm"
1072
+ onClick={() => onUpdate(node.id, { input: undefined })}
1073
+ className="text-xs"
1074
+ >
1075
+ Clear Connection
1076
+ </Button>
1077
+ </div>
1078
+ )}
1079
+ <Textarea
1080
+ className="w-full"
1081
  placeholder="Describe what to edit (e.g., 'make it brighter', 'add more contrast', 'make it look vintage')"
1082
  value={node.editPrompt || ""}
1083
+ onChange={(e) => onUpdate(node.id, { editPrompt: (e.target as HTMLTextAreaElement).value })}
1084
  rows={3}
1085
  />
1086
+ <Button
1087
+ className="w-full"
1088
  onClick={() => onProcess(node.id)}
1089
  disabled={node.isRunning}
1090
  title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
1091
  >
1092
  {node.isRunning ? "Processing..." : "Apply Edit"}
1093
+ </Button>
1094
  {node.output && (
1095
  <div className="space-y-2">
1096
  <img src={node.output} className="w-full rounded" alt="Output" />
1097
+ <Button
1098
+ className="w-full"
1099
+ variant="secondary"
1100
  onClick={() => downloadImage(node.output, `edit-${Date.now()}.png`)}
1101
  >
1102
  📥 Download Output
1103
+ </Button>
1104
  </div>
1105
  )}
1106
  </div>
app/editor/page.tsx CHANGED
@@ -10,6 +10,7 @@ import {
10
  AgeNodeView,
11
  FaceNodeView
12
  } from "./nodes";
 
13
 
14
  function cx(...args: Array<string | false | null | undefined>) {
15
  return args.filter(Boolean).join(" ");
@@ -22,19 +23,27 @@ const uid = () => Math.random().toString(36).slice(2, 9);
22
  function generateMergePrompt(characterData: { image: string; label: string }[]): string {
23
  const count = characterData.length;
24
 
25
- if (count === 2) {
26
- return `You are provided with 2 images. Each image may contain one or more people. Create a single new photorealistic image that combines ALL people from BOTH images into one scene. If image 1 has multiple people, include all of them. If image 2 has multiple people, include all of them. Place everyone together in the same scene, standing side by side or in a natural group arrangement. Ensure all people are clearly visible with consistent lighting, proper sizing, and natural shadows. The result should look like a genuine group photo.`;
27
- }
28
 
29
- return `You are provided with ${count} images. Each image may contain one or more people. Create a single new photorealistic image that combines ALL people from ALL ${count} images into one comprehensive group photo.
30
-
31
- Important instructions:
32
- - Include EVERY person from EVERY input image
33
- - If an image has multiple people, include all of them
34
- - Arrange everyone in a natural group formation
35
- - Ensure all people are clearly visible and recognizable
36
- - Match lighting, shadows, and proportions realistically
37
- - The final image should look like an authentic group photo with everyone together`;
 
 
 
 
 
 
 
 
 
 
38
  }
39
 
40
  // Types
@@ -56,7 +65,7 @@ type CharacterNode = NodeBase & {
56
  type MergeNode = NodeBase & {
57
  type: "MERGE";
58
  inputs: string[]; // node ids
59
- output?: string; // data URL from merge
60
  isRunning?: boolean;
61
  error?: string | null;
62
  };
@@ -119,6 +128,7 @@ type CameraNode = NodeBase & {
119
  lighting?: string;
120
  bokeh?: string;
121
  composition?: string;
 
122
  isRunning?: boolean;
123
  error?: string | null;
124
  };
@@ -333,18 +343,19 @@ function CharacterNodeView({
333
  onChange={(e) => onChangeLabel(node.id, e.target.value)}
334
  />
335
  <div className="flex items-center gap-2">
336
- <button
337
- className="text-2xl leading-none font-bold text-red-400 hover:text-red-300 opacity-50 hover:opacity-100 transition-all hover:scale-110 px-1"
338
  onClick={(e) => {
339
  e.stopPropagation();
340
- if (confirm(`Delete ${node.label || 'CHARACTER'} node?`)) {
341
  onDelete(node.id);
342
  }
343
  }}
344
  title="Delete node"
 
345
  >
346
  ×
347
- </button>
348
  <Port
349
  className="out"
350
  nodeId={node.id}
@@ -354,11 +365,11 @@ function CharacterNodeView({
354
  </div>
355
  </div>
356
  <div className="p-3 space-y-3">
357
- <div className="aspect-[4/5] w-full overflow-hidden rounded-xl bg-black/40 grid place-items-center">
358
  <img
359
  src={node.image}
360
  alt="character"
361
- className="h-full w-full object-cover"
362
  draggable={false}
363
  />
364
  </div>
@@ -433,7 +444,7 @@ function MergeNodeView({
433
 
434
 
435
  return (
436
- <div className="nb-node absolute text-white w-[380px]" style={{ left: pos.x, top: pos.y }}>
437
  <div
438
  className="nb-header cursor-grab active:cursor-grabbing rounded-t-[14px] px-3 py-2 flex items-center justify-between"
439
  onPointerDown={onPointerDown}
@@ -476,8 +487,8 @@ function MergeNodeView({
476
  if (!c) return null;
477
  return (
478
  <div key={id} className="flex items-center gap-2 bg-white/10 rounded px-2 py-1">
479
- <div className="w-6 h-6 rounded overflow-hidden">
480
- <img src={c.image} className="w-full h-full object-cover" alt="inp" />
481
  </div>
482
  <span className="text-xs">{c.label || `Character ${id.slice(-3)}`}</span>
483
  <button
@@ -495,35 +506,37 @@ function MergeNodeView({
495
  )}
496
  <div className="flex items-center gap-2">
497
  {node.inputs.length > 0 && (
498
- <button
499
- className="text-xs bg-red-500/20 hover:bg-red-500/30 text-red-300 rounded px-3 py-1"
 
500
  onClick={() => onClearConnections(node.id)}
501
  title="Clear all connections"
502
  >
503
  Clear
504
- </button>
505
  )}
506
- <button
507
- className="text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1 disabled:opacity-60"
508
  onClick={() => onRun(node.id)}
509
  disabled={node.isRunning || node.inputs.length < 2}
510
  >
511
  {node.isRunning ? "Merging…" : "Merge"}
512
- </button>
513
  </div>
514
 
515
  <div className="mt-2">
516
  <div className="text-xs text-white/70 mb-1">Output</div>
517
- <div className="aspect-[4/3] w-full overflow-hidden rounded-xl bg-black/40 grid place-items-center">
518
  {node.output ? (
519
- <img src={node.output} className="w-full h-full object-contain" alt="output" />
520
  ) : (
521
- <span className="text-white/40 text-xs">Run merge to see result</span>
522
  )}
523
  </div>
524
  {node.output && (
525
- <button
526
- className="w-full text-xs bg-green-600 hover:bg-green-500 rounded px-3 py-1 mt-2 transition-all"
 
527
  onClick={() => {
528
  const link = document.createElement('a');
529
  link.href = node.output as string;
@@ -534,7 +547,7 @@ function MergeNodeView({
534
  }}
535
  >
536
  📥 Download Merged Image
537
- </button>
538
  )}
539
  {node.error && (
540
  <div className="mt-2">
@@ -758,6 +771,7 @@ export default function EditorPage() {
758
  if (cam.lighting && cam.lighting !== "None") config.lighting = cam.lighting;
759
  if (cam.bokeh && cam.bokeh !== "None") config.bokeh = cam.bokeh;
760
  if (cam.composition && cam.composition !== "None") config.composition = cam.composition;
 
761
  break;
762
  case "AGE":
763
  if ((node as AgeNode).targetAge) {
@@ -823,7 +837,7 @@ export default function EditorPage() {
823
 
824
  // If this is a MERGE node with output, return its output
825
  if (currentNode.type === "MERGE" && (currentNode as MergeNode).output) {
826
- return (currentNode as MergeNode).output;
827
  }
828
 
829
  // If any node has been processed, return its output
@@ -1125,17 +1139,34 @@ export default function EditorPage() {
1125
  // Get character nodes with their labels
1126
  const characterData = merge.inputs
1127
  .map((id, index) => {
1128
- const char = nodes.find((c) => c.id === id) as CharacterNode | undefined;
1129
  if (!char) return null;
1130
- return {
1131
- image: char.image,
1132
- label: char.label || `CHARACTER${index + 1}`
1133
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
1134
  })
1135
  .filter(Boolean) as { image: string; label: string }[];
1136
 
1137
  if (characterData.length < 2) throw new Error("Connect at least two CHARACTER nodes.");
1138
 
 
 
 
 
1139
  // Generate dynamic prompt based on number of inputs
1140
  const prompt = generateMergePrompt(characterData);
1141
  const imgs = characterData.map(d => d.image);
@@ -1162,13 +1193,30 @@ export default function EditorPage() {
1162
  }
1163
  };
1164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1165
  // Connection paths with bezier curves
1166
  const connectionPaths = useMemo(() => {
1167
  const getNodeOutputPort = (n: AnyNode) => {
1168
  // Different nodes have different widths
1169
  const widths: Record<string, number> = {
1170
  CHARACTER: 340,
1171
- MERGE: 380,
1172
  BACKGROUND: 320,
1173
  CLOTHES: 320,
1174
  BLEND: 320,
@@ -1326,13 +1374,9 @@ export default function EditorPage() {
1326
  };
1327
 
1328
  return (
1329
- <div className="min-h-[100svh] bg-[#0b0b0b] text-white">
1330
- <header className="flex items-center justify-between px-6 py-4 border-b border-white/10">
1331
- <h1 className="text-lg font-semibold tracking-wide">Nano Banana Editor</h1>
1332
- <div className="flex items-center gap-2">
1333
- <button className="text-xs bg-white/10 hover:bg-white/20 rounded px-3 py-1" onClick={() => addCharacter()}>+ CHARACTER</button>
1334
- <button className="text-xs bg-white/10 hover:bg-white/20 rounded px-3 py-1" onClick={() => addMerge()}>+ MERGE</button>
1335
- </div>
1336
  </header>
1337
 
1338
  <div
@@ -1368,7 +1412,16 @@ export default function EditorPage() {
1368
  backfaceVisibility: "hidden"
1369
  }}
1370
  >
1371
- <svg className="absolute inset-0 pointer-events-none z-0" width="4800" height="3200">
 
 
 
 
 
 
 
 
 
1372
  <defs>
1373
  <filter id="glow">
1374
  <feGaussianBlur stdDeviation="3" result="coloredBlur"/>
@@ -1383,11 +1436,10 @@ export default function EditorPage() {
1383
  key={idx}
1384
  d={p.path}
1385
  fill="none"
1386
- stroke={p.active ? "#8b5cf6" : "#7c7c7c"}
1387
  strokeWidth="2.5"
1388
  strokeDasharray={p.active ? "5,5" : undefined}
1389
- filter={p.active ? "url(#glow)" : undefined}
1390
- opacity={p.active ? 0.8 : 1}
1391
  />
1392
  ))}
1393
  </svg>
 
10
  AgeNodeView,
11
  FaceNodeView
12
  } from "./nodes";
13
+ import { Button } from "../../components/ui/button";
14
 
15
  function cx(...args: Array<string | false | null | undefined>) {
16
  return args.filter(Boolean).join(" ");
 
23
  function generateMergePrompt(characterData: { image: string; label: string }[]): string {
24
  const count = characterData.length;
25
 
26
+ const labels = characterData.map((d, i) => `Image ${i + 1} (${d.label})`).join(", ");
 
 
27
 
28
+ return `MERGE TASK: You are provided with exactly ${count} source images.
29
+
30
+ Images provided:
31
+ ${characterData.map((d, i) => `- Image ${i + 1}: ${d.label}`).join("\n")}
32
+
33
+ INSTRUCTIONS:
34
+ 1. EXTRACT the exact people/subjects from EACH provided image
35
+ 2. DO NOT generate new people - use ONLY the people visible in the provided images
36
+ 3. COMBINE all extracted people into ONE single group photo
37
+ 4. The output must contain ALL people from ALL ${count} input images together
38
+
39
+ Requirements:
40
+ - Use the ACTUAL people from the provided images (do not create new ones)
41
+ - If an image has multiple people, include ALL of them
42
+ - Arrange everyone naturally in the same scene
43
+ - Match lighting and proportions realistically
44
+ - Output exactly ONE image with everyone combined
45
+
46
+ DO NOT create artistic interpretations or new people. EXTRACT and COMBINE the actual subjects from the provided photographs.`;
47
  }
48
 
49
  // Types
 
65
  type MergeNode = NodeBase & {
66
  type: "MERGE";
67
  inputs: string[]; // node ids
68
+ output?: string | null; // data URL from merge
69
  isRunning?: boolean;
70
  error?: string | null;
71
  };
 
128
  lighting?: string;
129
  bokeh?: string;
130
  composition?: string;
131
+ aspectRatio?: string;
132
  isRunning?: boolean;
133
  error?: string | null;
134
  };
 
343
  onChange={(e) => onChangeLabel(node.id, e.target.value)}
344
  />
345
  <div className="flex items-center gap-2">
346
+ <Button
347
+ variant="ghost" size="icon" className="text-destructive"
348
  onClick={(e) => {
349
  e.stopPropagation();
350
+ if (confirm('Delete MERGE node?')) {
351
  onDelete(node.id);
352
  }
353
  }}
354
  title="Delete node"
355
+ aria-label="Delete node"
356
  >
357
  ×
358
+ </Button>
359
  <Port
360
  className="out"
361
  nodeId={node.id}
 
365
  </div>
366
  </div>
367
  <div className="p-3 space-y-3">
368
+ <div className="aspect-[4/5] w-full rounded-xl bg-black/40 grid place-items-center overflow-hidden">
369
  <img
370
  src={node.image}
371
  alt="character"
372
+ className="h-full w-full object-contain"
373
  draggable={false}
374
  />
375
  </div>
 
444
 
445
 
446
  return (
447
+ <div className="nb-node absolute text-white w-[420px]" style={{ left: pos.x, top: pos.y }}>
448
  <div
449
  className="nb-header cursor-grab active:cursor-grabbing rounded-t-[14px] px-3 py-2 flex items-center justify-between"
450
  onPointerDown={onPointerDown}
 
487
  if (!c) return null;
488
  return (
489
  <div key={id} className="flex items-center gap-2 bg-white/10 rounded px-2 py-1">
490
+ <div className="w-6 h-6 rounded overflow-hidden bg-black/20">
491
+ <img src={c.image} className="w-full h-full object-contain" alt="inp" />
492
  </div>
493
  <span className="text-xs">{c.label || `Character ${id.slice(-3)}`}</span>
494
  <button
 
506
  )}
507
  <div className="flex items-center gap-2">
508
  {node.inputs.length > 0 && (
509
+ <Button
510
+ variant="destructive"
511
+ size="sm"
512
  onClick={() => onClearConnections(node.id)}
513
  title="Clear all connections"
514
  >
515
  Clear
516
+ </Button>
517
  )}
518
+ <Button
519
+ size="sm"
520
  onClick={() => onRun(node.id)}
521
  disabled={node.isRunning || node.inputs.length < 2}
522
  >
523
  {node.isRunning ? "Merging…" : "Merge"}
524
+ </Button>
525
  </div>
526
 
527
  <div className="mt-2">
528
  <div className="text-xs text-white/70 mb-1">Output</div>
529
+ <div className="w-full min-h-[200px] max-h-[400px] rounded-xl bg-black/40 grid place-items-center">
530
  {node.output ? (
531
+ <img src={node.output} className="w-full h-auto max-h-[400px] object-contain rounded-xl" alt="output" />
532
  ) : (
533
+ <span className="text-white/40 text-xs py-16">Run merge to see result</span>
534
  )}
535
  </div>
536
  {node.output && (
537
+ <Button
538
+ className="w-full mt-2"
539
+ variant="secondary"
540
  onClick={() => {
541
  const link = document.createElement('a');
542
  link.href = node.output as string;
 
547
  }}
548
  >
549
  📥 Download Merged Image
550
+ </Button>
551
  )}
552
  {node.error && (
553
  <div className="mt-2">
 
771
  if (cam.lighting && cam.lighting !== "None") config.lighting = cam.lighting;
772
  if (cam.bokeh && cam.bokeh !== "None") config.bokeh = cam.bokeh;
773
  if (cam.composition && cam.composition !== "None") config.composition = cam.composition;
774
+ if (cam.aspectRatio && cam.aspectRatio !== "None") config.aspectRatio = cam.aspectRatio;
775
  break;
776
  case "AGE":
777
  if ((node as AgeNode).targetAge) {
 
837
 
838
  // If this is a MERGE node with output, return its output
839
  if (currentNode.type === "MERGE" && (currentNode as MergeNode).output) {
840
+ return (currentNode as MergeNode).output || null;
841
  }
842
 
843
  // If any node has been processed, return its output
 
1139
  // Get character nodes with their labels
1140
  const characterData = merge.inputs
1141
  .map((id, index) => {
1142
+ const char = nodes.find((c) => c.id === id);
1143
  if (!char) return null;
1144
+
1145
+ // Support both CHARACTER nodes and any node with output
1146
+ let image: string | null = null;
1147
+ let label = "";
1148
+
1149
+ if (char.type === "CHARACTER") {
1150
+ image = (char as CharacterNode).image;
1151
+ label = (char as CharacterNode).label || `CHARACTER${index + 1}`;
1152
+ } else if ((char as any).output) {
1153
+ // If it's a processed node, use its output
1154
+ image = (char as any).output;
1155
+ label = `Input ${index + 1}`;
1156
+ }
1157
+
1158
+ if (!image) return null;
1159
+
1160
+ return { image, label };
1161
  })
1162
  .filter(Boolean) as { image: string; label: string }[];
1163
 
1164
  if (characterData.length < 2) throw new Error("Connect at least two CHARACTER nodes.");
1165
 
1166
+ // Debug: Log what we're sending
1167
+ console.log("🔄 Merging nodes:", characterData.map(d => d.label).join(", "));
1168
+ console.log("📷 Image URLs being sent:", characterData.map(d => d.image.substring(0, 100) + "..."));
1169
+
1170
  // Generate dynamic prompt based on number of inputs
1171
  const prompt = generateMergePrompt(characterData);
1172
  const imgs = characterData.map(d => d.image);
 
1193
  }
1194
  };
1195
 
1196
+ // Calculate SVG bounds for connection lines
1197
+ const svgBounds = useMemo(() => {
1198
+ let minX = 0, minY = 0, maxX = 1000, maxY = 1000;
1199
+ nodes.forEach(node => {
1200
+ minX = Math.min(minX, node.x - 100);
1201
+ minY = Math.min(minY, node.y - 100);
1202
+ maxX = Math.max(maxX, node.x + 500);
1203
+ maxY = Math.max(maxY, node.y + 500);
1204
+ });
1205
+ return {
1206
+ x: minX,
1207
+ y: minY,
1208
+ width: maxX - minX,
1209
+ height: maxY - minY
1210
+ };
1211
+ }, [nodes]);
1212
+
1213
  // Connection paths with bezier curves
1214
  const connectionPaths = useMemo(() => {
1215
  const getNodeOutputPort = (n: AnyNode) => {
1216
  // Different nodes have different widths
1217
  const widths: Record<string, number> = {
1218
  CHARACTER: 340,
1219
+ MERGE: 420,
1220
  BACKGROUND: 320,
1221
  CLOTHES: 320,
1222
  BLEND: 320,
 
1374
  };
1375
 
1376
  return (
1377
+ <div className="min-h-[100svh] bg-background text-foreground">
1378
+ <header className="flex items-center justify-between px-6 py-4 border-b border-border/60 bg-card/70 backdrop-blur">
1379
+ <h1 className="text-lg font-semibold tracking-wide"><span className="mr-2" aria-hidden>🍌</span>Nano Banana Editor</h1>
 
 
 
 
1380
  </header>
1381
 
1382
  <div
 
1412
  backfaceVisibility: "hidden"
1413
  }}
1414
  >
1415
+ <svg
1416
+ className="absolute pointer-events-none z-0"
1417
+ style={{
1418
+ left: `${svgBounds.x}px`,
1419
+ top: `${svgBounds.y}px`,
1420
+ width: `${svgBounds.width}px`,
1421
+ height: `${svgBounds.height}px`
1422
+ }}
1423
+ viewBox={`${svgBounds.x} ${svgBounds.y} ${svgBounds.width} ${svgBounds.height}`}
1424
+ >
1425
  <defs>
1426
  <filter id="glow">
1427
  <feGaussianBlur stdDeviation="3" result="coloredBlur"/>
 
1436
  key={idx}
1437
  d={p.path}
1438
  fill="none"
1439
+ stroke={p.active ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"}
1440
  strokeWidth="2.5"
1441
  strokeDasharray={p.active ? "5,5" : undefined}
1442
+ style={p.active ? undefined : { opacity: 0.9 }}
 
1443
  />
1444
  ))}
1445
  </svg>
app/globals.css CHANGED
@@ -1,36 +1,140 @@
1
  @import "tailwindcss";
2
 
 
3
  :root {
4
- --background: #ffffff;
5
- --foreground: #171717;
6
- }
7
 
8
- @theme inline {
9
- --color-background: var(--background);
10
- --color-foreground: var(--foreground);
11
- --font-sans: var(--font-geist-sans);
12
- --font-mono: var(--font-geist-mono);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  }
14
 
15
  @media (prefers-color-scheme: dark) {
16
  :root {
17
- --background: #0a0a0a;
18
- --foreground: #ededed;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  }
20
  }
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  body {
23
- background: var(--background);
24
- color: var(--foreground);
25
- font-family: Arial, Helvetica, sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  }
27
 
28
  /* Nano Banana Editor - node visuals */
29
  .nb-node {
30
- background: rgba(15,15,16,0.9);
31
- border: 1px solid rgba(255,255,255,0.08);
32
  box-shadow: 0 10px 30px rgba(0,0,0,0.35);
33
- border-radius: 14px;
34
  backdrop-filter: blur(6px);
35
  /* Prevent blurring on zoom */
36
  image-rendering: -webkit-optimize-contrast;
@@ -43,29 +147,29 @@ body {
43
  perspective: 1000px;
44
  }
45
  .nb-node .nb-header {
46
- background: linear-gradient(to bottom, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
47
  }
48
  .nb-port {
49
  width: 20px;
50
  height: 20px;
51
  border-radius: 9999px;
52
  border: 3px solid rgba(255,255,255,0.6);
53
- background: #111;
54
  cursor: crosshair;
55
  transition: transform 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
56
  position: relative;
57
  }
58
  .nb-port:hover {
59
- transform: scale(1.4);
60
- background: #222;
61
- box-shadow: 0 0 12px rgba(255,255,255,0.3);
62
  }
63
  .nb-port.out {
64
- border-color: #8b5cf6;
65
  }
66
  .nb-port.out:hover {
67
- background: #8b5cf6;
68
- box-shadow: 0 0 16px rgba(139,92,246,0.6);
69
  }
70
  .nb-port.in {
71
  border-color: #34d399;
@@ -83,10 +187,10 @@ body {
83
 
84
  /* Canvas grid */
85
  .nb-canvas {
86
- background-color: #0b0b0b;
87
  background-image:
88
- radial-gradient(circle at 1px 1px, rgba(255,255,255,0.08) 1px, transparent 0),
89
- radial-gradient(circle at 1px 1px, rgba(255,255,255,0.04) 1px, transparent 0);
90
  background-size: 20px 20px, 100px 100px;
91
  }
92
 
 
1
  @import "tailwindcss";
2
 
3
+ /* shadcn theme tokens */
4
  :root {
5
+ --background: 0 0% 100%;
6
+ --foreground: 222.2 84% 4.9%;
 
7
 
8
+ --card: 0 0% 100%;
9
+ --card-foreground: 222.2 84% 4.9%;
10
+
11
+ --popover: 0 0% 100%;
12
+ --popover-foreground: 222.2 84% 4.9%;
13
+
14
+ /* Brand: Orangish Red */
15
+ --primary: 14 90% 50%;
16
+ --primary-foreground: 0 0% 98%;
17
+
18
+ --secondary: 210 40% 96.1%;
19
+ --secondary-foreground: 222.2 47.4% 11.2%;
20
+
21
+ --muted: 210 40% 96.1%;
22
+ --muted-foreground: 215.4 16.3% 46.9%;
23
+
24
+ --accent: 14 95% 90%;
25
+ --accent-foreground: 14 90% 20%;
26
+
27
+ --destructive: 0 84.2% 60.2%;
28
+ --destructive-foreground: 210 40% 98%;
29
+
30
+ --border: 214.3 31.8% 91.4%;
31
+ --input: 214.3 31.8% 91.4%;
32
+ --ring: 14 90% 45%;
33
+
34
+ --radius: 0.75rem;
35
+
36
+ --chart-1: 12 76% 61%;
37
+ --chart-2: 173 58% 39%;
38
+ --chart-3: 197 37% 24%;
39
+ --chart-4: 43 74% 66%;
40
+ --chart-5: 27 87% 67%;
41
  }
42
 
43
  @media (prefers-color-scheme: dark) {
44
  :root {
45
+ --background: 240 10% 3.9%;
46
+ --foreground: 0 0% 98%;
47
+
48
+ --card: 240 10% 3.9%;
49
+ --card-foreground: 0 0% 98%;
50
+
51
+ --popover: 240 10% 3.9%;
52
+ --popover-foreground: 0 0% 98%;
53
+
54
+ /* Brand in dark mode */
55
+ --primary: 14 87% 60%;
56
+ --primary-foreground: 240 10% 3.9%;
57
+
58
+ --secondary: 240 3.7% 15.9%;
59
+ --secondary-foreground: 0 0% 98%;
60
+
61
+ --muted: 240 3.7% 15.9%;
62
+ --muted-foreground: 240 5% 64.9%;
63
+
64
+ --accent: 14 40% 20%;
65
+ --accent-foreground: 0 0% 98%;
66
+
67
+ --destructive: 0 62.8% 30.6%;
68
+ --destructive-foreground: 0 85.7% 97.3%;
69
+
70
+ --border: 240 3.7% 15.9%;
71
+ --input: 240 3.7% 15.9%;
72
+ --ring: 14 87% 55%;
73
  }
74
  }
75
 
76
+ @theme inline {
77
+ --color-background: hsl(var(--background));
78
+ --color-foreground: hsl(var(--foreground));
79
+ --color-card: hsl(var(--card));
80
+ --color-card-foreground: hsl(var(--card-foreground));
81
+ --color-popover: hsl(var(--popover));
82
+ --color-popover-foreground: hsl(var(--popover-foreground));
83
+ --color-primary: hsl(var(--primary));
84
+ --color-primary-foreground: hsl(var(--primary-foreground));
85
+ --color-secondary: hsl(var(--secondary));
86
+ --color-secondary-foreground: hsl(var(--secondary-foreground));
87
+ --color-muted: hsl(var(--muted));
88
+ --color-muted-foreground: hsl(var(--muted-foreground));
89
+ --color-accent: hsl(var(--accent));
90
+ --color-accent-foreground: hsl(var(--accent-foreground));
91
+ --color-destructive: hsl(var(--destructive));
92
+ --color-destructive-foreground: hsl(var(--destructive-foreground));
93
+ --color-border: hsl(var(--border));
94
+ --color-input: hsl(var(--input));
95
+ --color-ring: hsl(var(--ring));
96
+ --font-sans: var(--font-geist-sans);
97
+ --font-mono: var(--font-geist-mono);
98
+ }
99
+
100
  body {
101
+ background: hsl(var(--background));
102
+ color: hsl(var(--foreground));
103
+ font-family: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
104
+ }
105
+
106
+ /* Custom scrollbar styling */
107
+ .scrollbar-thin::-webkit-scrollbar {
108
+ width: 6px;
109
+ height: 6px;
110
+ }
111
+
112
+ .scrollbar-thin::-webkit-scrollbar-track {
113
+ background: hsl(var(--background) / 0.1);
114
+ border-radius: 3px;
115
+ }
116
+
117
+ .scrollbar-thin::-webkit-scrollbar-thumb {
118
+ background: hsl(var(--muted-foreground) / 0.3);
119
+ border-radius: 3px;
120
+ }
121
+
122
+ .scrollbar-thin::-webkit-scrollbar-thumb:hover {
123
+ background: hsl(var(--muted-foreground) / 0.5);
124
+ }
125
+
126
+ /* Firefox */
127
+ .scrollbar-thin {
128
+ scrollbar-width: thin;
129
+ scrollbar-color: hsl(var(--muted-foreground) / 0.3) hsl(var(--background) / 0.1);
130
  }
131
 
132
  /* Nano Banana Editor - node visuals */
133
  .nb-node {
134
+ background: hsl(var(--card) / 0.9);
135
+ border: 1px solid hsl(var(--border) / 0.6);
136
  box-shadow: 0 10px 30px rgba(0,0,0,0.35);
137
+ border-radius: var(--radius);
138
  backdrop-filter: blur(6px);
139
  /* Prevent blurring on zoom */
140
  image-rendering: -webkit-optimize-contrast;
 
147
  perspective: 1000px;
148
  }
149
  .nb-node .nb-header {
150
+ background: linear-gradient(to bottom, hsl(var(--muted) / 0.35), hsl(var(--muted) / 0.08));
151
  }
152
  .nb-port {
153
  width: 20px;
154
  height: 20px;
155
  border-radius: 9999px;
156
  border: 3px solid rgba(255,255,255,0.6);
157
+ background: hsl(var(--popover));
158
  cursor: crosshair;
159
  transition: transform 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
160
  position: relative;
161
  }
162
  .nb-port:hover {
163
+ transform: scale(1.25);
164
+ background: hsl(var(--accent));
165
+ box-shadow: 0 0 12px hsl(var(--ring) / 0.4);
166
  }
167
  .nb-port.out {
168
+ border-color: hsl(var(--primary));
169
  }
170
  .nb-port.out:hover {
171
+ background: hsl(var(--primary));
172
+ box-shadow: 0 0 16px hsl(var(--primary) / 0.6);
173
  }
174
  .nb-port.in {
175
  border-color: #34d399;
 
187
 
188
  /* Canvas grid */
189
  .nb-canvas {
190
+ background-color: hsl(var(--background));
191
  background-image:
192
+ radial-gradient(circle at 1px 1px, hsl(var(--muted-foreground) / 0.18) 1px, transparent 0),
193
+ radial-gradient(circle at 1px 1px, hsl(var(--muted-foreground) / 0.09) 1px, transparent 0);
194
  background-size: 20px 20px, 100px 100px;
195
  }
196
 
app/layout.tsx CHANGED
@@ -25,7 +25,7 @@ export default function RootLayout({
25
  return (
26
  <html lang="en">
27
  <body
28
- className={`${geistSans.variable} ${geistMono.variable} antialiased`}
29
  >
30
  {children}
31
  </body>
 
25
  return (
26
  <html lang="en">
27
  <body
28
+ className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground font-sans`}
29
  >
30
  {children}
31
  </body>
app/try-on/page.tsx CHANGED
@@ -206,7 +206,7 @@ export default function TryOnPage() {
206
  };
207
 
208
  return (
209
- <div className="min-h-[100svh] bg-black text-white">
210
  <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-10">
211
  {/* Header */}
212
  <div className="flex items-center justify-between mb-10">
 
206
  };
207
 
208
  return (
209
+ <div className="min-h-[100svh] bg-background text-foreground">
210
  <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-10">
211
  {/* Header */}
212
  <div className="flex items-center justify-between mb-10">
package-lock.json CHANGED
@@ -9,9 +9,12 @@
9
  "version": "0.1.0",
10
  "dependencies": {
11
  "@google/genai": "^1.17.0",
 
 
12
  "next": "15.5.2",
13
  "react": "19.1.0",
14
- "react-dom": "19.1.0"
 
15
  },
16
  "devDependencies": {
17
  "@eslint/eslintrc": "^3",
@@ -2357,12 +2360,33 @@
2357
  "node": ">=18"
2358
  }
2359
  },
 
 
 
 
 
 
 
 
 
 
 
 
2360
  "node_modules/client-only": {
2361
  "version": "0.0.1",
2362
  "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
2363
  "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
2364
  "license": "MIT"
2365
  },
 
 
 
 
 
 
 
 
 
2366
  "node_modules/color": {
2367
  "version": "4.2.3",
2368
  "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -5932,6 +5956,16 @@
5932
  "url": "https://github.com/sponsors/ljharb"
5933
  }
5934
  },
 
 
 
 
 
 
 
 
 
 
5935
  "node_modules/tailwindcss": {
5936
  "version": "4.1.13",
5937
  "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz",
 
9
  "version": "0.1.0",
10
  "dependencies": {
11
  "@google/genai": "^1.17.0",
12
+ "class-variance-authority": "^0.7.0",
13
+ "clsx": "^2.1.1",
14
  "next": "15.5.2",
15
  "react": "19.1.0",
16
+ "react-dom": "19.1.0",
17
+ "tailwind-merge": "^2.5.3"
18
  },
19
  "devDependencies": {
20
  "@eslint/eslintrc": "^3",
 
2360
  "node": ">=18"
2361
  }
2362
  },
2363
+ "node_modules/class-variance-authority": {
2364
+ "version": "0.7.1",
2365
+ "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
2366
+ "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
2367
+ "license": "Apache-2.0",
2368
+ "dependencies": {
2369
+ "clsx": "^2.1.1"
2370
+ },
2371
+ "funding": {
2372
+ "url": "https://polar.sh/cva"
2373
+ }
2374
+ },
2375
  "node_modules/client-only": {
2376
  "version": "0.0.1",
2377
  "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
2378
  "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
2379
  "license": "MIT"
2380
  },
2381
+ "node_modules/clsx": {
2382
+ "version": "2.1.1",
2383
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
2384
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
2385
+ "license": "MIT",
2386
+ "engines": {
2387
+ "node": ">=6"
2388
+ }
2389
+ },
2390
  "node_modules/color": {
2391
  "version": "4.2.3",
2392
  "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
 
5956
  "url": "https://github.com/sponsors/ljharb"
5957
  }
5958
  },
5959
+ "node_modules/tailwind-merge": {
5960
+ "version": "2.6.0",
5961
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
5962
+ "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==",
5963
+ "license": "MIT",
5964
+ "funding": {
5965
+ "type": "github",
5966
+ "url": "https://github.com/sponsors/dcastil"
5967
+ }
5968
+ },
5969
  "node_modules/tailwindcss": {
5970
  "version": "4.1.13",
5971
  "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz",
package.json CHANGED
@@ -10,9 +10,12 @@
10
  },
11
  "dependencies": {
12
  "@google/genai": "^1.17.0",
 
 
13
  "next": "15.5.2",
14
  "react": "19.1.0",
15
- "react-dom": "19.1.0"
 
16
  },
17
  "devDependencies": {
18
  "@eslint/eslintrc": "^3",
 
10
  },
11
  "dependencies": {
12
  "@google/genai": "^1.17.0",
13
+ "class-variance-authority": "^0.7.0",
14
+ "clsx": "^2.1.1",
15
  "next": "15.5.2",
16
  "react": "19.1.0",
17
+ "react-dom": "19.1.0",
18
+ "tailwind-merge": "^2.5.3"
19
  },
20
  "devDependencies": {
21
  "@eslint/eslintrc": "^3",