Reubencf commited on
Commit
64ea545
Β·
1 Parent(s): 3d45ae5

fixing the bugs

Browse files
Files changed (3) hide show
  1. app/api/process/route.ts +89 -62
  2. app/editor/nodes.tsx +269 -29
  3. app/editor/page.tsx +400 -78
app/api/process/route.ts CHANGED
@@ -52,72 +52,99 @@ export async function POST(req: NextRequest) {
52
  return NextResponse.json({ error: "Invalid or missing image data. Please ensure an input is connected." }, { status: 400 });
53
  }
54
 
55
- let prompt = "";
 
 
56
 
57
- // Generate appropriate prompt based on node type
58
- switch (body.type) {
59
- case "BACKGROUND":
60
- const bgType = body.params?.backgroundType || "color";
61
- if (bgType === "color") {
62
- prompt = `Change the background of this image to a solid ${body.params?.backgroundColor || "white"} background. Keep the person/subject exactly as they are, only change the background.`;
63
- } else if (bgType === "image") {
64
- prompt = `Change the background to ${body.params?.backgroundImage || "a beautiful beach scene"}. Keep the person/subject exactly as they are with proper lighting to match the new background.`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  } else {
66
- prompt = body.params?.customPrompt || "Change the background to a professional studio background.";
67
  }
68
- break;
69
-
70
- case "CLOTHES":
71
- prompt = body.params?.clothesPrompt ||
72
- "Change the person's clothes to " + (body.params?.clothesDescription || "formal business attire") +
73
- ". Keep their face, pose, and everything else exactly the same.";
74
- break;
75
-
76
- case "BLEND":
77
- prompt = `Blend this image with the style: ${body.params?.stylePrompt || "oil painting style"}. ` +
78
- `Strength: ${body.params?.blendStrength || 50}%. Keep the subject recognizable while applying the style.`;
79
- break;
80
-
81
- case "EDIT":
82
- prompt = body.params?.editPrompt || "Make subtle improvements to this image.";
83
- break;
84
-
85
- case "CAMERA":
86
- const camera = body.params || {};
87
- prompt = `Apply these camera settings to the image:\n` +
88
- `Focal Length: ${camera.focalLength || "50mm"}\n` +
89
- `Aperture: ${camera.aperture || "f/2.8"}\n` +
90
- `Shutter Speed: ${camera.shutterSpeed || "1/250s"}\n` +
91
- `White Balance: ${camera.whiteBalance || "5600K daylight"}\n` +
92
- `Camera Angle: ${camera.angle || "eye level"}\n` +
93
- `Make the image look like it was shot with these exact camera settings.`;
94
- break;
95
-
96
- case "AGE":
97
- const targetAge = body.params?.targetAge || 30;
98
- prompt = `Transform the person in this image to look exactly ${targetAge} years old. ` +
99
- `Adjust their facial features, skin texture, hair, and overall appearance to match that age naturally. ` +
100
- `Keep their identity recognizable but age-appropriate.`;
101
- break;
102
-
103
- case "FACE":
104
- const face = body.params?.faceOptions || {};
105
- const modifications = [];
106
- if (face.removePimples) modifications.push("remove all pimples and blemishes");
107
- if (face.addSunglasses) modifications.push("add stylish sunglasses");
108
- if (face.addHat) modifications.push("add a fashionable hat");
109
- if (face.changeHairstyle) modifications.push(`change hairstyle to ${face.changeHairstyle}`);
110
- if (face.facialExpression) modifications.push(`change facial expression to ${face.facialExpression}`);
111
- if (face.beardStyle) modifications.push(`add/change beard to ${face.beardStyle}`);
112
-
113
- prompt = modifications.length > 0
114
- ? `Modify the person's face: ${modifications.join(", ")}. Keep everything else the same.`
115
- : "Enhance the person's face subtly.";
116
- break;
117
-
118
- default:
119
- return NextResponse.json({ error: "Unknown node type" }, { status: 400 });
120
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
 
122
  // Add the custom prompt if provided
123
  if (body.prompt) {
 
52
  return NextResponse.json({ error: "Invalid or missing image data. Please ensure an input is connected." }, { status: 400 });
53
  }
54
 
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) {
61
+ const bgType = params.backgroundType;
62
+ if (bgType === "color") {
63
+ prompts.push(`Change the background to a solid ${params.backgroundColor || "white"} background.`);
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
+ }
71
+ }
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
93
+ if (params.editPrompt) {
94
+ prompts.push(params.editPrompt);
95
+ }
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") {
103
+ cameraSettings.push("Apply 8mm fisheye lens effect with 180-degree circular distortion");
104
  } else {
105
+ cameraSettings.push(`Focal Length: ${params.focalLength}`);
106
  }
