Spaces:
Running
Running
final changes
Browse files- .gitignore +2 -0
- app/api/process/route.ts +86 -2
- app/editor/editor.css +64 -0
- app/editor/nodes.tsx +17 -26
- app/editor/page.tsx +113 -90
.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
|
|
|
|
| 17 |
prompt?: string;
|
| 18 |
params?: any;
|
| 19 |
};
|
|
@@ -58,7 +59,90 @@ export async function POST(req: NextRequest) {
|
|
| 58 |
}
|
| 59 |
};
|
| 60 |
|
| 61 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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-
|
| 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=
|
| 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-
|
| 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=
|
| 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-
|
| 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-
|
| 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-
|
| 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-
|
| 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-
|
| 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 |
-
|
| 427 |
onDisconnect,
|
| 428 |
onRun,
|
| 429 |
onEndConnection,
|
|
@@ -434,8 +435,8 @@ function MergeNodeView({
|
|
| 434 |
}: {
|
| 435 |
node: MergeNode;
|
| 436 |
scaleRef: React.MutableRefObject<number>;
|
| 437 |
-
|
| 438 |
-
onDisconnect: (mergeId: string,
|
| 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
|
| 495 |
-
if (!
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
return (
|
| 497 |
<div key={id} className="flex items-center gap-2 bg-white/10 rounded px-2 py-1">
|
| 498 |
-
|
| 499 |
-
<
|
| 500 |
-
|
| 501 |
-
|
|
|
|
|
|
|
| 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
|
| 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,
|
| 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,
|
| 1069 |
: n
|
| 1070 |
)
|
| 1071 |
);
|
| 1072 |
};
|
| 1073 |
|
| 1074 |
// Connection drag handlers
|
| 1075 |
-
const handleStartConnection = (
|
| 1076 |
-
setDraggingFrom(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 1111 |
setNodes((prev) =>
|
| 1112 |
prev.map((n) =>
|
| 1113 |
n.id === mergeId && n.type === "MERGE"
|
| 1114 |
-
? { ...n, inputs: (n as MergeNode).inputs.filter((i) => i !==
|
| 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
|
| 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 |
-
|
| 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(
|
| 1150 |
|
| 1151 |
-
|
|
|
|
| 1152 |
method: "POST",
|
| 1153 |
headers: { "Content-Type": "application/json" },
|
| 1154 |
-
body: JSON.stringify({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1172 |
-
const
|
| 1173 |
.map((id, index) => {
|
| 1174 |
-
const
|
| 1175 |
-
if (!
|
| 1176 |
|
| 1177 |
-
// Support
|
| 1178 |
let image: string | null = null;
|
| 1179 |
let label = "";
|
| 1180 |
|
| 1181 |
-
if (
|
| 1182 |
-
image = (
|
| 1183 |
-
label = (
|
| 1184 |
-
} else if ((
|
| 1185 |
-
//
|
| 1186 |
-
image = (
|
| 1187 |
-
label =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 (
|
| 1197 |
|
| 1198 |
// Debug: Log what we're sending
|
| 1199 |
-
console.log("๐ Merging nodes:",
|
| 1200 |
-
console.log("๐ท Image URLs being sent:",
|
| 1201 |
|
| 1202 |
// Generate dynamic prompt based on number of inputs
|
| 1203 |
-
const prompt = generateMergePrompt(
|
| 1204 |
-
const imgs =
|
| 1205 |
|
| 1206 |
-
|
|
|
|
| 1207 |
method: "POST",
|
| 1208 |
headers: { "Content-Type": "application/json" },
|
| 1209 |
-
body: JSON.stringify({
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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}
|