Reubencf commited on
Commit
396e67b
ยท
1 Parent(s): 113aa9f

final changes

Browse files
.gitignore CHANGED
@@ -39,3 +39,5 @@ yarn-error.log*
39
  # typescript
40
  *.tsbuildinfo
41
  next-env.d.ts
 
 
 
39
  # typescript
40
  *.tsbuildinfo
41
  next-env.d.ts
42
+
43
+ .vercel
app/api/process/route.ts CHANGED
@@ -13,7 +13,8 @@ export async function POST(req: NextRequest) {
13
  try {
14
  const body = await req.json() as {
15
  type: string;
16
- image: string;
 
17
  prompt?: string;
18
  params?: any;
19
  };
@@ -58,7 +59,90 @@ export async function POST(req: NextRequest) {
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);
 
13
  try {
14
  const body = await req.json() as {
15
  type: string;
16
+ image?: string;
17
+ images?: string[];
18
  prompt?: string;
19
  params?: any;
20
  };
 
59
  }
60
  };
61
 
62
+ // Handle MERGE node type separately
63
+ if (body.type === "MERGE") {
64
+ const imgs = body.images?.filter(Boolean) ?? [];
65
+ if (imgs.length < 2) {
66
+ return NextResponse.json(
67
+ { error: "MERGE requires at least two images" },
68
+ { status: 400 }
69
+ );
70
+ }
71
+
72
+ // Build parts array for merge: first the text prompt, then image inlineData parts
73
+ let mergePrompt = body.prompt;
74
+
75
+ if (!mergePrompt) {
76
+ mergePrompt = `MERGE TASK: Create a natural, cohesive group photo combining ALL subjects from ${imgs.length} provided images.
77
+
78
+ CRITICAL REQUIREMENTS:
79
+ 1. Extract ALL people/subjects from EACH image exactly as they appear
80
+ 2. Place them together in a SINGLE UNIFIED SCENE with:
81
+ - Consistent lighting direction and color temperature
82
+ - Matching shadows and ambient lighting
83
+ - Proper scale relationships (realistic relative sizes)
84
+ - Natural spacing as if they were photographed together
85
+ - Shared environment/background that looks cohesive
86
+
87
+ 3. Composition guidelines:
88
+ - Arrange subjects at similar depth (not one far behind another)
89
+ - Use natural group photo positioning (slight overlap is ok)
90
+ - Ensure all faces are clearly visible
91
+ - Create visual balance in the composition
92
+ - Apply consistent color grading across all subjects
93
+
94
+ 4. Environmental unity:
95
+ - Use a single, coherent background for all subjects
96
+ - Match the perspective as if taken with one camera
97
+ - Ensure ground plane continuity (all standing on same level)
98
+ - Apply consistent atmospheric effects (if any)
99
+
100
+ The result should look like all subjects were photographed together in the same place at the same time, NOT like separate images placed side by side.`;
101
+ } else {
102
+ // Even with custom prompt, append cohesion requirements
103
+ const enforcement = `\n\nIMPORTANT: Create a COHESIVE group photo where all subjects appear to be in the same scene with consistent lighting, scale, and environment. The result should look naturally photographed together, not composited.`;
104
+ mergePrompt = `${mergePrompt}${enforcement}`;
105
+ }
106
+
107
+ const mergeParts: any[] = [{ text: mergePrompt }];
108
+ for (const url of imgs) {
109
+ const parsed = await toInlineDataFromAny(url);
110
+ if (!parsed) {
111
+ console.error('[MERGE] Failed to parse image:', url.substring(0, 100));
112
+ continue;
113
+ }
114
+ mergeParts.push({ inlineData: { mimeType: parsed.mimeType, data: parsed.data } });
115
+ }
116
+
117
+ console.log(`[MERGE] Sending ${mergeParts.length - 1} images to model`);
118
+
119
+ const response = await ai.models.generateContent({
120
+ model: "gemini-2.5-flash-image-preview",
121
+ contents: mergeParts,
122
+ });
123
+
124
+ const outParts = (response as any)?.candidates?.[0]?.content?.parts ?? [];
125
+ const images: string[] = [];
126
+ const texts: string[] = [];
127
+ for (const p of outParts) {
128
+ if (p?.inlineData?.data) {
129
+ images.push(`data:image/png;base64,${p.inlineData.data}`);
130
+ } else if (p?.text) {
131
+ texts.push(p.text);
132
+ }
133
+ }
134
+
135
+ if (!images.length) {
136
+ return NextResponse.json(
137
+ { error: "Model returned no image", text: texts.join("\n") },
138
+ { status: 500 }
139
+ );
140
+ }
141
+
142
+ return NextResponse.json({ image: images[0], images, text: texts.join("\n") });
143
+ }
144
+
145
+ // Parse input image for non-merge nodes
146
  let parsed = null as null | { mimeType: string; data: string };
147
  if (body.image) {
148
  parsed = await toInlineDataFromAny(body.image);
app/editor/editor.css ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Node editor custom styles and animations */
2
+
3
+ /* Animated connection lines */
4
+ @keyframes flow {
5
+ 0% {
6
+ stroke-dashoffset: 0;
7
+ }
8
+ 100% {
9
+ stroke-dashoffset: -20;
10
+ }
11
+ }
12
+
13
+ .connection-animated {
14
+ animation: flow 1s linear infinite;
15
+ stroke-dasharray: 5, 5;
16
+ }
17
+
18
+ /* Processing pulse effect */
19
+ @keyframes processingPulse {
20
+ 0%, 100% {
21
+ opacity: 1;
22
+ }
23
+ 50% {
24
+ opacity: 0.6;
25
+ }
26
+ }
27
+
28
+ .connection-processing {
29
+ animation: processingPulse 1.5s ease-in-out infinite;
30
+ stroke: #22c55e;
31
+ stroke-width: 3;
32
+ filter: drop-shadow(0 0 3px rgba(34, 197, 94, 0.5));
33
+ }
34
+
35
+ /* Flow particles effect */
36
+ @keyframes flowParticle {
37
+ 0% {
38
+ offset-distance: 0%;
39
+ opacity: 0;
40
+ }
41
+ 10% {
42
+ opacity: 1;
43
+ }
44
+ 90% {
45
+ opacity: 1;
46
+ }
47
+ 100% {
48
+ offset-distance: 100%;
49
+ opacity: 0;
50
+ }
51
+ }
52
+
53
+ .flow-particle {
54
+ animation: flowParticle 2s linear infinite;
55
+ }
56
+
57
+ /* Node processing state */
58
+ .nb-node.processing {
59
+ animation: processingPulse 1.5s ease-in-out infinite;
60
+ }
61
+
62
+ .nb-node.processing .nb-header {
63
+ background: linear-gradient(90deg, rgba(34, 197, 94, 0.2), rgba(34, 197, 94, 0.1));
64
+ }
app/editor/nodes.tsx CHANGED
@@ -115,7 +115,6 @@ export function BackgroundNodeView({
115
  onUpdatePosition,
116
  }: any) {
117
  const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
118
- const hasConfig = node.backgroundType && !node.output;
119
 
120
  const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
121
  if (e.target.files?.length) {
@@ -162,7 +161,7 @@ export function BackgroundNodeView({
162
 
163
  return (
164
  <div
165
- className={`nb-node absolute text-white w-[320px] ${hasConfig ? 'ring-2 ring-yellow-500/50' : ''}`}
166
  style={{ left: localPos.x, top: localPos.y }}
167
  onDrop={handleDrop}
168
  onDragOver={(e) => e.preventDefault()}
@@ -176,11 +175,11 @@ export function BackgroundNodeView({
176
  >
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 hover:bg-destructive/20"
184
  onClick={(e) => {
185
  e.stopPropagation();
186
  e.preventDefault();
@@ -304,7 +303,6 @@ export function BackgroundNodeView({
304
 
305
  export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
306
  const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
307
- const hasConfig = node.clothesImage && !node.output;
308
 
309
  const presetClothes = [
310
  { name: "Sukajan", path: "/sukajan.png" },
@@ -346,12 +344,11 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
346
 
347
  return (
348
  <div
349
- className={`nb-node absolute text-white w-[320px] ${hasConfig ? 'ring-2 ring-yellow-500/50' : ''}`}
350
  style={{ left: localPos.x, top: localPos.y }}
351
  onDrop={onDrop}
352
  onDragOver={(e) => e.preventDefault()}
353
  onPaste={onPaste}
354
- title={hasConfig ? "Has unsaved configuration - will be applied when processing downstream" : ""}
355
  >
356
  <div
357
  className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
@@ -361,11 +358,11 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
361
  >
362
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
363
  <div className="font-semibold text-sm flex-1 text-center">CLOTHES</div>
364
- <div className="flex items-center gap-2">
365
  <Button
366
  variant="ghost"
367
  size="icon"
368
- className="text-destructive hover:bg-destructive/20"
369
  onClick={(e) => {
370
  e.stopPropagation();
371
  e.preventDefault();
@@ -395,11 +392,6 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
395
  </Button>
396
  </div>
397
  )}
398
- {hasConfig && (
399
- <div className="text-xs bg-yellow-500/20 border border-yellow-500/50 rounded px-2 py-1 text-yellow-300">
400
- โšก Config pending - will apply when downstream node processes
401
- </div>
402
- )}
403
  <div className="text-xs text-white/70">Clothes Reference</div>
404
 
405
  {/* Preset clothes options */}
@@ -485,10 +477,9 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
485
 
486
  export function AgeNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
487
  const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
488
- const hasConfig = node.targetAge && node.targetAge !== 30 && !node.output;
489
 
490
  return (
491
- <div className={`nb-node absolute text-white w-[280px] ${hasConfig ? 'ring-2 ring-yellow-500/50' : ''}`} style={{ left: localPos.x, top: localPos.y }}>
492
  <div
493
  className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
494
  onPointerDown={onPointerDown}
@@ -497,11 +488,11 @@ export function AgeNodeView({ node, onDelete, onUpdate, onStartConnection, onEnd
497
  >
498
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
499
  <div className="font-semibold text-sm flex-1 text-center">AGE</div>
500
- <div className="flex items-center gap-2">
501
  <Button
502
  variant="ghost"
503
  size="icon"
504
- className="text-destructive hover:bg-destructive/20"
505
  onClick={(e) => {
506
  e.stopPropagation();
507
  e.preventDefault();
@@ -593,11 +584,11 @@ export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, on
593
  >
594
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
595
  <div className="font-semibold text-sm flex-1 text-center">CAMERA</div>
596
- <div className="flex items-center gap-2">
597
  <Button
598
  variant="ghost"
599
  size="icon"
600
- className="text-destructive hover:bg-destructive/20"
601
  onClick={(e) => {
602
  e.stopPropagation();
603
  e.preventDefault();
@@ -795,11 +786,11 @@ export function FaceNodeView({ node, onDelete, onUpdate, onStartConnection, onEn
795
  >
796
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
797
  <div className="font-semibold text-sm flex-1 text-center">FACE</div>
798
- <div className="flex items-center gap-2">
799
  <Button
800
  variant="ghost"
801
  size="icon"
802
- className="text-destructive hover:bg-destructive/20"
803
  onClick={(e) => {
804
  e.stopPropagation();
805
  e.preventDefault();
@@ -959,11 +950,11 @@ export function StyleNodeView({ node, onDelete, onUpdate, onStartConnection, onE
959
  >
960
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
961
  <div className="font-semibold text-sm flex-1 text-center">STYLE</div>
962
- <div className="flex items-center gap-2">
963
  <Button
964
  variant="ghost"
965
  size="icon"
966
- className="text-destructive hover:bg-destructive/20"
967
  onClick={(e) => {
968
  e.stopPropagation();
969
  e.preventDefault();
@@ -1058,11 +1049,11 @@ export function EditNodeView({ node, onDelete, onUpdate, onStartConnection, onEn
1058
  >
1059
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
1060
  <div className="font-semibold text-sm flex-1 text-center">EDIT</div>
1061
- <div className="flex items-center gap-2">
1062
  <Button
1063
  variant="ghost"
1064
  size="icon"
1065
- className="text-destructive"
1066
  onClick={() => onDelete(node.id)}
1067
  title="Delete node"
1068
  aria-label="Delete node"
 
115
  onUpdatePosition,
116
  }: any) {
117
  const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
 
118
 
119
  const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
120
  if (e.target.files?.length) {
 
161
 
162
  return (
163
  <div
164
+ className="nb-node absolute text-white w-[320px]"
165
  style={{ left: localPos.x, top: localPos.y }}
166
  onDrop={handleDrop}
167
  onDragOver={(e) => e.preventDefault()}
 
175
  >
176
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
177
  <div className="font-semibold text-sm flex-1 text-center">BACKGROUND</div>
178
+ <div className="flex items-center gap-1">
179
  <Button
180
  variant="ghost"
181
  size="icon"
182
+ className="text-destructive hover:bg-destructive/20 h-6 w-6"
183
  onClick={(e) => {
184
  e.stopPropagation();
185
  e.preventDefault();
 
303
 
304
  export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
305
  const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
 
306
 
307
  const presetClothes = [
308
  { name: "Sukajan", path: "/sukajan.png" },
 
344
 
345
  return (
346
  <div
347
+ className="nb-node absolute text-white w-[320px]"
348
  style={{ left: localPos.x, top: localPos.y }}
349
  onDrop={onDrop}
350
  onDragOver={(e) => e.preventDefault()}
351
  onPaste={onPaste}
 
352
  >
353
  <div
354
  className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
 
358
  >
359
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
360
  <div className="font-semibold text-sm flex-1 text-center">CLOTHES</div>
361
+ <div className="flex items-center gap-1">
362
  <Button
363
  variant="ghost"
364
  size="icon"
365
+ className="text-destructive hover:bg-destructive/20 h-6 w-6"
366
  onClick={(e) => {
367
  e.stopPropagation();
368
  e.preventDefault();
 
392
  </Button>
393
  </div>
394
  )}
 
 
 
 
 
395
  <div className="text-xs text-white/70">Clothes Reference</div>
396
 
397
  {/* Preset clothes options */}
 
477
 
478
  export function AgeNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
479
  const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
 
480
 
481
  return (
482
+ <div className="nb-node absolute text-white w-[280px]" style={{ left: localPos.x, top: localPos.y }}>
483
  <div
484
  className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
485
  onPointerDown={onPointerDown}
 
488
  >
489
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
490
  <div className="font-semibold text-sm flex-1 text-center">AGE</div>
491
+ <div className="flex items-center gap-1">
492
  <Button
493
  variant="ghost"
494
  size="icon"
495
+ className="text-destructive hover:bg-destructive/20 h-6 w-6"
496
  onClick={(e) => {
497
  e.stopPropagation();
498
  e.preventDefault();
 
584
  >
585
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
586
  <div className="font-semibold text-sm flex-1 text-center">CAMERA</div>
587
+ <div className="flex items-center gap-1">
588
  <Button
589
  variant="ghost"
590
  size="icon"
591
+ className="text-destructive hover:bg-destructive/20 h-6 w-6"
592
  onClick={(e) => {
593
  e.stopPropagation();
594
  e.preventDefault();
 
786
  >
787
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
788
  <div className="font-semibold text-sm flex-1 text-center">FACE</div>
789
+ <div className="flex items-center gap-1">
790
  <Button
791
  variant="ghost"
792
  size="icon"
793
+ className="text-destructive hover:bg-destructive/20 h-6 w-6"
794
  onClick={(e) => {
795
  e.stopPropagation();
796
  e.preventDefault();
 
950
  >
951
  <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
952
  <div className="font-semibold text-sm flex-1 text-center">STYLE</div>
953
+ <div className="flex items-center gap-1">
954
  <Button
955
  variant="ghost"
956
  size="icon"
957
+ className="text-destructive hover:bg-destructive/20 h-6 w-6"
958
  onClick={(e) => {
959
  e.stopPropagation();
960
  e.preventDefault();
 
1049
  >
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-1">
1053
  <Button
1054
  variant="ghost"
1055
  size="icon"
1056
+ className="text-destructive hover:bg-destructive/20 h-6 w-6"
1057
  onClick={() => onDelete(node.id)}
1058
  title="Delete node"
1059
  aria-label="Delete node"
app/editor/page.tsx CHANGED
@@ -1,6 +1,7 @@
1
  "use client";
2
 
3
  import React, { useEffect, useMemo, useRef, useState } from "react";
 
4
  import {
5
  BackgroundNodeView,
6
  ClothesNodeView,
@@ -423,7 +424,7 @@ function CharacterNodeView({
423
  function MergeNodeView({
424
  node,
425
  scaleRef,
426
- characters,
427
  onDisconnect,
428
  onRun,
429
  onEndConnection,
@@ -434,8 +435,8 @@ function MergeNodeView({
434
  }: {
435
  node: MergeNode;
436
  scaleRef: React.MutableRefObject<number>;
437
- characters: CharacterNode[];
438
- onDisconnect: (mergeId: string, characterId: string) => void;
439
  onRun: (mergeId: string) => void;
440
  onEndConnection: (mergeId: string) => void;
441
  onStartConnection: (nodeId: string) => void;
@@ -491,14 +492,35 @@ function MergeNodeView({
491
  <div className="text-xs text-white/70">Inputs</div>
492
  <div className="flex flex-wrap gap-2">
493
  {node.inputs.map((id) => {
494
- const c = characters.find((x) => x.id === id);
495
- if (!c) return null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
  return (
497
  <div key={id} className="flex items-center gap-2 bg-white/10 rounded px-2 py-1">
498
- <div className="w-6 h-6 rounded overflow-hidden bg-black/20">
499
- <img src={c.image} className="w-full h-full object-contain" alt="inp" />
500
- </div>
501
- <span className="text-xs">{c.label || `Character ${id.slice(-3)}`}</span>
 
 
502
  <button
503
  className="text-[10px] text-red-300 hover:text-red-200"
504
  onClick={() => onDisconnect(node.id, id)}
@@ -510,7 +532,7 @@ function MergeNodeView({
510
  })}
511
  </div>
512
  {node.inputs.length === 0 && (
513
- <p className="text-xs text-white/40">Drag from CHARACTER output port to connect</p>
514
  )}
515
  <div className="flex items-center gap-2">
516
  {node.inputs.length > 0 && (
@@ -591,17 +613,6 @@ export default function EditorPage() {
591
  } as CharacterNode,
592
  ]);
593
 
594
- // Theme state
595
- const [theme, setTheme] = useState<'dark' | 'light'>('dark');
596
-
597
- // Apply theme to document
598
- useEffect(() => {
599
- if (theme === 'light') {
600
- document.documentElement.classList.remove('dark');
601
- } else {
602
- document.documentElement.classList.add('dark');
603
- }
604
- }, [theme]);
605
 
606
  // Viewport state
607
  const [scale, setScale] = useState(1);
@@ -1061,19 +1072,19 @@ export default function EditorPage() {
1061
  }
1062
  };
1063
 
1064
- const connectToMerge = (mergeId: string, characterId: string) => {
1065
  setNodes((prev) =>
1066
  prev.map((n) =>
1067
  n.id === mergeId && n.type === "MERGE"
1068
- ? { ...n, inputs: Array.from(new Set([...(n as MergeNode).inputs, characterId])) }
1069
  : n
1070
  )
1071
  );
1072
  };
1073
 
1074
  // Connection drag handlers
1075
- const handleStartConnection = (characterId: string) => {
1076
- setDraggingFrom(characterId);
1077
  // Prevent text selection during dragging
1078
  document.body.style.userSelect = 'none';
1079
  document.body.style.webkitUserSelect = 'none';
@@ -1081,7 +1092,15 @@ export default function EditorPage() {
1081
 
1082
  const handleEndConnection = (mergeId: string) => {
1083
  if (draggingFrom) {
1084
- connectToMerge(mergeId, draggingFrom);
 
 
 
 
 
 
 
 
1085
  setDraggingFrom(null);
1086
  setDragPos(null);
1087
  // Re-enable text selection
@@ -1107,20 +1126,20 @@ export default function EditorPage() {
1107
  document.body.style.webkitUserSelect = '';
1108
  }
1109
  };
1110
- const disconnectFromMerge = (mergeId: string, characterId: string) => {
1111
  setNodes((prev) =>
1112
  prev.map((n) =>
1113
  n.id === mergeId && n.type === "MERGE"
1114
- ? { ...n, inputs: (n as MergeNode).inputs.filter((i) => i !== characterId) }
1115
  : n
1116
  )
1117
  );
1118
  };
1119
 
1120
  const executeMerge = async (merge: MergeNode): Promise<string | null> => {
1121
- // Get images from merge inputs
1122
  const mergeImages: string[] = [];
1123
- const characterData: { image: string; label: string }[] = [];
1124
 
1125
  for (const inputId of merge.inputs) {
1126
  const inputNode = nodes.find(n => n.id === inputId);
@@ -1132,26 +1151,37 @@ export default function EditorPage() {
1132
  image = (inputNode as CharacterNode).image;
1133
  label = (inputNode as CharacterNode).label || "";
1134
  } else if ((inputNode as any).output) {
 
1135
  image = (inputNode as any).output;
 
 
 
 
 
1136
  }
1137
 
1138
  if (image) {
1139
  mergeImages.push(image);
1140
- characterData.push({ image, label: label || `Input ${mergeImages.length}` });
1141
  }
1142
  }
1143
  }
1144
 
1145
  if (mergeImages.length < 2) {
1146
- throw new Error("Not enough valid inputs for merge");
1147
  }
1148
 
1149
- const prompt = generateMergePrompt(characterData);
1150
 
1151
- const res = await fetch("/api/merge", {
 
1152
  method: "POST",
1153
  headers: { "Content-Type": "application/json" },
1154
- body: JSON.stringify({ images: mergeImages, prompt }),
 
 
 
 
1155
  });
1156
 
1157
  const data = await res.json();
@@ -1159,7 +1189,7 @@ export default function EditorPage() {
1159
  throw new Error(data.error || "Merge failed");
1160
  }
1161
 
1162
- return (data.images?.[0] as string) || null;
1163
  };
1164
 
1165
  const runMerge = async (mergeId: string) => {
@@ -1168,23 +1198,27 @@ export default function EditorPage() {
1168
  const merge = (nodes.find((n) => n.id === mergeId) as MergeNode) || null;
1169
  if (!merge) return;
1170
 
1171
- // Get character nodes with their labels
1172
- const characterData = merge.inputs
1173
  .map((id, index) => {
1174
- const char = nodes.find((c) => c.id === id);
1175
- if (!char) return null;
1176
 
1177
- // Support both CHARACTER nodes and any node with output
1178
  let image: string | null = null;
1179
  let label = "";
1180
 
1181
- if (char.type === "CHARACTER") {
1182
- image = (char as CharacterNode).image;
1183
- label = (char as CharacterNode).label || `CHARACTER${index + 1}`;
1184
- } else if ((char as any).output) {
1185
- // If it's a processed node, use its output
1186
- image = (char as any).output;
1187
- label = `Input ${index + 1}`;
 
 
 
 
1188
  }
1189
 
1190
  if (!image) return null;
@@ -1193,20 +1227,25 @@ export default function EditorPage() {
1193
  })
1194
  .filter(Boolean) as { image: string; label: string }[];
1195
 
1196
- if (characterData.length < 2) throw new Error("Connect at least two CHARACTER nodes.");
1197
 
1198
  // Debug: Log what we're sending
1199
- console.log("๐Ÿ”„ Merging nodes:", characterData.map(d => d.label).join(", "));
1200
- console.log("๐Ÿ“ท Image URLs being sent:", characterData.map(d => d.image.substring(0, 100) + "..."));
1201
 
1202
  // Generate dynamic prompt based on number of inputs
1203
- const prompt = generateMergePrompt(characterData);
1204
- const imgs = characterData.map(d => d.image);
1205
 
1206
- const res = await fetch("/api/merge", {
 
1207
  method: "POST",
1208
  headers: { "Content-Type": "application/json" },
1209
- body: JSON.stringify({ images: imgs, prompt }),
 
 
 
 
1210
  });
1211
  const js = await res.json();
1212
  if (!res.ok) {
@@ -1217,7 +1256,7 @@ export default function EditorPage() {
1217
  }
1218
  throw new Error(errorMsg);
1219
  }
1220
- const out = (js.images?.[0] as string) || null;
1221
  setNodes((prev) => prev.map((n) => (n.id === mergeId && n.type === "MERGE" ? { ...n, output: out, isRunning: false } : n)));
1222
  } catch (e: any) {
1223
  console.error("Merge error:", e);
@@ -1271,7 +1310,7 @@ export default function EditorPage() {
1271
  return `M ${x1} ${y1} C ${x1 + controlOffset} ${y1}, ${x2 - controlOffset} ${y2}, ${x2} ${y2}`;
1272
  };
1273
 
1274
- const paths: { path: string; active?: boolean }[] = [];
1275
 
1276
  // Handle all connections
1277
  for (const node of nodes) {
@@ -1283,7 +1322,11 @@ export default function EditorPage() {
1283
  if (inputNode) {
1284
  const start = getNodeOutputPort(inputNode);
1285
  const end = getNodeInputPort(node);
1286
- paths.push({ path: createPath(start.x, start.y, end.x, end.y) });
 
 
 
 
1287
  }
1288
  }
1289
  } else if ((node as any).input) {
@@ -1293,7 +1336,11 @@ export default function EditorPage() {
1293
  if (inputNode) {
1294
  const start = getNodeOutputPort(inputNode);
1295
  const end = getNodeInputPort(node);
1296
- paths.push({ path: createPath(start.x, start.y, end.x, end.y) });
 
 
 
 
1297
  }
1298
  }
1299
  }
@@ -1407,35 +1454,10 @@ export default function EditorPage() {
1407
 
1408
  return (
1409
  <div className="min-h-[100svh] bg-background text-foreground">
1410
- <header className="flex items-center justify-between px-6 py-4 border-b border-border/60 bg-card/70 backdrop-blur">
1411
  <h1 className="text-lg font-semibold tracking-wide">
1412
  <span className="mr-2" aria-hidden>๐ŸŒ</span>Nano Banana Editor
1413
  </h1>
1414
- <Button
1415
- variant="ghost"
1416
- size="icon"
1417
- onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
1418
- title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
1419
- className="rounded-lg"
1420
- >
1421
- {theme === 'dark' ? (
1422
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1423
- <circle cx="12" cy="12" r="5"/>
1424
- <line x1="12" y1="1" x2="12" y2="3"/>
1425
- <line x1="12" y1="21" x2="12" y2="23"/>
1426
- <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
1427
- <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
1428
- <line x1="1" y1="12" x2="3" y2="12"/>
1429
- <line x1="21" y1="12" x2="23" y2="12"/>
1430
- <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
1431
- <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
1432
- </svg>
1433
- ) : (
1434
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
1435
- <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
1436
- </svg>
1437
- )}
1438
- </Button>
1439
  </header>
1440
 
1441
  <div
@@ -1493,12 +1515,13 @@ export default function EditorPage() {
1493
  {connectionPaths.map((p, idx) => (
1494
  <path
1495
  key={idx}
 
1496
  d={p.path}
1497
  fill="none"
1498
- stroke={p.active ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"}
1499
- strokeWidth="2.5"
1500
- strokeDasharray={p.active ? "5,5" : undefined}
1501
- style={p.active ? undefined : { opacity: 0.9 }}
1502
  />
1503
  ))}
1504
  </svg>
@@ -1525,7 +1548,7 @@ export default function EditorPage() {
1525
  key={node.id}
1526
  node={node as MergeNode}
1527
  scaleRef={scaleRef}
1528
- characters={nodes.filter((n) => n.type === "CHARACTER") as CharacterNode[]}
1529
  onDisconnect={disconnectFromMerge}
1530
  onRun={runMerge}
1531
  onEndConnection={handleEndConnection}
 
1
  "use client";
2
 
3
  import React, { useEffect, useMemo, useRef, useState } from "react";
4
+ import "./editor.css";
5
  import {
6
  BackgroundNodeView,
7
  ClothesNodeView,
 
424
  function MergeNodeView({
425
  node,
426
  scaleRef,
427
+ allNodes,
428
  onDisconnect,
429
  onRun,
430
  onEndConnection,
 
435
  }: {
436
  node: MergeNode;
437
  scaleRef: React.MutableRefObject<number>;
438
+ allNodes: AnyNode[];
439
+ onDisconnect: (mergeId: string, nodeId: string) => void;
440
  onRun: (mergeId: string) => void;
441
  onEndConnection: (mergeId: string) => void;
442
  onStartConnection: (nodeId: string) => void;
 
492
  <div className="text-xs text-white/70">Inputs</div>
493
  <div className="flex flex-wrap gap-2">
494
  {node.inputs.map((id) => {
495
+ const inputNode = allNodes.find((n) => n.id === id);
496
+ if (!inputNode) return null;
497
+
498
+ // Get image and label based on node type
499
+ let image: string | null = null;
500
+ let label = "";
501
+
502
+ if (inputNode.type === "CHARACTER") {
503
+ image = (inputNode as CharacterNode).image;
504
+ label = (inputNode as CharacterNode).label || "Character";
505
+ } else if ((inputNode as any).output) {
506
+ image = (inputNode as any).output;
507
+ label = `${inputNode.type}`;
508
+ } else if (inputNode.type === "MERGE" && (inputNode as MergeNode).output) {
509
+ image = (inputNode as MergeNode).output;
510
+ label = "Merged";
511
+ } else {
512
+ // Node without output yet
513
+ label = `${inputNode.type} (pending)`;
514
+ }
515
+
516
  return (
517
  <div key={id} className="flex items-center gap-2 bg-white/10 rounded px-2 py-1">
518
+ {image && (
519
+ <div className="w-6 h-6 rounded overflow-hidden bg-black/20">
520
+ <img src={image} className="w-full h-full object-contain" alt="inp" />
521
+ </div>
522
+ )}
523
+ <span className="text-xs">{label}</span>
524
  <button
525
  className="text-[10px] text-red-300 hover:text-red-200"
526
  onClick={() => onDisconnect(node.id, id)}
 
532
  })}
533
  </div>
534
  {node.inputs.length === 0 && (
535
+ <p className="text-xs text-white/40">Drag from any node's output port to connect</p>
536
  )}
537
  <div className="flex items-center gap-2">
538
  {node.inputs.length > 0 && (
 
613
  } as CharacterNode,
614
  ]);
615
 
 
 
 
 
 
 
 
 
 
 
 
616
 
617
  // Viewport state
618
  const [scale, setScale] = useState(1);
 
1072
  }
1073
  };
1074
 
1075
+ const connectToMerge = (mergeId: string, nodeId: string) => {
1076
  setNodes((prev) =>
1077
  prev.map((n) =>
1078
  n.id === mergeId && n.type === "MERGE"
1079
+ ? { ...n, inputs: Array.from(new Set([...(n as MergeNode).inputs, nodeId])) }
1080
  : n
1081
  )
1082
  );
1083
  };
1084
 
1085
  // Connection drag handlers
1086
+ const handleStartConnection = (nodeId: string) => {
1087
+ setDraggingFrom(nodeId);
1088
  // Prevent text selection during dragging
1089
  document.body.style.userSelect = 'none';
1090
  document.body.style.webkitUserSelect = 'none';
 
1092
 
1093
  const handleEndConnection = (mergeId: string) => {
1094
  if (draggingFrom) {
1095
+ // Allow connections from any node type that could have an output
1096
+ const sourceNode = nodes.find(n => n.id === draggingFrom);
1097
+ if (sourceNode) {
1098
+ // Allow connections from:
1099
+ // - CHARACTER nodes (always have an image)
1100
+ // - Any node with an output (processed nodes)
1101
+ // - Any processing node (for future processing)
1102
+ connectToMerge(mergeId, draggingFrom);
1103
+ }
1104
  setDraggingFrom(null);
1105
  setDragPos(null);
1106
  // Re-enable text selection
 
1126
  document.body.style.webkitUserSelect = '';
1127
  }
1128
  };
1129
+ const disconnectFromMerge = (mergeId: string, nodeId: string) => {
1130
  setNodes((prev) =>
1131
  prev.map((n) =>
1132
  n.id === mergeId && n.type === "MERGE"
1133
+ ? { ...n, inputs: (n as MergeNode).inputs.filter((i) => i !== nodeId) }
1134
  : n
1135
  )
1136
  );
1137
  };
1138
 
1139
  const executeMerge = async (merge: MergeNode): Promise<string | null> => {
1140
+ // Get images from merge inputs - now accepts any node type
1141
  const mergeImages: string[] = [];
1142
+ const inputData: { image: string; label: string }[] = [];
1143
 
1144
  for (const inputId of merge.inputs) {
1145
  const inputNode = nodes.find(n => n.id === inputId);
 
1151
  image = (inputNode as CharacterNode).image;
1152
  label = (inputNode as CharacterNode).label || "";
1153
  } else if ((inputNode as any).output) {
1154
+ // Any processed node with output
1155
  image = (inputNode as any).output;
1156
+ label = `${inputNode.type} Output`;
1157
+ } else if (inputNode.type === "MERGE" && (inputNode as MergeNode).output) {
1158
+ // Another merge node's output
1159
+ image = (inputNode as MergeNode).output;
1160
+ label = "Merged Image";
1161
  }
1162
 
1163
  if (image) {
1164
  mergeImages.push(image);
1165
+ inputData.push({ image, label: label || `Input ${mergeImages.length}` });
1166
  }
1167
  }
1168
  }
1169
 
1170
  if (mergeImages.length < 2) {
1171
+ throw new Error("Not enough valid inputs for merge. Need at least 2 images.");
1172
  }
1173
 
1174
+ const prompt = generateMergePrompt(inputData);
1175
 
1176
+ // Use the process route instead of merge route
1177
+ const res = await fetch("/api/process", {
1178
  method: "POST",
1179
  headers: { "Content-Type": "application/json" },
1180
+ body: JSON.stringify({
1181
+ type: "MERGE",
1182
+ images: mergeImages,
1183
+ prompt
1184
+ }),
1185
  });
1186
 
1187
  const data = await res.json();
 
1189
  throw new Error(data.error || "Merge failed");
1190
  }
1191
 
1192
+ return data.image || (data.images?.[0] as string) || null;
1193
  };
1194
 
1195
  const runMerge = async (mergeId: string) => {
 
1198
  const merge = (nodes.find((n) => n.id === mergeId) as MergeNode) || null;
1199
  if (!merge) return;
1200
 
1201
+ // Get input nodes with their labels - now accepts any node type
1202
+ const inputData = merge.inputs
1203
  .map((id, index) => {
1204
+ const inputNode = nodes.find((n) => n.id === id);
1205
+ if (!inputNode) return null;
1206
 
1207
+ // Support CHARACTER nodes, processed nodes, and MERGE outputs
1208
  let image: string | null = null;
1209
  let label = "";
1210
 
1211
+ if (inputNode.type === "CHARACTER") {
1212
+ image = (inputNode as CharacterNode).image;
1213
+ label = (inputNode as CharacterNode).label || `CHARACTER ${index + 1}`;
1214
+ } else if ((inputNode as any).output) {
1215
+ // Any processed node with output
1216
+ image = (inputNode as any).output;
1217
+ label = `${inputNode.type} Output ${index + 1}`;
1218
+ } else if (inputNode.type === "MERGE" && (inputNode as MergeNode).output) {
1219
+ // Another merge node's output
1220
+ image = (inputNode as MergeNode).output;
1221
+ label = `Merged Image ${index + 1}`;
1222
  }
1223
 
1224
  if (!image) return null;
 
1227
  })
1228
  .filter(Boolean) as { image: string; label: string }[];
1229
 
1230
+ if (inputData.length < 2) throw new Error("Connect at least two nodes with images (CHARACTER nodes or processed nodes).");
1231
 
1232
  // Debug: Log what we're sending
1233
+ console.log("๐Ÿ”„ Merging nodes:", inputData.map(d => d.label).join(", "));
1234
+ console.log("๐Ÿ“ท Image URLs being sent:", inputData.map(d => d.image.substring(0, 100) + "..."));
1235
 
1236
  // Generate dynamic prompt based on number of inputs
1237
+ const prompt = generateMergePrompt(inputData);
1238
+ const imgs = inputData.map(d => d.image);
1239
 
1240
+ // Use the process route with MERGE type
1241
+ const res = await fetch("/api/process", {
1242
  method: "POST",
1243
  headers: { "Content-Type": "application/json" },
1244
+ body: JSON.stringify({
1245
+ type: "MERGE",
1246
+ images: imgs,
1247
+ prompt
1248
+ }),
1249
  });
1250
  const js = await res.json();
1251
  if (!res.ok) {
 
1256
  }
1257
  throw new Error(errorMsg);
1258
  }
1259
+ const out = js.image || (js.images?.[0] as string) || null;
1260
  setNodes((prev) => prev.map((n) => (n.id === mergeId && n.type === "MERGE" ? { ...n, output: out, isRunning: false } : n)));
1261
  } catch (e: any) {
1262
  console.error("Merge error:", e);
 
1310
  return `M ${x1} ${y1} C ${x1 + controlOffset} ${y1}, ${x2 - controlOffset} ${y2}, ${x2} ${y2}`;
1311
  };
1312
 
1313
+ const paths: { path: string; active?: boolean; processing?: boolean }[] = [];
1314
 
1315
  // Handle all connections
1316
  for (const node of nodes) {
 
1322
  if (inputNode) {
1323
  const start = getNodeOutputPort(inputNode);
1324
  const end = getNodeInputPort(node);
1325
+ const isProcessing = merge.isRunning || (inputNode as any).isRunning;
1326
+ paths.push({
1327
+ path: createPath(start.x, start.y, end.x, end.y),
1328
+ processing: isProcessing
1329
+ });
1330
  }
1331
  }
1332
  } else if ((node as any).input) {
 
1336
  if (inputNode) {
1337
  const start = getNodeOutputPort(inputNode);
1338
  const end = getNodeInputPort(node);
1339
+ const isProcessing = (node as any).isRunning || (inputNode as any).isRunning;
1340
+ paths.push({
1341
+ path: createPath(start.x, start.y, end.x, end.y),
1342
+ processing: isProcessing
1343
+ });
1344
  }
1345
  }
1346
  }
 
1454
 
1455
  return (
1456
  <div className="min-h-[100svh] bg-background text-foreground">
1457
+ <header className="flex items-center px-6 py-4 border-b border-border/60 bg-card/70 backdrop-blur">
1458
  <h1 className="text-lg font-semibold tracking-wide">
1459
  <span className="mr-2" aria-hidden>๐ŸŒ</span>Nano Banana Editor
1460
  </h1>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1461
  </header>
1462
 
1463
  <div
 
1515
  {connectionPaths.map((p, idx) => (
1516
  <path
1517
  key={idx}
1518
+ className={p.processing ? "connection-processing connection-animated" : ""}
1519
  d={p.path}
1520
  fill="none"
1521
+ stroke={p.processing ? undefined : (p.active ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))")}
1522
+ strokeWidth={p.processing ? undefined : "2.5"}
1523
+ strokeDasharray={p.active && !p.processing ? "5,5" : undefined}
1524
+ style={p.active && !p.processing ? undefined : (!p.processing ? { opacity: 0.9 } : {})}
1525
  />
1526
  ))}
1527
  </svg>
 
1548
  key={node.id}
1549
  node={node as MergeNode}
1550
  scaleRef={scaleRef}
1551
+ allNodes={nodes}
1552
  onDisconnect={disconnectFromMerge}
1553
  onRun={runMerge}
1554
  onEndConnection={handleEndConnection}