107
+ }
108
+ if (params.aperture) cameraSettings.push(`Aperture: ${params.aperture}`);
109
+ if (params.shutterSpeed) cameraSettings.push(`Shutter Speed: ${params.shutterSpeed}`);
110
+ if (params.whiteBalance) cameraSettings.push(`White Balance: ${params.whiteBalance}`);
111
+ if (params.angle) cameraSettings.push(`Camera Angle: ${params.angle}`);
112
+ if (params.iso) cameraSettings.push(`${params.iso}`);
113
+ if (params.filmStyle) cameraSettings.push(`Film style: ${params.filmStyle}`);
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(", ")}`);
120
+ }
121
+ }
122
+
123
+ // Age transformation
124
+ if (params.targetAge) {
125
+ prompts.push(`Transform the person to look exactly ${params.targetAge} years old with age-appropriate features.`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  }
127
+
128
+ // Face modifications
129
+ if (params.faceOptions) {
130
+ const face = params.faceOptions;
131
+ const modifications: string[] = [];
132
+ if (face.removePimples) modifications.push("remove all pimples and blemishes");
133
+ if (face.addSunglasses) modifications.push("add stylish sunglasses");
134
+ if (face.addHat) modifications.push("add a fashionable hat");
135
+ if (face.changeHairstyle) modifications.push(`change hairstyle to ${face.changeHairstyle}`);
136
+ if (face.facialExpression) modifications.push(`change facial expression to ${face.facialExpression}`);
137
+ if (face.beardStyle) modifications.push(`add/change beard to ${face.beardStyle}`);
138
+
139
+ if (modifications.length > 0) {
140
+ prompts.push(`Face modifications: ${modifications.join(", ")}`);
141
+ }
142
+ }
143
+
144
+ // Combine all prompts
145
+ let prompt = prompts.length > 0
146
+ ? prompts.join("\n\n") + "\n\nApply all these modifications while maintaining the person's identity and keeping unspecified aspects unchanged."
147
+ : "Process this image with high quality output.";
148
 
149
  // Add the custom prompt if provided
150
  if (body.prompt) {
app/editor/nodes.tsx CHANGED
@@ -2,6 +2,16 @@
2
 
3
  import React, { useState, useRef, useEffect } from "react";
4
 
 
 
 
 
 
 
 
 
 
 
5
  // Import types (we'll need to export these from page.tsx)
6
  type BackgroundNode = any;
7
  type ClothesNode = any;
@@ -98,9 +108,59 @@ export function BackgroundNodeView({
98
  onUpdatePosition,
99
  }: any) {
100
  const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
  return (
103
- <div className="nb-node absolute text-white w-[320px]" style={{ left: localPos.x, top: localPos.y }}>
 
 
 
 
 
 
104
  <div
105
  className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
106
  onPointerDown={onPointerDown}
@@ -122,6 +182,7 @@ export function BackgroundNodeView({
122
  >
123
  <option value="color">Solid Color</option>
124
  <option value="image">Preset Background</option>
 
125
  <option value="custom">Custom Prompt</option>
126
  </select>
127
 
@@ -149,6 +210,35 @@ export function BackgroundNodeView({
149
  </select>
150
  )}
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  {node.backgroundType === "custom" && (
153
  <textarea
154
  className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
@@ -160,15 +250,24 @@ export function BackgroundNodeView({
160
  )}
161
 
162
  <button
163
- className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1"
164
  onClick={() => onProcess(node.id)}
165
  disabled={node.isRunning}
 
166
  >
167
  {node.isRunning ? "Processing..." : "Apply Background"}
168
  </button>
169
 
170
  {node.output && (
171
- <img src={node.output} className="w-full rounded" alt="Output" />
 
 
 
 
 
 
 
 
172
  )}
173
  {node.error && (
174
  <div className="text-xs text-red-400 mt-2">{node.error}</div>
@@ -180,6 +279,7 @@ export function BackgroundNodeView({
180
 
181
  export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
182
  const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
 
183
 
184
  const presetClothes = [
185
  { name: "Sukajan", path: "/sukajan.png" },
@@ -221,11 +321,12 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
221
 
222
  return (
223
  <div
224
- className="nb-node absolute text-white w-[320px]"
225
  style={{ left: localPos.x, top: localPos.y }}
226
  onDrop={onDrop}
227
  onDragOver={(e) => e.preventDefault()}
228
  onPaste={onPaste}
 
229
  >
230
  <div
231
  className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
@@ -241,6 +342,11 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
241
  </div>
242
  </div>
243
  <div className="p-3 space-y-3">
 
 
 
 
 
244
  <div className="text-xs text-white/70">Clothes Reference</div>
245
 
246
  {/* Preset clothes options */}
@@ -295,13 +401,24 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
295
  ) : null}
296
 
297
  <button
298
- className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1"
299
  onClick={() => onProcess(node.id)}
300
  disabled={node.isRunning || !node.clothesImage}
 
301
  >
302
  {node.isRunning ? "Processing..." : "Apply Clothes"}
303
  </button>
304
- {node.output && <img src={node.output} className="w-full rounded" alt="Output" />}
 
 
 
 
 
 
 
 
 
 
305
  {node.error && (
306
  <div className="text-xs text-red-400 mt-2">{node.error}</div>
307
  )}
@@ -312,9 +429,10 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
312
 
313
  export function AgeNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
314
  const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
 
315
 
316
  return (
317
- <div className="nb-node absolute text-white w-[280px]" style={{ left: localPos.x, top: localPos.y }}>
318
  <div
319
  className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
320
  onPointerDown={onPointerDown}
@@ -344,13 +462,24 @@ export function AgeNodeView({ node, onDelete, onUpdate, onStartConnection, onEnd
344
  />
345
  </div>
346
  <button
347
- className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1"
348
  onClick={() => onProcess(node.id)}
349
  disabled={node.isRunning}
 
350
  >
351
  {node.isRunning ? "Processing..." : "Apply Age"}
352
  </button>
353
- {node.output && <img src={node.output} className="w-full rounded" alt="Output" />}
 
 
 
 
 
 
 
 
 
 
354
  {node.error && (
355
  <div className="text-xs text-red-400 mt-2">{node.error}</div>
356
  )}
@@ -361,11 +490,16 @@ export function AgeNodeView({ node, onDelete, onUpdate, onStartConnection, onEnd
361
 
362
  export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
363
  const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
364
- const focalLengths = ["None", "8mm fisheye", "12mm", "24mm", "35mm", "50mm", "85mm", "135mm", "200mm"];
365
- const apertures = ["None", "f/1.2", "f/1.8", "f/2.8", "f/5.6", "f/8", "f/11", "f/16"];
366
- const shutterSpeeds = ["None", "1/8000s", "1/250s", "1/30s", "5s"];
367
- const whiteBalances = ["None", "3200K tungsten", "5600K daylight", "7000K shade"];
368
- const angles = ["None", "eye level", "low angle", "high angle", "Dutch tilt", "bird's eye"];
 
 
 
 
 
369
 
370
  return (
371
  <div className="nb-node absolute text-white w-[360px]" style={{ left: localPos.x, top: localPos.y }}>
@@ -382,7 +516,9 @@ export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, on
382
  <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
383
  </div>
384
  </div>
385
- <div className="p-3 space-y-2">
 
 
386
  <div className="grid grid-cols-2 gap-2">
387
  <div>
388
  <label className="text-xs text-white/70">Focal Length</label>
@@ -405,7 +541,7 @@ export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, on
405
  </select>
406
  </div>
407
  <div>
408
- <label className="text-xs text-white/70">Shutter</label>
409
  <select
410
  className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
411
  value={node.shutterSpeed || "None"}
@@ -414,6 +550,21 @@ export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, on
414
  {shutterSpeeds.map(s => <option key={s} value={s}>{s}</option>)}
415
  </select>
416
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
  <div>
418
  <label className="text-xs text-white/70">White Balance</label>
419
  <select
@@ -424,25 +575,81 @@ export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, on
424
  {whiteBalances.map(w => <option key={w} value={w}>{w}</option>)}
425
  </select>
426
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
  </div>
428
- <div>
429
- <label className="text-xs text-white/70">Camera Angle</label>
 
 
 
 
430
  <select
431
  className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
432
  value={node.angle || "None"}
433
  onChange={(e) => onUpdate(node.id, { angle: e.target.value })}
434
- >
435
- {angles.map(a => <option key={a} value={a}>{a}</option>)}
436
- </select>
 
 
 
 
 
 
 
 
 
 
 
437
  </div>
438
  <button
439
- className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1"
440
  onClick={() => onProcess(node.id)}
441
  disabled={node.isRunning}
 
442
  >
443
  {node.isRunning ? "Processing..." : "Apply Camera Settings"}
444
  </button>
445
- {node.output && <img src={node.output} className="w-full rounded mt-2" alt="Output" />}
 
 
 
 
 
 
 
 
 
 
446
  {node.error && (
447
  <div className="text-xs text-red-400 mt-2">{node.error}</div>
448
  )}
@@ -546,13 +753,24 @@ export function FaceNodeView({ node, onDelete, onUpdate, onStartConnection, onEn
546
  </div>
547
 
548
  <button
549
- className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1"
550
  onClick={() => onProcess(node.id)}
551
  disabled={node.isRunning}
 
552
  >
553
  {node.isRunning ? "Processing..." : "Apply Face Changes"}
554
  </button>
555
- {node.output && <img src={node.output} className="w-full rounded mt-2" alt="Output" />}
 
 
 
 
 
 
 
 
 
 
556
  {node.error && (
557
  <div className="text-xs text-red-400 mt-2">{node.error}</div>
558
  )}
@@ -660,13 +878,24 @@ export function BlendNodeView({ node, onDelete, onUpdate, onStartConnection, onE
660
  />
661
  </div>
662
  <button
663
- className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1"
664
  onClick={() => onProcess(node.id)}
665
  disabled={node.isRunning || !node.styleImage}
 
666
  >
667
  {node.isRunning ? "Processing..." : "Apply Style"}
668
  </button>
669
- {node.output && <img src={node.output} className="w-full rounded" alt="Output" />}
 
 
 
 
 
 
 
 
 
 
670
  {node.error && (
671
  <div className="text-xs text-red-400 mt-2">{node.error}</div>
672
  )}
@@ -702,13 +931,24 @@ export function EditNodeView({ node, onDelete, onUpdate, onStartConnection, onEn
702
  rows={3}
703
  />
704
  <button
705
- className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1"
706
  onClick={() => onProcess(node.id)}
707
  disabled={node.isRunning}
 
708
  >
709
  {node.isRunning ? "Processing..." : "Apply Edit"}
710
  </button>
711
- {node.output && <img src={node.output} className="w-full rounded" alt="Output" />}
 
 
 
 
 
 
 
 
 
 
712
  </div>
713
  </div>
714
  );
 
2
 
3
  import React, { useState, useRef, useEffect } from "react";
4
 
5
+ // Helper function to download image
6
+ function downloadImage(dataUrl: string, filename: string) {
7
+ const link = document.createElement('a');
8
+ link.href = dataUrl;
9
+ link.download = filename;
10
+ document.body.appendChild(link);
11
+ link.click();
12
+ document.body.removeChild(link);
13
+ }
14
+
15
  // Import types (we'll need to export these from page.tsx)
16
  type BackgroundNode = any;
17
  type ClothesNode = any;
 
108
  onUpdatePosition,
109
  }: any) {
110
  const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
111
+ const hasConfig = node.backgroundType && !node.output;
112
+
113
+ const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
114
+ if (e.target.files?.length) {
115
+ const reader = new FileReader();
116
+ reader.onload = () => {
117
+ onUpdate(node.id, { customBackgroundImage: reader.result });
118
+ };
119
+ reader.readAsDataURL(e.target.files[0]);
120
+ }
121
+ };
122
+
123
+ const handleImagePaste = (e: React.ClipboardEvent) => {
124
+ const items = e.clipboardData.items;
125
+ for (let i = 0; i < items.length; i++) {
126
+ if (items[i].type.startsWith("image/")) {
127
+ const file = items[i].getAsFile();
128
+ if (file) {
129
+ const reader = new FileReader();
130
+ reader.onload = () => {
131
+ onUpdate(node.id, { customBackgroundImage: reader.result });
132
+ };
133
+ reader.readAsDataURL(file);
134
+ return;
135
+ }
136
+ }
137
+ }
138
+ const text = e.clipboardData.getData("text");
139
+ if (text && (text.startsWith("http") || text.startsWith("data:image"))) {
140
+ onUpdate(node.id, { customBackgroundImage: text });
141
+ }
142
+ };
143
+
144
+ const handleDrop = (e: React.DragEvent) => {
145
+ e.preventDefault();
146
+ const files = e.dataTransfer.files;
147
+ if (files && files.length) {
148
+ const reader = new FileReader();
149
+ reader.onload = () => {
150
+ onUpdate(node.id, { customBackgroundImage: reader.result });
151
+ };
152
+ reader.readAsDataURL(files[0]);
153
+ }
154
+ };
155
 
156
  return (
157
+ <div
158
+ className={`nb-node absolute text-white w-[320px] ${hasConfig ? 'ring-2 ring-yellow-500/50' : ''}`}
159
+ style={{ left: localPos.x, top: localPos.y }}
160
+ onDrop={handleDrop}
161
+ onDragOver={(e) => e.preventDefault()}
162
+ onPaste={handleImagePaste}
163
+ >
164
  <div
165
  className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
166
  onPointerDown={onPointerDown}
 
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
 
 
210
  </select>
211
  )}
212
 
213
+ {node.backgroundType === "upload" && (
214
+ <div className="space-y-2">
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">
227
+ <input
228
+ type="file"
229
+ accept="image/*"
230
+ className="hidden"
231
+ onChange={handleImageUpload}
232
+ />
233
+ <div className="border-2 border-dashed border-white/20 rounded-lg p-4 text-center cursor-pointer hover:border-white/40">
234
+ <p className="text-xs text-white/60">Drop, upload, or paste background image</p>
235
+ <p className="text-xs text-white/40 mt-1">JPG, PNG, WEBP</p>
236
+ </div>
237
+ </label>
238
+ )}
239
+ </div>
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"
 
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 && (
273
  <div className="text-xs text-red-400 mt-2">{node.error}</div>
 
279
 
280
  export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
281
  const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
282
+ const hasConfig = node.clothesImage && !node.output;
283
 
284
  const presetClothes = [
285
  { name: "Sukajan", path: "/sukajan.png" },
 
321
 
322
  return (
323
  <div
324
+ className={`nb-node absolute text-white w-[320px] ${hasConfig ? 'ring-2 ring-yellow-500/50' : ''}`}
325
  style={{ left: localPos.x, top: localPos.y }}
326
  onDrop={onDrop}
327
  onDragOver={(e) => e.preventDefault()}
328
  onPaste={onPaste}
329
+ title={hasConfig ? "Has unsaved configuration - will be applied when processing downstream" : ""}
330
  >
331
  <div
332
  className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
 
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
348
+ </div>
349
+ )}
350
  <div className="text-xs text-white/70">Clothes Reference</div>
351
 
352
  {/* Preset clothes options */}
 
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 && (
423
  <div className="text-xs text-red-400 mt-2">{node.error}</div>
424
  )}
 
429
 
430
  export function AgeNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
431
  const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
432
+ const hasConfig = node.targetAge && node.targetAge !== 30 && !node.output;
433
 
434
  return (
435
+ <div className={`nb-node absolute text-white w-[280px] ${hasConfig ? 'ring-2 ring-yellow-500/50' : ''}`} style={{ left: localPos.x, top: localPos.y }}>
436
  <div
437
  className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
438
  onPointerDown={onPointerDown}
 
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 && (
484
  <div className="text-xs text-red-400 mt-2">{node.error}</div>
485
  )}
 
490
 
491
  export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
492
  const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
493
+ const focalLengths = ["None", "8mm fisheye", "12mm", "24mm", "35mm", "50mm", "85mm", "135mm", "200mm", "300mm", "400mm"];
494
+ const apertures = ["None", "f/0.95", "f/1.2", "f/1.4", "f/1.8", "f/2", "f/2.8", "f/4", "f/5.6", "f/8", "f/11", "f/16", "f/22"];
495
+ const shutterSpeeds = ["None", "1/8000s", "1/4000s", "1/2000s", "1/1000s", "1/500s", "1/250s", "1/125s", "1/60s", "1/30s", "1/15s", "1/8s", "1/4s", "1/2s", "1s", "2s", "5s", "10s", "30s"];
496
+ const whiteBalances = ["None", "2800K candlelight", "3200K tungsten", "4000K fluorescent", "5600K daylight", "6500K cloudy", "7000K shade", "8000K blue sky"];
497
+ const angles = ["None", "eye level", "low angle", "high angle", "Dutch tilt", "bird's eye", "worm's eye", "over the shoulder", "POV"];
498
+ const isoValues = ["None", "ISO 50", "ISO 100", "ISO 200", "ISO 400", "ISO 800", "ISO 1600", "ISO 3200", "ISO 6400", "ISO 12800"];
499
+ const filmStyles = ["None", "Kodak Portra", "Fuji Velvia", "Ilford HP5", "Cinestill 800T", "Lomography", "Cross Process", "Black & White", "Sepia", "Vintage", "Film Noir"];
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 }}>
 
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>
 
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"}
 
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
+
565
+ {/* Creative Settings */}
566
+ <div className="text-xs text-white/50 font-semibold mb-1 mt-3">Creative Settings</div>
567
+ <div className="grid grid-cols-2 gap-2">
568
  <div>
569
  <label className="text-xs text-white/70">White Balance</label>
570
  <select
 
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
+
610
+ {/* Composition Settings */}
611
+ <div className="text-xs text-white/50 font-semibold mb-1 mt-3">Composition</div>
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 && (
654
  <div className="text-xs text-red-400 mt-2">{node.error}</div>
655
  )}
 
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 && (
775
  <div className="text-xs text-red-400 mt-2">{node.error}</div>
776
  )}
 
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 && (
900
  <div className="text-xs text-red-400 mt-2">{node.error}</div>
901
  )}
 
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>
953
  </div>
954
  );
app/editor/page.tsx CHANGED
@@ -65,9 +65,11 @@ type BackgroundNode = NodeBase & {
65
  type: "BACKGROUND";
66
  input?: string; // node id
67
  output?: string;
68
- backgroundType: "color" | "image" | "custom";
69
  backgroundColor?: string;
70
  backgroundImage?: string;
 
 
71
  isRunning?: boolean;
72
  error?: string | null;
73
  };
@@ -77,6 +79,7 @@ type ClothesNode = NodeBase & {
77
  input?: string;
78
  output?: string;
79
  clothesImage?: string;
 
80
  clothesPrompt?: string;
81
  isRunning?: boolean;
82
  error?: string | null;
@@ -86,6 +89,7 @@ type BlendNode = NodeBase & {
86
  type: "BLEND";
87
  input?: string;
88
  output?: string;
 
89
  stylePrompt?: string;
90
  blendStrength?: number;
91
  isRunning?: boolean;
@@ -110,6 +114,11 @@ type CameraNode = NodeBase & {
110
  shutterSpeed?: string;
111
  whiteBalance?: string;
112
  angle?: string;
 
 
 
 
 
113
  isRunning?: boolean;
114
  error?: string | null;
115
  };
@@ -399,6 +408,7 @@ function MergeNodeView({
399
  onDisconnect,
400
  onRun,
401
  onEndConnection,
 
402
  onUpdatePosition,
403
  onDelete,
404
  onClearConnections,
@@ -409,6 +419,7 @@ function MergeNodeView({
409
  onDisconnect: (mergeId: string, characterId: string) => void;
410
  onRun: (mergeId: string) => void;
411
  onEndConnection: (mergeId: string) => void;
 
412
  onUpdatePosition: (id: string, x: number, y: number) => void;
413
  onDelete: (id: string) => void;
414
  onClearConnections: (mergeId: string) => void;
@@ -435,19 +446,27 @@ function MergeNodeView({
435
  isOutput={false}
436
  onEndConnection={onEndConnection}
437
  />
438
- <div className="font-semibold tracking-wide text-sm flex-1">MERGE</div>
439
- <button
440
- 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"
441
- onClick={(e) => {
442
- e.stopPropagation();
443
- if (confirm('Delete MERGE node?')) {
444
- onDelete(node.id);
445
- }
446
- }}
447
- title="Delete node"
448
- >
449
- Γ—
450
- </button>
 
 
 
 
 
 
 
 
451
  </div>
452
  <div className="p-3 space-y-3">
453
  <div className="text-xs text-white/70">Inputs</div>
@@ -502,6 +521,21 @@ function MergeNodeView({
502
  <span className="text-white/40 text-xs">Run merge to see result</span>
503
  )}
504
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
505
  {node.error && (
506
  <div className="mt-2">
507
  <div className="text-xs text-red-400">{node.error}</div>
@@ -630,15 +664,17 @@ export default function EditorPage() {
630
  // Handle single input connections for new nodes
631
  const handleEndSingleConnection = (nodeId: string) => {
632
  if (draggingFrom) {
633
- // Find the source node to get its output
634
  const sourceNode = nodes.find(n => n.id === draggingFrom);
635
- if (sourceNode && (sourceNode as any).output) {
636
- // Connect the output to this node's input
637
- setNodes(prev => prev.map(n =>
638
- n.id === nodeId ? { ...n, input: draggingFrom } : n
639
- ));
640
- } else if (sourceNode?.type === "CHARACTER") {
641
- // Direct connection from CHARACTER node
 
 
642
  setNodes(prev => prev.map(n =>
643
  n.id === nodeId ? { ...n, input: draggingFrom } : n
644
  ));
@@ -648,6 +684,112 @@ export default function EditorPage() {
648
  }
649
  };
650
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
651
  // Process node with API
652
  const processNode = async (nodeId: string) => {
653
  const node = nodes.find(n => n.id === nodeId);
@@ -656,24 +798,156 @@ export default function EditorPage() {
656
  return;
657
  }
658
 
659
- // Get input image
660
  let inputImage: string | null = null;
 
 
661
  const inputId = (node as any).input;
662
 
663
  if (inputId) {
664
- const inputNode = nodes.find(n => n.id === inputId);
665
- if (inputNode) {
666
- if (inputNode.type === "CHARACTER") {
667
- inputImage = (inputNode as CharacterNode).image;
668
- } else if ((inputNode as any).output) {
669
- inputImage = (inputNode as any).output;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
670
  }
671
  }
 
 
 
672
  }
673
 
674
  if (!inputImage) {
675
  const errorMsg = inputId
676
- ? "Connected node has no output image. Process the previous node first."
677
  : "No input connected. Connect an image source to this node.";
678
  setNodes(prev => prev.map(n =>
679
  n.id === nodeId ? { ...n, error: errorMsg, isRunning: false } : n
@@ -681,54 +955,37 @@ export default function EditorPage() {
681
  return;
682
  }
683
 
684
- console.log("Processing node:", node.type, "with image:", inputImage.substring(0, 50) + "...");
685
-
686
- // Set loading state
687
- setNodes(prev => prev.map(n =>
688
- n.id === nodeId ? { ...n, isRunning: true, error: null } : n
689
- ));
 
 
 
 
 
 
 
 
 
690
 
691
- try {
692
- const params: any = {};
693
-
694
- // Build params based on node type
695
- switch (node.type) {
696
- case "BACKGROUND":
697
- params.backgroundType = (node as BackgroundNode).backgroundType;
698
- params.backgroundColor = (node as BackgroundNode).backgroundColor;
699
- params.backgroundImage = (node as BackgroundNode).backgroundImage;
700
- params.customPrompt = (node as BackgroundNode).customPrompt;
701
- break;
702
- case "CLOTHES":
703
- params.clothesPrompt = (node as ClothesNode).clothesPrompt;
704
- break;
705
- case "BLEND":
706
- params.stylePrompt = (node as BlendNode).stylePrompt;
707
- params.blendStrength = (node as BlendNode).blendStrength;
708
- break;
709
- case "EDIT":
710
- params.editPrompt = (node as EditNode).editPrompt;
711
- break;
712
- case "CAMERA":
713
- params.focalLength = (node as CameraNode).focalLength;
714
- params.aperture = (node as CameraNode).aperture;
715
- params.shutterSpeed = (node as CameraNode).shutterSpeed;
716
- params.whiteBalance = (node as CameraNode).whiteBalance;
717
- params.angle = (node as CameraNode).angle;
718
- break;
719
- case "AGE":
720
- params.targetAge = (node as AgeNode).targetAge;
721
- break;
722
- case "FACE":
723
- params.faceOptions = (node as FaceNode).faceOptions;
724
- break;
725
  }
 
 
726
 
 
 
727
  const res = await fetch("/api/process", {
728
  method: "POST",
729
  headers: { "Content-Type": "application/json" },
730
  body: JSON.stringify({
731
- type: node.type,
732
  image: inputImage,
733
  params
734
  }),
@@ -737,14 +994,33 @@ export default function EditorPage() {
737
  const data = await res.json();
738
  if (!res.ok) throw new Error(data.error || "Processing failed");
739
 
740
- setNodes(prev => prev.map(n =>
741
- n.id === nodeId ? { ...n, output: data.image, isRunning: false, error: null } : n
742
- ));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
743
  } catch (e: any) {
744
  console.error("Process error:", e);
745
- setNodes(prev => prev.map(n =>
746
- n.id === nodeId ? { ...n, isRunning: false, error: e?.message || "Error" } : n
747
- ));
 
 
 
 
748
  }
749
  };
750
 
@@ -795,6 +1071,51 @@ export default function EditorPage() {
795
  );
796
  };
797
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
798
  const runMerge = async (mergeId: string) => {
799
  setNodes((prev) => prev.map((n) => (n.id === mergeId && n.type === "MERGE" ? { ...n, isRunning: true, error: null } : n)));
800
  try {
@@ -850,7 +1171,7 @@ export default function EditorPage() {
850
  MERGE: 380,
851
  BACKGROUND: 320,
852
  CLOTHES: 320,
853
- BLEND: 300,
854
  EDIT: 320,
855
  CAMERA: 360,
856
  AGE: 280,
@@ -1087,6 +1408,7 @@ export default function EditorPage() {
1087
  onDisconnect={disconnectFromMerge}
1088
  onRun={runMerge}
1089
  onEndConnection={handleEndConnection}
 
1090
  onUpdatePosition={updateNodePosition}
1091
  onDelete={deleteNode}
1092
  onClearConnections={clearMergeConnections}
 
65
  type: "BACKGROUND";
66
  input?: string; // node id
67
  output?: string;
68
+ backgroundType: "color" | "image" | "upload" | "custom";
69
  backgroundColor?: string;
70
  backgroundImage?: string;
71
+ customBackgroundImage?: string;
72
+ customPrompt?: string;
73
  isRunning?: boolean;
74
  error?: string | null;
75
  };
 
79
  input?: string;
80
  output?: string;
81
  clothesImage?: string;
82
+ selectedPreset?: string;
83
  clothesPrompt?: string;
84
  isRunning?: boolean;
85
  error?: string | null;
 
89
  type: "BLEND";
90
  input?: string;
91
  output?: string;
92
+ styleImage?: string;
93
  stylePrompt?: string;
94
  blendStrength?: number;
95
  isRunning?: boolean;
 
114
  shutterSpeed?: string;
115
  whiteBalance?: string;
116
  angle?: string;
117
+ iso?: string;
118
+ filmStyle?: string;
119
+ lighting?: string;
120
+ bokeh?: string;
121
+ composition?: string;
122
  isRunning?: boolean;
123
  error?: string | null;
124
  };
 
408
  onDisconnect,
409
  onRun,
410
  onEndConnection,
411
+ onStartConnection,
412
  onUpdatePosition,
413
  onDelete,
414
  onClearConnections,
 
419
  onDisconnect: (mergeId: string, characterId: string) => void;
420
  onRun: (mergeId: string) => void;
421
  onEndConnection: (mergeId: string) => void;
422
+ onStartConnection: (nodeId: string) => void;
423
  onUpdatePosition: (id: string, x: number, y: number) => void;
424
  onDelete: (id: string) => void;
425
  onClearConnections: (mergeId: string) => void;
 
446
  isOutput={false}
447
  onEndConnection={onEndConnection}
448
  />
449
+ <div className="font-semibold tracking-wide text-sm flex-1 text-center">MERGE</div>
450
+ <div className="flex items-center gap-2">
451
+ <button
452
+ 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"
453
+ onClick={(e) => {
454
+ e.stopPropagation();
455
+ if (confirm('Delete MERGE node?')) {
456
+ onDelete(node.id);
457
+ }
458
+ }}
459
+ title="Delete node"
460
+ >
461
+ Γ—
462
+ </button>
463
+ <Port
464
+ className="out"
465
+ nodeId={node.id}
466
+ isOutput={true}
467
+ onStartConnection={onStartConnection}
468
+ />
469
+ </div>
470
  </div>
471
  <div className="p-3 space-y-3">
472
  <div className="text-xs text-white/70">Inputs</div>
 
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;
530
+ link.download = `merge-${Date.now()}.png`;
531
+ document.body.appendChild(link);
532
+ link.click();
533
+ document.body.removeChild(link);
534
+ }}
535
+ >
536
+ πŸ“₯ Download Merged Image
537
+ </button>
538
+ )}
539
  {node.error && (
540
  <div className="mt-2">
541
  <div className="text-xs text-red-400">{node.error}</div>
 
664
  // Handle single input connections for new nodes
665
  const handleEndSingleConnection = (nodeId: string) => {
666
  if (draggingFrom) {
667
+ // Find the source node
668
  const sourceNode = nodes.find(n => n.id === draggingFrom);
669
+ if (sourceNode) {
670
+ // Allow connections from ANY node that has an output port
671
+ // This includes:
672
+ // - CHARACTER nodes (always have an image)
673
+ // - MERGE nodes (can have output after merging)
674
+ // - Any processing node (BACKGROUND, CLOTHES, BLEND, etc.)
675
+ // - Even unprocessed nodes (for configuration chaining)
676
+
677
+ // All nodes can be connected for chaining
678
  setNodes(prev => prev.map(n =>
679
  n.id === nodeId ? { ...n, input: draggingFrom } : n
680
  ));
 
684
  }
685
  };
686
 
687
+ // Helper to count pending configurations in chain
688
+ const countPendingConfigurations = (startNodeId: string): number => {
689
+ let count = 0;
690
+ const visited = new Set<string>();
691
+
692
+ const traverse = (nodeId: string) => {
693
+ if (visited.has(nodeId)) return;
694
+ visited.add(nodeId);
695
+
696
+ const node = nodes.find(n => n.id === nodeId);
697
+ if (!node) return;
698
+
699
+ // Check if this node has configuration but no output
700
+ if (!(node as any).output && node.type !== "CHARACTER" && node.type !== "MERGE") {
701
+ const config = getNodeConfiguration(node);
702
+ if (Object.keys(config).length > 0) {
703
+ count++;
704
+ }
705
+ }
706
+
707
+ // Check upstream
708
+ const upstreamId = (node as any).input;
709
+ if (upstreamId) {
710
+ traverse(upstreamId);
711
+ }
712
+ };
713
+
714
+ traverse(startNodeId);
715
+ return count;
716
+ };
717
+
718
+ // Helper to extract configuration from a node
719
+ const getNodeConfiguration = (node: AnyNode): any => {
720
+ const config: any = {};
721
+
722
+ switch (node.type) {
723
+ case "BACKGROUND":
724
+ if ((node as BackgroundNode).backgroundType) {
725
+ config.backgroundType = (node as BackgroundNode).backgroundType;
726
+ config.backgroundColor = (node as BackgroundNode).backgroundColor;
727
+ config.backgroundImage = (node as BackgroundNode).backgroundImage;
728
+ config.customBackgroundImage = (node as BackgroundNode).customBackgroundImage;
729
+ config.customPrompt = (node as BackgroundNode).customPrompt;
730
+ }
731
+ break;
732
+ case "CLOTHES":
733
+ if ((node as ClothesNode).clothesImage) {
734
+ config.clothesImage = (node as ClothesNode).clothesImage;
735
+ config.selectedPreset = (node as ClothesNode).selectedPreset;
736
+ }
737
+ break;
738
+ case "BLEND":
739
+ if ((node as BlendNode).styleImage) {
740
+ config.styleImage = (node as BlendNode).styleImage;
741
+ config.blendStrength = (node as BlendNode).blendStrength;
742
+ }
743
+ break;
744
+ case "EDIT":
745
+ if ((node as EditNode).editPrompt) {
746
+ config.editPrompt = (node as EditNode).editPrompt;
747
+ }
748
+ break;
749
+ case "CAMERA":
750
+ const cam = node as CameraNode;
751
+ if (cam.focalLength && cam.focalLength !== "None") config.focalLength = cam.focalLength;
752
+ if (cam.aperture && cam.aperture !== "None") config.aperture = cam.aperture;
753
+ if (cam.shutterSpeed && cam.shutterSpeed !== "None") config.shutterSpeed = cam.shutterSpeed;
754
+ if (cam.whiteBalance && cam.whiteBalance !== "None") config.whiteBalance = cam.whiteBalance;
755
+ if (cam.angle && cam.angle !== "None") config.angle = cam.angle;
756
+ if (cam.iso && cam.iso !== "None") config.iso = cam.iso;
757
+ if (cam.filmStyle && cam.filmStyle !== "None") config.filmStyle = cam.filmStyle;
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) {
764
+ config.targetAge = (node as AgeNode).targetAge;
765
+ }
766
+ break;
767
+ case "FACE":
768
+ const face = node as FaceNode;
769
+ if (face.faceOptions) {
770
+ const opts: any = {};
771
+ if (face.faceOptions.removePimples) opts.removePimples = true;
772
+ if (face.faceOptions.addSunglasses) opts.addSunglasses = true;
773
+ if (face.faceOptions.addHat) opts.addHat = true;
774
+ if (face.faceOptions.changeHairstyle && face.faceOptions.changeHairstyle !== "None") {
775
+ opts.changeHairstyle = face.faceOptions.changeHairstyle;
776
+ }
777
+ if (face.faceOptions.facialExpression && face.faceOptions.facialExpression !== "None") {
778
+ opts.facialExpression = face.faceOptions.facialExpression;
779
+ }
780
+ if (face.faceOptions.beardStyle && face.faceOptions.beardStyle !== "None") {
781
+ opts.beardStyle = face.faceOptions.beardStyle;
782
+ }
783
+ if (Object.keys(opts).length > 0) {
784
+ config.faceOptions = opts;
785
+ }
786
+ }
787
+ break;
788
+ }
789
+
790
+ return config;
791
+ };
792
+
793
  // Process node with API
794
  const processNode = async (nodeId: string) => {
795
  const node = nodes.find(n => n.id === nodeId);
 
798
  return;
799
  }
800
 
801
+ // Get input image and collect all configurations from chain
802
  let inputImage: string | null = null;
803
+ let accumulatedParams: any = {};
804
+ const processedNodes: string[] = []; // Track which nodes' configs we're applying
805
  const inputId = (node as any).input;
806
 
807
  if (inputId) {
808
+ // Track unprocessed MERGE nodes that need to be executed
809
+ const unprocessedMerges: MergeNode[] = [];
810
+
811
+ // Find the source image by traversing the chain backwards
812
+ const findSourceImage = (currentNodeId: string, visited: Set<string> = new Set()): string | null => {
813
+ if (visited.has(currentNodeId)) return null;
814
+ visited.add(currentNodeId);
815
+
816
+ const currentNode = nodes.find(n => n.id === currentNodeId);
817
+ if (!currentNode) return null;
818
+
819
+ // If this is a CHARACTER node, return its image
820
+ if (currentNode.type === "CHARACTER") {
821
+ return (currentNode as CharacterNode).image;
822
+ }
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
830
+ if ((currentNode as any).output) {
831
+ return (currentNode as any).output;
832
+ }
833
+
834
+ // For MERGE nodes without output, we need to process them first
835
+ if (currentNode.type === "MERGE") {
836
+ const merge = currentNode as MergeNode;
837
+ if (!merge.output && merge.inputs.length >= 2) {
838
+ // Mark this merge for processing
839
+ unprocessedMerges.push(merge);
840
+ // For now, return null - we'll process the merge first
841
+ return null;
842
+ } else if (merge.inputs.length > 0) {
843
+ // Try to get image from first input if merge can't be executed
844
+ const firstInput = merge.inputs[0];
845
+ const inputImage = findSourceImage(firstInput, visited);
846
+ if (inputImage) return inputImage;
847
+ }
848
+ }
849
+
850
+ // Otherwise, check upstream
851
+ const upstreamId = (currentNode as any).input;
852
+ if (upstreamId) {
853
+ return findSourceImage(upstreamId, visited);
854
+ }
855
+
856
+ return null;
857
+ };
858
+
859
+ // Collect all configurations from unprocessed nodes in the chain
860
+ const collectConfigurations = (currentNodeId: string, visited: Set<string> = new Set()): any => {
861
+ if (visited.has(currentNodeId)) return {};
862
+ visited.add(currentNodeId);
863
+
864
+ const currentNode = nodes.find(n => n.id === currentNodeId);
865
+ if (!currentNode) return {};
866
+
867
+ let configs: any = {};
868
+
869
+ // First, collect from upstream nodes
870
+ const upstreamId = (currentNode as any).input;
871
+ if (upstreamId) {
872
+ configs = collectConfigurations(upstreamId, visited);
873
+ }
874
+
875
+ // Add this node's configuration only if:
876
+ // 1. It's the current node being processed, OR
877
+ // 2. It hasn't been processed yet (no output) AND it's not the current node
878
+ const shouldIncludeConfig =
879
+ currentNodeId === nodeId || // Always include current node's config
880
+ (!(currentNode as any).output && currentNodeId !== nodeId); // Include unprocessed intermediate nodes
881
+
882
+ if (shouldIncludeConfig) {
883
+ const nodeConfig = getNodeConfiguration(currentNode);
884
+ if (Object.keys(nodeConfig).length > 0) {
885
+ configs = { ...configs, ...nodeConfig };
886
+ // Track unprocessed intermediate nodes
887
+ if (currentNodeId !== nodeId && !(currentNode as any).output) {
888
+ processedNodes.push(currentNodeId);
889
+ }
890
+ }
891
+ }
892
+
893
+ return configs;
894
+ };
895
+
896
+ // Find the source image
897
+ inputImage = findSourceImage(inputId);
898
+
899
+ // If we found unprocessed merges, we need to execute them first
900
+ if (unprocessedMerges.length > 0 && !inputImage) {
901
+ console.log(`Found ${unprocessedMerges.length} unprocessed MERGE nodes in chain. Processing them first...`);
902
+
903
+ // Process each merge node
904
+ for (const merge of unprocessedMerges) {
905
+ // Set loading state for the merge
906
+ setNodes(prev => prev.map(n =>
907
+ n.id === merge.id ? { ...n, isRunning: true, error: null } : n
908
+ ));
909
+
910
+ try {
911
+ const mergeOutput = await executeMerge(merge);
912
+
913
+ // Update the merge node with output
914
+ setNodes(prev => prev.map(n =>
915
+ n.id === merge.id ? { ...n, output: mergeOutput, isRunning: false, error: null } : n
916
+ ));
917
+
918
+ // Track that we processed this merge as part of the chain
919
+ processedNodes.push(merge.id);
920
+
921
+ // Now use this as our input image if it's the direct input
922
+ if (inputId === merge.id) {
923
+ inputImage = mergeOutput;
924
+ }
925
+ } catch (e: any) {
926
+ console.error("Auto-merge error:", e);
927
+ setNodes(prev => prev.map(n =>
928
+ n.id === merge.id ? { ...n, isRunning: false, error: e?.message || "Merge failed" } : n
929
+ ));
930
+ // Abort the main processing if merge failed
931
+ setNodes(prev => prev.map(n =>
932
+ n.id === nodeId ? { ...n, error: "Failed to process upstream MERGE node", isRunning: false } : n
933
+ ));
934
+ return;
935
+ }
936
+ }
937
+
938
+ // After processing merges, try to find the source image again
939
+ if (!inputImage) {
940
+ inputImage = findSourceImage(inputId);
941
  }
942
  }
943
+
944
+ // Collect configurations from the chain
945
+ accumulatedParams = collectConfigurations(inputId, new Set());
946
  }
947
 
948
  if (!inputImage) {
949
  const errorMsg = inputId
950
+ ? "No source image found in the chain. Connect to a CHARACTER node or processed node."
951
  : "No input connected. Connect an image source to this node.";
952
  setNodes(prev => prev.map(n =>
953
  n.id === nodeId ? { ...n, error: errorMsg, isRunning: false } : n
 
955
  return;
956
  }
957
 
958
+ // Add current node's configuration
959
+ const currentNodeConfig = getNodeConfiguration(node);
960
+ const params = { ...accumulatedParams, ...currentNodeConfig };
961
+
962
+ // Count how many unprocessed nodes we're combining
963
+ const unprocessedNodeCount = Object.keys(params).length > 0 ?
964
+ (processedNodes.length + 1) : 1;
965
+
966
+ // Show info about batch processing
967
+ if (unprocessedNodeCount > 1) {
968
+ console.log(`πŸš€ Combining ${unprocessedNodeCount} node transformations into ONE API call`);
969
+ console.log("Combined parameters:", params);
970
+ } else {
971
+ console.log("Processing single node:", node.type);
972
+ }
973
 
974
+ // Set loading state for all nodes being processed
975
+ setNodes(prev => prev.map(n => {
976
+ if (n.id === nodeId || processedNodes.includes(n.id)) {
977
+ return { ...n, isRunning: true, error: null };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
978
  }
979
+ return n;
980
+ }));
981
 
982
+ try {
983
+ // Make a SINGLE API call with all accumulated parameters
984
  const res = await fetch("/api/process", {
985
  method: "POST",
986
  headers: { "Content-Type": "application/json" },
987
  body: JSON.stringify({
988
+ type: "COMBINED", // Indicate this is a combined processing
989
  image: inputImage,
990
  params
991
  }),
 
994
  const data = await res.json();
995
  if (!res.ok) throw new Error(data.error || "Processing failed");
996
 
997
+ // Only update the current node with the output
998
+ // Don't show output in intermediate nodes - they were just used for configuration
999
+ setNodes(prev => prev.map(n => {
1000
+ if (n.id === nodeId) {
1001
+ // Only the current node gets the final output displayed
1002
+ return { ...n, output: data.image, isRunning: false, error: null };
1003
+ } else if (processedNodes.includes(n.id)) {
1004
+ // Mark intermediate nodes as no longer running but don't give them output
1005
+ // This way they remain unprocessed visually but their configs were used
1006
+ return { ...n, isRunning: false, error: null };
1007
+ }
1008
+ return n;
1009
+ }));
1010
+
1011
+ if (unprocessedNodeCount > 1) {
1012
+ console.log(`βœ… Successfully applied ${unprocessedNodeCount} transformations in ONE API call!`);
1013
+ console.log(`Saved ${unprocessedNodeCount - 1} API calls by combining transformations`);
1014
+ }
1015
  } catch (e: any) {
1016
  console.error("Process error:", e);
1017
+ // Clear loading state for all nodes
1018
+ setNodes(prev => prev.map(n => {
1019
+ if (n.id === nodeId || processedNodes.includes(n.id)) {
1020
+ return { ...n, isRunning: false, error: e?.message || "Error" };
1021
+ }
1022
+ return n;
1023
+ }));
1024
  }
1025
  };
1026
 
 
1071
  );
1072
  };
1073
 
1074
+ const executeMerge = async (merge: MergeNode): Promise<string | null> => {
1075
+ // Get images from merge inputs
1076
+ const mergeImages: string[] = [];
1077
+ const characterData: { image: string; label: string }[] = [];
1078
+
1079
+ for (const inputId of merge.inputs) {
1080
+ const inputNode = nodes.find(n => n.id === inputId);
1081
+ if (inputNode) {
1082
+ let image: string | null = null;
1083
+ let label = "";
1084
+
1085
+ if (inputNode.type === "CHARACTER") {
1086
+ image = (inputNode as CharacterNode).image;
1087
+ label = (inputNode as CharacterNode).label || "";
1088
+ } else if ((inputNode as any).output) {
1089
+ image = (inputNode as any).output;
1090
+ }
1091
+
1092
+ if (image) {
1093
+ mergeImages.push(image);
1094
+ characterData.push({ image, label: label || `Input ${mergeImages.length}` });
1095
+ }
1096
+ }
1097
+ }
1098
+
1099
+ if (mergeImages.length < 2) {
1100
+ throw new Error("Not enough valid inputs for merge");
1101
+ }
1102
+
1103
+ const prompt = generateMergePrompt(characterData);
1104
+
1105
+ const res = await fetch("/api/merge", {
1106
+ method: "POST",
1107
+ headers: { "Content-Type": "application/json" },
1108
+ body: JSON.stringify({ images: mergeImages, prompt }),
1109
+ });
1110
+
1111
+ const data = await res.json();
1112
+ if (!res.ok) {
1113
+ throw new Error(data.error || "Merge failed");
1114
+ }
1115
+
1116
+ return (data.images?.[0] as string) || null;
1117
+ };
1118
+
1119
  const runMerge = async (mergeId: string) => {
1120
  setNodes((prev) => prev.map((n) => (n.id === mergeId && n.type === "MERGE" ? { ...n, isRunning: true, error: null } : n)));
1121
  try {
 
1171
  MERGE: 380,
1172
  BACKGROUND: 320,
1173
  CLOTHES: 320,
1174
+ BLEND: 320,
1175
  EDIT: 320,
1176
  CAMERA: 360,
1177
  AGE: 280,
 
1408
  onDisconnect={disconnectFromMerge}
1409
  onRun={runMerge}
1410
  onEndConnection={handleEndConnection}
1411
+ onStartConnection={handleStartConnection}
1412
  onUpdatePosition={updateNodePosition}
1413
  onDelete={deleteNode}
1414
  onClearConnections={clearMergeConnections}