Reubencf commited on
Commit
ec49af2
·
1 Parent(s): 2bdbd57

Special Branch For HF

Browse files
.dockerignore ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules
3
+ npm-debug.log*
4
+ yarn-debug.log*
5
+ yarn-error.log*
6
+
7
+ # Next.js
8
+ .next/
9
+ out/
10
+
11
+ # Production
12
+ build
13
+ dist
14
+
15
+ # Environment variables
16
+ .env*
17
+
18
+ # Debug
19
+ npm-debug.log*
20
+ yarn-debug.log*
21
+ yarn-error.log*
22
+
23
+ # Vercel
24
+ .vercel
25
+
26
+ # TypeScript
27
+ *.tsbuildinfo
28
+ next-env.d.ts
29
+
30
+ # OS generated files
31
+ .DS_Store
32
+ .DS_Store?
33
+ ._*
34
+ .Spotlight-V100
35
+ .Trashes
36
+ ehthumbs.db
37
+ Thumbs.db
38
+
39
+ # IDE
40
+ .vscode
41
+ .idea
42
+ *.swp
43
+ *.swo
44
+
45
+ # Logs
46
+ logs
47
+ *.log
48
+
49
+ # Git
50
+ .git
51
+ .gitignore
52
+ README.md
53
+
54
+ # Docker
55
+ Dockerfile
56
+ .dockerignore
Dockerfile ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:18-alpine AS base
2
+
3
+ # Install dependencies only when needed
4
+ FROM base AS deps
5
+ # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
6
+ RUN apk add --no-cache libc6-compat
7
+ WORKDIR /app
8
+
9
+ # Install dependencies based on the preferred package manager
10
+ COPY package.json package-lock.json* ./
11
+ RUN npm ci
12
+
13
+ # Rebuild the source code only when needed
14
+ FROM base AS builder
15
+ WORKDIR /app
16
+ COPY --from=deps /app/node_modules ./node_modules
17
+ COPY . .
18
+
19
+ # Next.js collects completely anonymous telemetry data about general usage.
20
+ # Learn more here: https://nextjs.org/telemetry
21
+ # Uncomment the following line in case you want to disable telemetry during the build.
22
+ # ENV NEXT_TELEMETRY_DISABLED 1
23
+
24
+ RUN npm run build
25
+
26
+ # Production image, copy all the files and run next
27
+ FROM base AS runner
28
+ WORKDIR /app
29
+
30
+ ENV NODE_ENV production
31
+ # Uncomment the following line in case you want to disable telemetry during runtime.
32
+ # ENV NEXT_TELEMETRY_DISABLED 1
33
+
34
+ RUN addgroup --system --gid 1001 nodejs
35
+ RUN adduser --system --uid 1001 nextjs
36
+
37
+ COPY --from=builder /app/public ./public
38
+
39
+ # Set the correct permission for prerender cache
40
+ RUN mkdir .next
41
+ RUN chown nextjs:nodejs .next
42
+
43
+ # Automatically leverage output traces to reduce image size
44
+ # https://nextjs.org/docs/advanced-features/output-file-tracing
45
+ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
46
+ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
47
+
48
+ USER nextjs
49
+
50
+ EXPOSE 7860
51
+
52
+ ENV PORT 7860
53
+ ENV HOSTNAME "0.0.0.0"
54
+
55
+ # server.js is created by next build from the standalone output
56
+ # https://nextjs.org/docs/pages/api-reference/next-config-js/output
57
+ CMD ["node", "server.js"]
README.md CHANGED
Binary files a/README.md and b/README.md differ
 
app/api/generate/route.ts CHANGED
@@ -5,7 +5,7 @@ export const runtime = "nodejs"; // Ensure Node runtime for SDK
5
 
6
  export async function POST(req: NextRequest) {
7
  try {
8
- const { prompt } = (await req.json()) as { prompt?: string };
9
  if (!prompt || typeof prompt !== "string") {
10
  return NextResponse.json(
11
  { error: "Missing prompt" },
@@ -13,10 +13,11 @@ export async function POST(req: NextRequest) {
13
  );
14
  }
15
 
16
- const apiKey = process.env.GOOGLE_API_KEY;
 
17
  if (!apiKey || apiKey === 'your_api_key_here') {
18
  return NextResponse.json(
19
- { error: "API key not configured. Please add GOOGLE_API_KEY to .env.local file. Get your key from: https://aistudio.google.com/app/apikey" },
20
  { status: 500 }
21
  );
22
  }
 
5
 
6
  export async function POST(req: NextRequest) {
7
  try {
8
+ const { prompt, apiToken } = (await req.json()) as { prompt?: string; apiToken?: string };
9
  if (!prompt || typeof prompt !== "string") {
10
  return NextResponse.json(
11
  { error: "Missing prompt" },
 
13
  );
14
  }
15
 
16
+ // Use user-provided API token or fall back to environment variable
17
+ const apiKey = apiToken || process.env.GOOGLE_API_KEY;
18
  if (!apiKey || apiKey === 'your_api_key_here') {
19
  return NextResponse.json(
20
+ { error: "API key not provided. Please enter your Hugging Face API token in the top right corner or add GOOGLE_API_KEY to .env.local file. Get your key from: https://aistudio.google.com/app/apikey" },
21
  { status: 500 }
22
  );
23
  }
app/api/merge/route.ts CHANGED
@@ -35,6 +35,7 @@ export async function POST(req: NextRequest) {
35
  const body = (await req.json()) as {
36
  images?: string[]; // data URLs
37
  prompt?: string;
 
38
  };
39
 
40
  const imgs = body.images?.filter(Boolean) ?? [];
@@ -45,10 +46,11 @@ export async function POST(req: NextRequest) {
45
  );
46
  }
47
 
48
- const apiKey = process.env.GOOGLE_API_KEY;
 
49
  if (!apiKey || apiKey === 'your_api_key_here') {
50
  return NextResponse.json(
51
- { error: "API key not configured. Please add GOOGLE_API_KEY to .env.local file. Get your key from: https://aistudio.google.com/app/apikey" },
52
  { status: 500 }
53
  );
54
  }
 
35
  const body = (await req.json()) as {
36
  images?: string[]; // data URLs
37
  prompt?: string;
38
+ apiToken?: string;
39
  };
40
 
41
  const imgs = body.images?.filter(Boolean) ?? [];
 
46
  );
47
  }
48
 
49
+ // Use user-provided API token or fall back to environment variable
50
+ const apiKey = body.apiToken || process.env.GOOGLE_API_KEY;
51
  if (!apiKey || apiKey === 'your_api_key_here') {
52
  return NextResponse.json(
53
+ { error: "API key not provided. Please enter your Hugging Face API token in the top right corner or add GOOGLE_API_KEY to .env.local file. Get your key from: https://aistudio.google.com/app/apikey" },
54
  { status: 500 }
55
  );
56
  }
app/api/process/route.ts CHANGED
@@ -26,6 +26,7 @@ export async function POST(req: NextRequest) {
26
  images?: string[];
27
  prompt?: string;
28
  params?: any;
 
29
  };
30
  } catch (jsonError) {
31
  console.error('[API] Failed to parse JSON:', jsonError);
@@ -35,10 +36,11 @@ export async function POST(req: NextRequest) {
35
  );
36
  }
37
 
38
- const apiKey = process.env.GOOGLE_API_KEY;
 
39
  if (!apiKey || apiKey === 'your_actual_api_key_here') {
40
  return NextResponse.json(
41
- { error: "API key not configured. Please add GOOGLE_API_KEY to .env.local file." },
42
  { status: 500 }
43
  );
44
  }
 
26
  images?: string[];
27
  prompt?: string;
28
  params?: any;
29
+ apiToken?: string;
30
  };
31
  } catch (jsonError) {
32
  console.error('[API] Failed to parse JSON:', jsonError);
 
36
  );
37
  }
38
 
39
+ // Use user-provided API token or fall back to environment variable
40
+ const apiKey = body.apiToken || process.env.GOOGLE_API_KEY;
41
  if (!apiKey || apiKey === 'your_actual_api_key_here') {
42
  return NextResponse.json(
43
+ { error: "API key not provided. Please enter your Hugging Face API token in the top right corner or add GOOGLE_API_KEY to .env.local file." },
44
  { status: 500 }
45
  );
46
  }
app/{editor/editor.css → editor.css} RENAMED
File without changes
app/editor/page.tsx CHANGED
@@ -1,1762 +0,0 @@
1
- "use client";
2
-
3
- import React, { useEffect, useMemo, useRef, useState } from "react";
4
- import "./editor.css";
5
- import {
6
- BackgroundNodeView,
7
- ClothesNodeView,
8
- StyleNodeView,
9
- EditNodeView,
10
- CameraNodeView,
11
- AgeNodeView,
12
- FaceNodeView
13
- } from "./nodes";
14
- import { Button } from "../../components/ui/button";
15
-
16
- function cx(...args: Array<string | false | null | undefined>) {
17
- return args.filter(Boolean).join(" ");
18
- }
19
-
20
- // Simple ID helper
21
- const uid = () => Math.random().toString(36).slice(2, 9);
22
-
23
- // Generate merge prompt based on number of inputs
24
- function generateMergePrompt(characterData: { image: string; label: string }[]): string {
25
- const count = characterData.length;
26
-
27
- const labels = characterData.map((d, i) => `Image ${i + 1} (${d.label})`).join(", ");
28
-
29
- return `MERGE TASK: Create a natural, cohesive group photo combining ALL subjects from ${count} provided images.
30
-
31
- Images provided:
32
- ${characterData.map((d, i) => `- Image ${i + 1}: ${d.label}`).join("\n")}
33
-
34
- CRITICAL REQUIREMENTS:
35
- 1. Extract ALL people/subjects from EACH image exactly as they appear
36
- 2. Place them together in a SINGLE UNIFIED SCENE with:
37
- - Consistent lighting direction and color temperature
38
- - Matching shadows and ambient lighting
39
- - Proper scale relationships (realistic relative sizes)
40
- - Natural spacing as if they were photographed together
41
- - Shared environment/background that looks cohesive
42
-
43
- 3. Composition guidelines:
44
- - Arrange subjects at similar depth (not one far behind another)
45
- - Use natural group photo positioning (slight overlap is ok)
46
- - Ensure all faces are clearly visible
47
- - Create visual balance in the composition
48
- - Apply consistent color grading across all subjects
49
-
50
- 4. Environmental unity:
51
- - Use a single, coherent background for all subjects
52
- - Match the perspective as if taken with one camera
53
- - Ensure ground plane continuity (all standing on same level)
54
- - Apply consistent atmospheric effects (if any)
55
-
56
- 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.`;
57
- }
58
-
59
- // Types
60
- type NodeType = "CHARACTER" | "MERGE" | "BACKGROUND" | "CLOTHES" | "STYLE" | "EDIT" | "CAMERA" | "AGE" | "FACE" | "BLEND";
61
-
62
- type NodeBase = {
63
- id: string;
64
- type: NodeType;
65
- x: number; // world coords
66
- y: number; // world coords
67
- };
68
-
69
- type CharacterNode = NodeBase & {
70
- type: "CHARACTER";
71
- image: string; // data URL or http URL
72
- label?: string;
73
- };
74
-
75
- type MergeNode = NodeBase & {
76
- type: "MERGE";
77
- inputs: string[]; // node ids
78
- output?: string | null; // data URL from merge
79
- isRunning?: boolean;
80
- error?: string | null;
81
- };
82
-
83
- type BackgroundNode = NodeBase & {
84
- type: "BACKGROUND";
85
- input?: string; // node id
86
- output?: string;
87
- backgroundType: "color" | "image" | "upload" | "custom";
88
- backgroundColor?: string;
89
- backgroundImage?: string;
90
- customBackgroundImage?: string;
91
- customPrompt?: string;
92
- isRunning?: boolean;
93
- error?: string | null;
94
- };
95
-
96
- type ClothesNode = NodeBase & {
97
- type: "CLOTHES";
98
- input?: string;
99
- output?: string;
100
- clothesImage?: string;
101
- selectedPreset?: string;
102
- clothesPrompt?: string;
103
- isRunning?: boolean;
104
- error?: string | null;
105
- };
106
-
107
- type StyleNode = NodeBase & {
108
- type: "STYLE";
109
- input?: string;
110
- output?: string;
111
- stylePreset?: string;
112
- styleStrength?: number;
113
- isRunning?: boolean;
114
- error?: string | null;
115
- };
116
-
117
- type EditNode = NodeBase & {
118
- type: "EDIT";
119
- input?: string;
120
- output?: string;
121
- editPrompt?: string;
122
- isRunning?: boolean;
123
- error?: string | null;
124
- };
125
-
126
- type CameraNode = NodeBase & {
127
- type: "CAMERA";
128
- input?: string;
129
- output?: string;
130
- focalLength?: string;
131
- aperture?: string;
132
- shutterSpeed?: string;
133
- whiteBalance?: string;
134
- angle?: string;
135
- iso?: string;
136
- filmStyle?: string;
137
- lighting?: string;
138
- bokeh?: string;
139
- composition?: string;
140
- aspectRatio?: string;
141
- isRunning?: boolean;
142
- error?: string | null;
143
- };
144
-
145
- type AgeNode = NodeBase & {
146
- type: "AGE";
147
- input?: string;
148
- output?: string;
149
- targetAge?: number;
150
- isRunning?: boolean;
151
- error?: string | null;
152
- };
153
-
154
- type FaceNode = NodeBase & {
155
- type: "FACE";
156
- input?: string;
157
- output?: string;
158
- faceOptions?: {
159
- removePimples?: boolean;
160
- addSunglasses?: boolean;
161
- addHat?: boolean;
162
- changeHairstyle?: string;
163
- facialExpression?: string;
164
- beardStyle?: string;
165
- };
166
- isRunning?: boolean;
167
- error?: string | null;
168
- };
169
-
170
- type BlendNode = NodeBase & {
171
- type: "BLEND";
172
- input?: string;
173
- output?: string;
174
- blendStrength?: number;
175
- isRunning?: boolean;
176
- error?: string | null;
177
- };
178
-
179
- type AnyNode = CharacterNode | MergeNode | BackgroundNode | ClothesNode | StyleNode | EditNode | CameraNode | AgeNode | FaceNode | BlendNode;
180
-
181
- // Default placeholder portrait
182
- const DEFAULT_PERSON =
183
- "https://images.unsplash.com/photo-1527980965255-d3b416303d12?q=80&w=640&auto=format&fit=crop";
184
-
185
- function toDataUrls(files: FileList | File[]): Promise<string[]> {
186
- const arr = Array.from(files as File[]);
187
- return Promise.all(
188
- arr.map(
189
- (file) =>
190
- new Promise<string>((resolve, reject) => {
191
- const r = new FileReader();
192
- r.onload = () => resolve(r.result as string);
193
- r.onerror = reject;
194
- r.readAsDataURL(file);
195
- })
196
- )
197
- );
198
- }
199
-
200
- // Viewport helpers
201
- function screenToWorld(
202
- clientX: number,
203
- clientY: number,
204
- container: DOMRect,
205
- tx: number,
206
- ty: number,
207
- scale: number
208
- ) {
209
- const x = (clientX - container.left - tx) / scale;
210
- const y = (clientY - container.top - ty) / scale;
211
- return { x, y };
212
- }
213
-
214
- function useNodeDrag(
215
- nodeId: string,
216
- scaleRef: React.MutableRefObject<number>,
217
- initial: { x: number; y: number },
218
- onUpdatePosition: (id: string, x: number, y: number) => void
219
- ) {
220
- const [localPos, setLocalPos] = useState(initial);
221
- const dragging = useRef(false);
222
- const start = useRef<{ sx: number; sy: number; ox: number; oy: number } | null>(
223
- null
224
- );
225
-
226
- useEffect(() => {
227
- setLocalPos(initial);
228
- }, [initial.x, initial.y]);
229
-
230
- const onPointerDown = (e: React.PointerEvent) => {
231
- e.stopPropagation();
232
- dragging.current = true;
233
- start.current = { sx: e.clientX, sy: e.clientY, ox: localPos.x, oy: localPos.y };
234
- (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
235
- };
236
- const onPointerMove = (e: React.PointerEvent) => {
237
- if (!dragging.current || !start.current) return;
238
- const dx = (e.clientX - start.current.sx) / scaleRef.current;
239
- const dy = (e.clientY - start.current.sy) / scaleRef.current;
240
- const newX = start.current.ox + dx;
241
- const newY = start.current.oy + dy;
242
- setLocalPos({ x: newX, y: newY });
243
- onUpdatePosition(nodeId, newX, newY);
244
- };
245
- const onPointerUp = (e: React.PointerEvent) => {
246
- dragging.current = false;
247
- start.current = null;
248
- (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
249
- };
250
- return { pos: localPos, onPointerDown, onPointerMove, onPointerUp };
251
- }
252
-
253
- function Port({
254
- className,
255
- nodeId,
256
- isOutput,
257
- onStartConnection,
258
- onEndConnection
259
- }: {
260
- className?: string;
261
- nodeId?: string;
262
- isOutput?: boolean;
263
- onStartConnection?: (nodeId: string) => void;
264
- onEndConnection?: (nodeId: string) => void;
265
- }) {
266
- const handlePointerDown = (e: React.PointerEvent) => {
267
- e.stopPropagation();
268
- if (isOutput && nodeId && onStartConnection) {
269
- onStartConnection(nodeId);
270
- }
271
- };
272
-
273
- const handlePointerUp = (e: React.PointerEvent) => {
274
- e.stopPropagation();
275
- if (!isOutput && nodeId && onEndConnection) {
276
- onEndConnection(nodeId);
277
- }
278
- };
279
-
280
- return (
281
- <div
282
- className={cx("nb-port", className)}
283
- onPointerDown={handlePointerDown}
284
- onPointerUp={handlePointerUp}
285
- onPointerEnter={handlePointerUp}
286
- />
287
- );
288
- }
289
-
290
- function CharacterNodeView({
291
- node,
292
- scaleRef,
293
- onChangeImage,
294
- onChangeLabel,
295
- onStartConnection,
296
- onUpdatePosition,
297
- onDelete,
298
- }: {
299
- node: CharacterNode;
300
- scaleRef: React.MutableRefObject<number>;
301
- onChangeImage: (id: string, url: string) => void;
302
- onChangeLabel: (id: string, label: string) => void;
303
- onStartConnection: (nodeId: string) => void;
304
- onUpdatePosition: (id: string, x: number, y: number) => void;
305
- onDelete: (id: string) => void;
306
- }) {
307
- const { pos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(
308
- node.id,
309
- scaleRef,
310
- { x: node.x, y: node.y },
311
- onUpdatePosition
312
- );
313
-
314
- const onDrop = async (e: React.DragEvent) => {
315
- e.preventDefault();
316
- const f = e.dataTransfer.files;
317
- if (f && f.length) {
318
- const [first] = await toDataUrls(f);
319
- if (first) onChangeImage(node.id, first);
320
- }
321
- };
322
-
323
- const onPaste = async (e: React.ClipboardEvent) => {
324
- const items = e.clipboardData.items;
325
- const files: File[] = [];
326
- for (let i = 0; i < items.length; i++) {
327
- const it = items[i];
328
- if (it.type.startsWith("image/")) {
329
- const f = it.getAsFile();
330
- if (f) files.push(f);
331
- }
332
- }
333
- if (files.length) {
334
- const [first] = await toDataUrls(files);
335
- if (first) onChangeImage(node.id, first);
336
- return;
337
- }
338
- const text = e.clipboardData.getData("text");
339
- if (text && (text.startsWith("http") || text.startsWith("data:image"))) {
340
- onChangeImage(node.id, text);
341
- }
342
- };
343
-
344
- return (
345
- <div
346
- className="nb-node absolute text-white w-[340px] select-none"
347
- style={{ left: pos.x, top: pos.y }}
348
- onDrop={onDrop}
349
- onDragOver={(e) => e.preventDefault()}
350
- onPaste={onPaste}
351
- >
352
- <div
353
- className="nb-header cursor-grab active:cursor-grabbing rounded-t-[14px] px-3 py-2 flex items-center justify-between"
354
- onPointerDown={onPointerDown}
355
- onPointerMove={onPointerMove}
356
- onPointerUp={onPointerUp}
357
- >
358
- <input
359
- className="bg-transparent outline-none text-sm font-semibold tracking-wide flex-1"
360
- value={node.label || "CHARACTER"}
361
- onChange={(e) => onChangeLabel(node.id, e.target.value)}
362
- />
363
- <div className="flex items-center gap-2">
364
- <Button
365
- variant="ghost" size="icon" className="text-destructive"
366
- onClick={(e) => {
367
- e.stopPropagation();
368
- if (confirm('Delete MERGE node?')) {
369
- onDelete(node.id);
370
- }
371
- }}
372
- title="Delete node"
373
- aria-label="Delete node"
374
- >
375
- ×
376
- </Button>
377
- <Port
378
- className="out"
379
- nodeId={node.id}
380
- isOutput={true}
381
- onStartConnection={onStartConnection}
382
- />
383
- </div>
384
- </div>
385
- <div className="p-3 space-y-3">
386
- <div className="aspect-[4/5] w-full rounded-xl bg-black/40 grid place-items-center overflow-hidden">
387
- <img
388
- src={node.image}
389
- alt="character"
390
- className="h-full w-full object-contain"
391
- draggable={false}
392
- />
393
- </div>
394
- <div className="flex gap-2">
395
- <label className="text-xs bg-white/10 hover:bg-white/20 rounded px-3 py-1 cursor-pointer">
396
- Upload
397
- <input
398
- type="file"
399
- accept="image/*"
400
- className="hidden"
401
- onChange={async (e) => {
402
- const files = e.currentTarget.files;
403
- if (files && files.length > 0) {
404
- const [first] = await toDataUrls(files);
405
- if (first) onChangeImage(node.id, first);
406
- // Reset input safely
407
- try {
408
- e.currentTarget.value = "";
409
- } catch {}
410
- }
411
- }}
412
- />
413
- </label>
414
- <button
415
- className="text-xs bg-white/10 hover:bg-white/20 rounded px-3 py-1"
416
- onClick={async () => {
417
- try {
418
- const text = await navigator.clipboard.readText();
419
- if (text && (text.startsWith("http") || text.startsWith("data:image"))) {
420
- onChangeImage(node.id, text);
421
- }
422
- } catch {}
423
- }}
424
- >
425
- Paste URL
426
- </button>
427
- </div>
428
- </div>
429
- </div>
430
- );
431
- }
432
-
433
- function MergeNodeView({
434
- node,
435
- scaleRef,
436
- allNodes,
437
- onDisconnect,
438
- onRun,
439
- onEndConnection,
440
- onStartConnection,
441
- onUpdatePosition,
442
- onDelete,
443
- onClearConnections,
444
- }: {
445
- node: MergeNode;
446
- scaleRef: React.MutableRefObject<number>;
447
- allNodes: AnyNode[];
448
- onDisconnect: (mergeId: string, nodeId: string) => void;
449
- onRun: (mergeId: string) => void;
450
- onEndConnection: (mergeId: string) => void;
451
- onStartConnection: (nodeId: string) => void;
452
- onUpdatePosition: (id: string, x: number, y: number) => void;
453
- onDelete: (id: string) => void;
454
- onClearConnections: (mergeId: string) => void;
455
- }) {
456
- const { pos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(
457
- node.id,
458
- scaleRef,
459
- { x: node.x, y: node.y },
460
- onUpdatePosition
461
- );
462
-
463
-
464
- return (
465
- <div className="nb-node absolute text-white w-[420px]" style={{ left: pos.x, top: pos.y }}>
466
- <div
467
- className="nb-header cursor-grab active:cursor-grabbing rounded-t-[14px] px-3 py-2 flex items-center justify-between"
468
- onPointerDown={onPointerDown}
469
- onPointerMove={onPointerMove}
470
- onPointerUp={onPointerUp}
471
- >
472
- <Port
473
- className="in"
474
- nodeId={node.id}
475
- isOutput={false}
476
- onEndConnection={onEndConnection}
477
- />
478
- <div className="font-semibold tracking-wide text-sm flex-1 text-center">MERGE</div>
479
- <div className="flex items-center gap-2">
480
- <button
481
- 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"
482
- onClick={(e) => {
483
- e.stopPropagation();
484
- if (confirm('Delete MERGE node?')) {
485
- onDelete(node.id);
486
- }
487
- }}
488
- title="Delete node"
489
- >
490
- ×
491
- </button>
492
- <Port
493
- className="out"
494
- nodeId={node.id}
495
- isOutput={true}
496
- onStartConnection={onStartConnection}
497
- />
498
- </div>
499
- </div>
500
- <div className="p-3 space-y-3">
501
- <div className="text-xs text-white/70">Inputs</div>
502
- <div className="flex flex-wrap gap-2">
503
- {node.inputs.map((id) => {
504
- const inputNode = allNodes.find((n) => n.id === id);
505
- if (!inputNode) return null;
506
-
507
- // Get image and label based on node type
508
- let image: string | null = null;
509
- let label = "";
510
-
511
- if (inputNode.type === "CHARACTER") {
512
- image = (inputNode as CharacterNode).image;
513
- label = (inputNode as CharacterNode).label || "Character";
514
- } else if ((inputNode as any).output) {
515
- image = (inputNode as any).output;
516
- label = `${inputNode.type}`;
517
- } else if (inputNode.type === "MERGE" && (inputNode as MergeNode).output) {
518
- const mergeOutput = (inputNode as MergeNode).output;
519
- image = mergeOutput !== undefined ? mergeOutput : null;
520
- label = "Merged";
521
- } else {
522
- // Node without output yet
523
- label = `${inputNode.type} (pending)`;
524
- }
525
-
526
- return (
527
- <div key={id} className="flex items-center gap-2 bg-white/10 rounded px-2 py-1">
528
- {image && (
529
- <div className="w-6 h-6 rounded overflow-hidden bg-black/20">
530
- <img src={image} className="w-full h-full object-contain" alt="inp" />
531
- </div>
532
- )}
533
- <span className="text-xs">{label}</span>
534
- <button
535
- className="text-[10px] text-red-300 hover:text-red-200"
536
- onClick={() => onDisconnect(node.id, id)}
537
- >
538
- remove
539
- </button>
540
- </div>
541
- );
542
- })}
543
- </div>
544
- {node.inputs.length === 0 && (
545
- <p className="text-xs text-white/40">Drag from any node's output port to connect</p>
546
- )}
547
- <div className="flex items-center gap-2">
548
- {node.inputs.length > 0 && (
549
- <Button
550
- variant="destructive"
551
- size="sm"
552
- onClick={() => onClearConnections(node.id)}
553
- title="Clear all connections"
554
- >
555
- Clear
556
- </Button>
557
- )}
558
- <Button
559
- size="sm"
560
- onClick={() => onRun(node.id)}
561
- disabled={node.isRunning || node.inputs.length < 2}
562
- >
563
- {node.isRunning ? "Merging…" : "Merge"}
564
- </Button>
565
- </div>
566
-
567
- <div className="mt-2">
568
- <div className="text-xs text-white/70 mb-1">Output</div>
569
- <div className="w-full min-h-[200px] max-h-[400px] rounded-xl bg-black/40 grid place-items-center">
570
- {node.output ? (
571
- <img src={node.output} className="w-full h-auto max-h-[400px] object-contain rounded-xl" alt="output" />
572
- ) : (
573
- <span className="text-white/40 text-xs py-16">Run merge to see result</span>
574
- )}
575
- </div>
576
- {node.output && (
577
- <Button
578
- className="w-full mt-2"
579
- variant="secondary"
580
- onClick={() => {
581
- const link = document.createElement('a');
582
- link.href = node.output as string;
583
- link.download = `merge-${Date.now()}.png`;
584
- document.body.appendChild(link);
585
- link.click();
586
- document.body.removeChild(link);
587
- }}
588
- >
589
- 📥 Download Merged Image
590
- </Button>
591
- )}
592
- {node.error && (
593
- <div className="mt-2">
594
- <div className="text-xs text-red-400">{node.error}</div>
595
- {node.error.includes("API key") && (
596
- <div className="text-xs text-white/50 mt-2 space-y-1">
597
- <p>To fix this:</p>
598
- <ol className="list-decimal list-inside space-y-1">
599
- <li>Get key from: <a href="https://aistudio.google.com/app/apikey" target="_blank" className="text-blue-400 hover:underline">Google AI Studio</a></li>
600
- <li>Edit .env.local file in project root</li>
601
- <li>Replace placeholder with your key</li>
602
- <li>Restart server (Ctrl+C, npm run dev)</li>
603
- </ol>
604
- </div>
605
- )}
606
- </div>
607
- )}
608
- </div>
609
- </div>
610
- </div>
611
- );
612
- }
613
-
614
- export default function EditorPage() {
615
- const [nodes, setNodes] = useState<AnyNode[]>(() => [
616
- {
617
- id: uid(),
618
- type: "CHARACTER",
619
- x: 80,
620
- y: 120,
621
- image: DEFAULT_PERSON,
622
- label: "CHARACTER 1",
623
- } as CharacterNode,
624
- ]);
625
-
626
-
627
- // Viewport state
628
- const [scale, setScale] = useState(1);
629
- const [tx, setTx] = useState(0);
630
- const [ty, setTy] = useState(0);
631
- const containerRef = useRef<HTMLDivElement>(null);
632
- const scaleRef = useRef(scale);
633
- useEffect(() => {
634
- scaleRef.current = scale;
635
- }, [scale]);
636
-
637
- // Connection dragging state
638
- const [draggingFrom, setDraggingFrom] = useState<string | null>(null);
639
- const [dragPos, setDragPos] = useState<{x: number, y: number} | null>(null);
640
-
641
- const characters = nodes.filter((n) => n.type === "CHARACTER") as CharacterNode[];
642
- const merges = nodes.filter((n) => n.type === "MERGE") as MergeNode[];
643
-
644
- // Editor actions
645
- const addCharacter = (at?: { x: number; y: number }) => {
646
- setNodes((prev) => [
647
- ...prev,
648
- {
649
- id: uid(),
650
- type: "CHARACTER",
651
- x: at ? at.x : 80 + Math.random() * 60,
652
- y: at ? at.y : 120 + Math.random() * 60,
653
- image: DEFAULT_PERSON,
654
- label: `CHARACTER ${prev.filter((n) => n.type === "CHARACTER").length + 1}`,
655
- } as CharacterNode,
656
- ]);
657
- };
658
- const addMerge = (at?: { x: number; y: number }) => {
659
- setNodes((prev) => [
660
- ...prev,
661
- {
662
- id: uid(),
663
- type: "MERGE",
664
- x: at ? at.x : 520,
665
- y: at ? at.y : 160,
666
- inputs: [],
667
- } as MergeNode,
668
- ]);
669
- };
670
-
671
- const setCharacterImage = (id: string, url: string) => {
672
- setNodes((prev) =>
673
- prev.map((n) => (n.id === id && n.type === "CHARACTER" ? { ...n, image: url } : n))
674
- );
675
- };
676
- const setCharacterLabel = (id: string, label: string) => {
677
- setNodes((prev) => prev.map((n) => (n.id === id && n.type === "CHARACTER" ? { ...n, label } : n)));
678
- };
679
-
680
- const updateNodePosition = (id: string, x: number, y: number) => {
681
- setNodes((prev) => prev.map((n) => (n.id === id ? { ...n, x, y } : n)));
682
- };
683
-
684
- const deleteNode = (id: string) => {
685
- setNodes((prev) => {
686
- // If it's a MERGE node, just remove it
687
- // If it's a CHARACTER node, also remove it from all MERGE inputs
688
- return prev
689
- .filter((n) => n.id !== id)
690
- .map((n) => {
691
- if (n.type === "MERGE") {
692
- const merge = n as MergeNode;
693
- return {
694
- ...merge,
695
- inputs: merge.inputs.filter((inputId) => inputId !== id),
696
- };
697
- }
698
- return n;
699
- });
700
- });
701
- };
702
-
703
- const clearMergeConnections = (mergeId: string) => {
704
- setNodes((prev) =>
705
- prev.map((n) =>
706
- n.id === mergeId && n.type === "MERGE"
707
- ? { ...n, inputs: [] }
708
- : n
709
- )
710
- );
711
- };
712
-
713
- // Update any node's properties
714
- const updateNode = (id: string, updates: any) => {
715
- setNodes((prev) => prev.map((n) => (n.id === id ? { ...n, ...updates } : n)));
716
- };
717
-
718
- // Handle single input connections for new nodes
719
- const handleEndSingleConnection = (nodeId: string) => {
720
- if (draggingFrom) {
721
- // Find the source node
722
- const sourceNode = nodes.find(n => n.id === draggingFrom);
723
- if (sourceNode) {
724
- // Allow connections from ANY node that has an output port
725
- // This includes:
726
- // - CHARACTER nodes (always have an image)
727
- // - MERGE nodes (can have output after merging)
728
- // - Any processing node (BACKGROUND, CLOTHES, BLEND, etc.)
729
- // - Even unprocessed nodes (for configuration chaining)
730
-
731
- // All nodes can be connected for chaining
732
- setNodes(prev => prev.map(n =>
733
- n.id === nodeId ? { ...n, input: draggingFrom } : n
734
- ));
735
- }
736
- setDraggingFrom(null);
737
- setDragPos(null);
738
- // Re-enable text selection
739
- document.body.style.userSelect = '';
740
- document.body.style.webkitUserSelect = '';
741
- }
742
- };
743
-
744
- // Helper to count pending configurations in chain
745
- const countPendingConfigurations = (startNodeId: string): number => {
746
- let count = 0;
747
- const visited = new Set<string>();
748
-
749
- const traverse = (nodeId: string) => {
750
- if (visited.has(nodeId)) return;
751
- visited.add(nodeId);
752
-
753
- const node = nodes.find(n => n.id === nodeId);
754
- if (!node) return;
755
-
756
- // Check if this node has configuration but no output
757
- if (!(node as any).output && node.type !== "CHARACTER" && node.type !== "MERGE") {
758
- const config = getNodeConfiguration(node);
759
- if (Object.keys(config).length > 0) {
760
- count++;
761
- }
762
- }
763
-
764
- // Check upstream
765
- const upstreamId = (node as any).input;
766
- if (upstreamId) {
767
- traverse(upstreamId);
768
- }
769
- };
770
-
771
- traverse(startNodeId);
772
- return count;
773
- };
774
-
775
- // Helper to extract configuration from a node
776
- const getNodeConfiguration = (node: AnyNode): any => {
777
- const config: any = {};
778
-
779
- switch (node.type) {
780
- case "BACKGROUND":
781
- if ((node as BackgroundNode).backgroundType) {
782
- config.backgroundType = (node as BackgroundNode).backgroundType;
783
- config.backgroundColor = (node as BackgroundNode).backgroundColor;
784
- config.backgroundImage = (node as BackgroundNode).backgroundImage;
785
- config.customBackgroundImage = (node as BackgroundNode).customBackgroundImage;
786
- config.customPrompt = (node as BackgroundNode).customPrompt;
787
- }
788
- break;
789
- case "CLOTHES":
790
- if ((node as ClothesNode).clothesImage) {
791
- config.clothesImage = (node as ClothesNode).clothesImage;
792
- config.selectedPreset = (node as ClothesNode).selectedPreset;
793
- }
794
- break;
795
- case "STYLE":
796
- if ((node as StyleNode).stylePreset) {
797
- config.stylePreset = (node as StyleNode).stylePreset;
798
- config.styleStrength = (node as StyleNode).styleStrength;
799
- }
800
- break;
801
- case "EDIT":
802
- if ((node as EditNode).editPrompt) {
803
- config.editPrompt = (node as EditNode).editPrompt;
804
- }
805
- break;
806
- case "CAMERA":
807
- const cam = node as CameraNode;
808
- if (cam.focalLength && cam.focalLength !== "None") config.focalLength = cam.focalLength;
809
- if (cam.aperture && cam.aperture !== "None") config.aperture = cam.aperture;
810
- if (cam.shutterSpeed && cam.shutterSpeed !== "None") config.shutterSpeed = cam.shutterSpeed;
811
- if (cam.whiteBalance && cam.whiteBalance !== "None") config.whiteBalance = cam.whiteBalance;
812
- if (cam.angle && cam.angle !== "None") config.angle = cam.angle;
813
- if (cam.iso && cam.iso !== "None") config.iso = cam.iso;
814
- if (cam.filmStyle && cam.filmStyle !== "None") config.filmStyle = cam.filmStyle;
815
- if (cam.lighting && cam.lighting !== "None") config.lighting = cam.lighting;
816
- if (cam.bokeh && cam.bokeh !== "None") config.bokeh = cam.bokeh;
817
- if (cam.composition && cam.composition !== "None") config.composition = cam.composition;
818
- if (cam.aspectRatio && cam.aspectRatio !== "None") config.aspectRatio = cam.aspectRatio;
819
- break;
820
- case "AGE":
821
- if ((node as AgeNode).targetAge) {
822
- config.targetAge = (node as AgeNode).targetAge;
823
- }
824
- break;
825
- case "FACE":
826
- const face = node as FaceNode;
827
- if (face.faceOptions) {
828
- const opts: any = {};
829
- if (face.faceOptions.removePimples) opts.removePimples = true;
830
- if (face.faceOptions.addSunglasses) opts.addSunglasses = true;
831
- if (face.faceOptions.addHat) opts.addHat = true;
832
- if (face.faceOptions.changeHairstyle && face.faceOptions.changeHairstyle !== "None") {
833
- opts.changeHairstyle = face.faceOptions.changeHairstyle;
834
- }
835
- if (face.faceOptions.facialExpression && face.faceOptions.facialExpression !== "None") {
836
- opts.facialExpression = face.faceOptions.facialExpression;
837
- }
838
- if (face.faceOptions.beardStyle && face.faceOptions.beardStyle !== "None") {
839
- opts.beardStyle = face.faceOptions.beardStyle;
840
- }
841
- if (Object.keys(opts).length > 0) {
842
- config.faceOptions = opts;
843
- }
844
- }
845
- break;
846
- }
847
-
848
- return config;
849
- };
850
-
851
- // Process node with API
852
- const processNode = async (nodeId: string) => {
853
- const node = nodes.find(n => n.id === nodeId);
854
- if (!node) {
855
- console.error("Node not found:", nodeId);
856
- return;
857
- }
858
-
859
- // Get input image and collect all configurations from chain
860
- let inputImage: string | null = null;
861
- let accumulatedParams: any = {};
862
- const processedNodes: string[] = []; // Track which nodes' configs we're applying
863
- const inputId = (node as any).input;
864
-
865
- if (inputId) {
866
- // Track unprocessed MERGE nodes that need to be executed
867
- const unprocessedMerges: MergeNode[] = [];
868
-
869
- // Find the source image by traversing the chain backwards
870
- const findSourceImage = (currentNodeId: string, visited: Set<string> = new Set()): string | null => {
871
- if (visited.has(currentNodeId)) return null;
872
- visited.add(currentNodeId);
873
-
874
- const currentNode = nodes.find(n => n.id === currentNodeId);
875
- if (!currentNode) return null;
876
-
877
- // If this is a CHARACTER node, return its image
878
- if (currentNode.type === "CHARACTER") {
879
- return (currentNode as CharacterNode).image;
880
- }
881
-
882
- // If this is a MERGE node with output, return its output
883
- if (currentNode.type === "MERGE" && (currentNode as MergeNode).output) {
884
- return (currentNode as MergeNode).output || null;
885
- }
886
-
887
- // If any node has been processed, return its output
888
- if ((currentNode as any).output) {
889
- return (currentNode as any).output;
890
- }
891
-
892
- // For MERGE nodes without output, we need to process them first
893
- if (currentNode.type === "MERGE") {
894
- const merge = currentNode as MergeNode;
895
- if (!merge.output && merge.inputs.length >= 2) {
896
- // Mark this merge for processing
897
- unprocessedMerges.push(merge);
898
- // For now, return null - we'll process the merge first
899
- return null;
900
- } else if (merge.inputs.length > 0) {
901
- // Try to get image from first input if merge can't be executed
902
- const firstInput = merge.inputs[0];
903
- const inputImage = findSourceImage(firstInput, visited);
904
- if (inputImage) return inputImage;
905
- }
906
- }
907
-
908
- // Otherwise, check upstream
909
- const upstreamId = (currentNode as any).input;
910
- if (upstreamId) {
911
- return findSourceImage(upstreamId, visited);
912
- }
913
-
914
- return null;
915
- };
916
-
917
- // Collect all configurations from unprocessed nodes in the chain
918
- const collectConfigurations = (currentNodeId: string, visited: Set<string> = new Set()): any => {
919
- if (visited.has(currentNodeId)) return {};
920
- visited.add(currentNodeId);
921
-
922
- const currentNode = nodes.find(n => n.id === currentNodeId);
923
- if (!currentNode) return {};
924
-
925
- let configs: any = {};
926
-
927
- // First, collect from upstream nodes
928
- const upstreamId = (currentNode as any).input;
929
- if (upstreamId) {
930
- configs = collectConfigurations(upstreamId, visited);
931
- }
932
-
933
- // Add this node's configuration only if:
934
- // 1. It's the current node being processed, OR
935
- // 2. It hasn't been processed yet (no output) AND it's not the current node
936
- const shouldIncludeConfig =
937
- currentNodeId === nodeId || // Always include current node's config
938
- (!(currentNode as any).output && currentNodeId !== nodeId); // Include unprocessed intermediate nodes
939
-
940
- if (shouldIncludeConfig) {
941
- const nodeConfig = getNodeConfiguration(currentNode);
942
- if (Object.keys(nodeConfig).length > 0) {
943
- configs = { ...configs, ...nodeConfig };
944
- // Track unprocessed intermediate nodes
945
- if (currentNodeId !== nodeId && !(currentNode as any).output) {
946
- processedNodes.push(currentNodeId);
947
- }
948
- }
949
- }
950
-
951
- return configs;
952
- };
953
-
954
- // Find the source image
955
- inputImage = findSourceImage(inputId);
956
-
957
- // If we found unprocessed merges, we need to execute them first
958
- if (unprocessedMerges.length > 0 && !inputImage) {
959
- console.log(`Found ${unprocessedMerges.length} unprocessed MERGE nodes in chain. Processing them first...`);
960
-
961
- // Process each merge node
962
- for (const merge of unprocessedMerges) {
963
- // Set loading state for the merge
964
- setNodes(prev => prev.map(n =>
965
- n.id === merge.id ? { ...n, isRunning: true, error: null } : n
966
- ));
967
-
968
- try {
969
- const mergeOutput = await executeMerge(merge);
970
-
971
- // Update the merge node with output
972
- setNodes(prev => prev.map(n =>
973
- n.id === merge.id ? { ...n, output: mergeOutput || undefined, isRunning: false, error: null } : n
974
- ));
975
-
976
- // Track that we processed this merge as part of the chain
977
- processedNodes.push(merge.id);
978
-
979
- // Now use this as our input image if it's the direct input
980
- if (inputId === merge.id) {
981
- inputImage = mergeOutput;
982
- }
983
- } catch (e: any) {
984
- console.error("Auto-merge error:", e);
985
- setNodes(prev => prev.map(n =>
986
- n.id === merge.id ? { ...n, isRunning: false, error: e?.message || "Merge failed" } : n
987
- ));
988
- // Abort the main processing if merge failed
989
- setNodes(prev => prev.map(n =>
990
- n.id === nodeId ? { ...n, error: "Failed to process upstream MERGE node", isRunning: false } : n
991
- ));
992
- return;
993
- }
994
- }
995
-
996
- // After processing merges, try to find the source image again
997
- if (!inputImage) {
998
- inputImage = findSourceImage(inputId);
999
- }
1000
- }
1001
-
1002
- // Collect configurations from the chain
1003
- accumulatedParams = collectConfigurations(inputId, new Set());
1004
- }
1005
-
1006
- if (!inputImage) {
1007
- const errorMsg = inputId
1008
- ? "No source image found in the chain. Connect to a CHARACTER node or processed node."
1009
- : "No input connected. Connect an image source to this node.";
1010
- setNodes(prev => prev.map(n =>
1011
- n.id === nodeId ? { ...n, error: errorMsg, isRunning: false } : n
1012
- ));
1013
- return;
1014
- }
1015
-
1016
- // Add current node's configuration
1017
- const currentNodeConfig = getNodeConfiguration(node);
1018
- const params = { ...accumulatedParams, ...currentNodeConfig };
1019
-
1020
- // Count how many unprocessed nodes we're combining
1021
- const unprocessedNodeCount = Object.keys(params).length > 0 ?
1022
- (processedNodes.length + 1) : 1;
1023
-
1024
- // Show info about batch processing
1025
- if (unprocessedNodeCount > 1) {
1026
- console.log(`🚀 Combining ${unprocessedNodeCount} node transformations into ONE API call`);
1027
- console.log("Combined parameters:", params);
1028
- } else {
1029
- console.log("Processing single node:", node.type);
1030
- }
1031
-
1032
- // Set loading state for all nodes being processed
1033
- setNodes(prev => prev.map(n => {
1034
- if (n.id === nodeId || processedNodes.includes(n.id)) {
1035
- return { ...n, isRunning: true, error: null };
1036
- }
1037
- return n;
1038
- }));
1039
-
1040
- try {
1041
- // Validate image data before sending
1042
- if (inputImage && inputImage.length > 10 * 1024 * 1024) { // 10MB limit warning
1043
- console.warn("Large input image detected, size:", (inputImage.length / (1024 * 1024)).toFixed(2) + "MB");
1044
- }
1045
-
1046
- // Check if params contains custom images and validate them
1047
- if (params.clothesImage) {
1048
- console.log("[Process] Clothes image size:", (params.clothesImage.length / 1024).toFixed(2) + "KB");
1049
- // Validate it's a proper data URL
1050
- if (!params.clothesImage.startsWith('data:') && !params.clothesImage.startsWith('http') && !params.clothesImage.startsWith('/')) {
1051
- throw new Error("Invalid clothes image format. Please upload a valid image.");
1052
- }
1053
- }
1054
-
1055
- if (params.customBackgroundImage) {
1056
- console.log("[Process] Custom background size:", (params.customBackgroundImage.length / 1024).toFixed(2) + "KB");
1057
- // Validate it's a proper data URL
1058
- if (!params.customBackgroundImage.startsWith('data:') && !params.customBackgroundImage.startsWith('http') && !params.customBackgroundImage.startsWith('/')) {
1059
- throw new Error("Invalid background image format. Please upload a valid image.");
1060
- }
1061
- }
1062
-
1063
- // Log request details for debugging
1064
- console.log("[Process] Sending request with:", {
1065
- hasImage: !!inputImage,
1066
- imageSize: inputImage ? (inputImage.length / 1024).toFixed(2) + "KB" : 0,
1067
- paramsKeys: Object.keys(params),
1068
- nodeType: node.type
1069
- });
1070
-
1071
- // Make a SINGLE API call with all accumulated parameters
1072
- const res = await fetch("/api/process", {
1073
- method: "POST",
1074
- headers: { "Content-Type": "application/json" },
1075
- body: JSON.stringify({
1076
- type: "COMBINED", // Indicate this is a combined processing
1077
- image: inputImage,
1078
- params
1079
- }),
1080
- });
1081
-
1082
- // Check if response is actually JSON before parsing
1083
- const contentType = res.headers.get("content-type");
1084
- if (!contentType || !contentType.includes("application/json")) {
1085
- const textResponse = await res.text();
1086
- console.error("Non-JSON response received:", textResponse);
1087
- throw new Error("Server returned an error page instead of JSON. Check your API key configuration.");
1088
- }
1089
-
1090
- const data = await res.json();
1091
- if (!res.ok) throw new Error(data.error || "Processing failed");
1092
-
1093
- // Only update the current node with the output
1094
- // Don't show output in intermediate nodes - they were just used for configuration
1095
- setNodes(prev => prev.map(n => {
1096
- if (n.id === nodeId) {
1097
- // Only the current node gets the final output displayed
1098
- return { ...n, output: data.image, isRunning: false, error: null };
1099
- } else if (processedNodes.includes(n.id)) {
1100
- // Mark intermediate nodes as no longer running but don't give them output
1101
- // This way they remain unprocessed visually but their configs were used
1102
- return { ...n, isRunning: false, error: null };
1103
- }
1104
- return n;
1105
- }));
1106
-
1107
- if (unprocessedNodeCount > 1) {
1108
- console.log(`✅ Successfully applied ${unprocessedNodeCount} transformations in ONE API call!`);
1109
- console.log(`Saved ${unprocessedNodeCount - 1} API calls by combining transformations`);
1110
- }
1111
- } catch (e: any) {
1112
- console.error("Process error:", e);
1113
- // Clear loading state for all nodes
1114
- setNodes(prev => prev.map(n => {
1115
- if (n.id === nodeId || processedNodes.includes(n.id)) {
1116
- return { ...n, isRunning: false, error: e?.message || "Error" };
1117
- }
1118
- return n;
1119
- }));
1120
- }
1121
- };
1122
-
1123
- const connectToMerge = (mergeId: string, nodeId: string) => {
1124
- setNodes((prev) =>
1125
- prev.map((n) =>
1126
- n.id === mergeId && n.type === "MERGE"
1127
- ? { ...n, inputs: Array.from(new Set([...(n as MergeNode).inputs, nodeId])) }
1128
- : n
1129
- )
1130
- );
1131
- };
1132
-
1133
- // Connection drag handlers
1134
- const handleStartConnection = (nodeId: string) => {
1135
- setDraggingFrom(nodeId);
1136
- // Prevent text selection during dragging
1137
- document.body.style.userSelect = 'none';
1138
- document.body.style.webkitUserSelect = 'none';
1139
- };
1140
-
1141
- const handleEndConnection = (mergeId: string) => {
1142
- if (draggingFrom) {
1143
- // Allow connections from any node type that could have an output
1144
- const sourceNode = nodes.find(n => n.id === draggingFrom);
1145
- if (sourceNode) {
1146
- // Allow connections from:
1147
- // - CHARACTER nodes (always have an image)
1148
- // - Any node with an output (processed nodes)
1149
- // - Any processing node (for future processing)
1150
- connectToMerge(mergeId, draggingFrom);
1151
- }
1152
- setDraggingFrom(null);
1153
- setDragPos(null);
1154
- // Re-enable text selection
1155
- document.body.style.userSelect = '';
1156
- document.body.style.webkitUserSelect = '';
1157
- }
1158
- };
1159
-
1160
- const handlePointerMove = (e: React.PointerEvent) => {
1161
- if (draggingFrom) {
1162
- const rect = containerRef.current!.getBoundingClientRect();
1163
- const world = screenToWorld(e.clientX, e.clientY, rect, tx, ty, scale);
1164
- setDragPos(world);
1165
- }
1166
- };
1167
-
1168
- const handlePointerUp = () => {
1169
- if (draggingFrom) {
1170
- setDraggingFrom(null);
1171
- setDragPos(null);
1172
- // Re-enable text selection
1173
- document.body.style.userSelect = '';
1174
- document.body.style.webkitUserSelect = '';
1175
- }
1176
- };
1177
- const disconnectFromMerge = (mergeId: string, nodeId: string) => {
1178
- setNodes((prev) =>
1179
- prev.map((n) =>
1180
- n.id === mergeId && n.type === "MERGE"
1181
- ? { ...n, inputs: (n as MergeNode).inputs.filter((i) => i !== nodeId) }
1182
- : n
1183
- )
1184
- );
1185
- };
1186
-
1187
- const executeMerge = async (merge: MergeNode): Promise<string | null> => {
1188
- // Get images from merge inputs - now accepts any node type
1189
- const mergeImages: string[] = [];
1190
- const inputData: { image: string; label: string }[] = [];
1191
-
1192
- for (const inputId of merge.inputs) {
1193
- const inputNode = nodes.find(n => n.id === inputId);
1194
- if (inputNode) {
1195
- let image: string | null = null;
1196
- let label = "";
1197
-
1198
- if (inputNode.type === "CHARACTER") {
1199
- image = (inputNode as CharacterNode).image;
1200
- label = (inputNode as CharacterNode).label || "";
1201
- } else if ((inputNode as any).output) {
1202
- // Any processed node with output
1203
- image = (inputNode as any).output;
1204
- label = `${inputNode.type} Output`;
1205
- } else if (inputNode.type === "MERGE" && (inputNode as MergeNode).output) {
1206
- // Another merge node's output
1207
- const mergeOutput = (inputNode as MergeNode).output;
1208
- image = mergeOutput !== undefined ? mergeOutput : null;
1209
- label = "Merged Image";
1210
- }
1211
-
1212
- if (image) {
1213
- // Validate image format
1214
- if (!image.startsWith('data:') && !image.startsWith('http') && !image.startsWith('/')) {
1215
- console.error(`Invalid image format for ${label}:`, image.substring(0, 100));
1216
- continue; // Skip invalid images
1217
- }
1218
- mergeImages.push(image);
1219
- inputData.push({ image, label: label || `Input ${mergeImages.length}` });
1220
- }
1221
- }
1222
- }
1223
-
1224
- if (mergeImages.length < 2) {
1225
- throw new Error("Not enough valid inputs for merge. Need at least 2 images.");
1226
- }
1227
-
1228
- // Log merge details for debugging
1229
- console.log("[Merge] Processing merge with:", {
1230
- imageCount: mergeImages.length,
1231
- imageSizes: mergeImages.map(img => (img.length / 1024).toFixed(2) + "KB"),
1232
- labels: inputData.map(d => d.label)
1233
- });
1234
-
1235
- const prompt = generateMergePrompt(inputData);
1236
-
1237
- // Use the process route instead of merge route
1238
- const res = await fetch("/api/process", {
1239
- method: "POST",
1240
- headers: { "Content-Type": "application/json" },
1241
- body: JSON.stringify({
1242
- type: "MERGE",
1243
- images: mergeImages,
1244
- prompt
1245
- }),
1246
- });
1247
-
1248
- // Check if response is actually JSON before parsing
1249
- const contentType = res.headers.get("content-type");
1250
- if (!contentType || !contentType.includes("application/json")) {
1251
- const textResponse = await res.text();
1252
- console.error("Non-JSON response received:", textResponse);
1253
- throw new Error("Server returned an error page instead of JSON. Check your API key configuration.");
1254
- }
1255
-
1256
- const data = await res.json();
1257
- if (!res.ok) {
1258
- throw new Error(data.error || "Merge failed");
1259
- }
1260
-
1261
- return data.image || (data.images?.[0] as string) || null;
1262
- };
1263
-
1264
- const runMerge = async (mergeId: string) => {
1265
- setNodes((prev) => prev.map((n) => (n.id === mergeId && n.type === "MERGE" ? { ...n, isRunning: true, error: null } : n)));
1266
- try {
1267
- const merge = (nodes.find((n) => n.id === mergeId) as MergeNode) || null;
1268
- if (!merge) return;
1269
-
1270
- // Get input nodes with their labels - now accepts any node type
1271
- const inputData = merge.inputs
1272
- .map((id, index) => {
1273
- const inputNode = nodes.find((n) => n.id === id);
1274
- if (!inputNode) return null;
1275
-
1276
- // Support CHARACTER nodes, processed nodes, and MERGE outputs
1277
- let image: string | null = null;
1278
- let label = "";
1279
-
1280
- if (inputNode.type === "CHARACTER") {
1281
- image = (inputNode as CharacterNode).image;
1282
- label = (inputNode as CharacterNode).label || `CHARACTER ${index + 1}`;
1283
- } else if ((inputNode as any).output) {
1284
- // Any processed node with output
1285
- image = (inputNode as any).output;
1286
- label = `${inputNode.type} Output ${index + 1}`;
1287
- } else if (inputNode.type === "MERGE" && (inputNode as MergeNode).output) {
1288
- // Another merge node's output
1289
- const mergeOutput = (inputNode as MergeNode).output;
1290
- image = mergeOutput !== undefined ? mergeOutput : null;
1291
- label = `Merged Image ${index + 1}`;
1292
- }
1293
-
1294
- if (!image) return null;
1295
-
1296
- return { image, label };
1297
- })
1298
- .filter(Boolean) as { image: string; label: string }[];
1299
-
1300
- if (inputData.length < 2) throw new Error("Connect at least two nodes with images (CHARACTER nodes or processed nodes).");
1301
-
1302
- // Debug: Log what we're sending
1303
- console.log("🔄 Merging nodes:", inputData.map(d => d.label).join(", "));
1304
- console.log("📷 Image URLs being sent:", inputData.map(d => d.image.substring(0, 100) + "..."));
1305
-
1306
- // Generate dynamic prompt based on number of inputs
1307
- const prompt = generateMergePrompt(inputData);
1308
- const imgs = inputData.map(d => d.image);
1309
-
1310
- // Use the process route with MERGE type
1311
- const res = await fetch("/api/process", {
1312
- method: "POST",
1313
- headers: { "Content-Type": "application/json" },
1314
- body: JSON.stringify({
1315
- type: "MERGE",
1316
- images: imgs,
1317
- prompt
1318
- }),
1319
- });
1320
-
1321
- // Check if response is actually JSON before parsing
1322
- const contentType = res.headers.get("content-type");
1323
- if (!contentType || !contentType.includes("application/json")) {
1324
- const textResponse = await res.text();
1325
- console.error("Non-JSON response received:", textResponse);
1326
- throw new Error("Server returned an error page instead of JSON. Check your API key configuration.");
1327
- }
1328
-
1329
- const js = await res.json();
1330
- if (!res.ok) {
1331
- // Show more helpful error messages
1332
- const errorMsg = js.error || "Merge failed";
1333
- if (errorMsg.includes("API key")) {
1334
- throw new Error("API key not configured. Add GOOGLE_API_KEY to .env.local");
1335
- }
1336
- throw new Error(errorMsg);
1337
- }
1338
- const out = js.image || (js.images?.[0] as string) || null;
1339
- setNodes((prev) => prev.map((n) => (n.id === mergeId && n.type === "MERGE" ? { ...n, output: out, isRunning: false } : n)));
1340
- } catch (e: any) {
1341
- console.error("Merge error:", e);
1342
- setNodes((prev) => prev.map((n) => (n.id === mergeId && n.type === "MERGE" ? { ...n, isRunning: false, error: e?.message || "Error" } : n)));
1343
- }
1344
- };
1345
-
1346
- // Calculate SVG bounds for connection lines
1347
- const svgBounds = useMemo(() => {
1348
- let minX = 0, minY = 0, maxX = 1000, maxY = 1000;
1349
- nodes.forEach(node => {
1350
- minX = Math.min(minX, node.x - 100);
1351
- minY = Math.min(minY, node.y - 100);
1352
- maxX = Math.max(maxX, node.x + 500);
1353
- maxY = Math.max(maxY, node.y + 500);
1354
- });
1355
- return {
1356
- x: minX,
1357
- y: minY,
1358
- width: maxX - minX,
1359
- height: maxY - minY
1360
- };
1361
- }, [nodes]);
1362
-
1363
- // Connection paths with bezier curves
1364
- const connectionPaths = useMemo(() => {
1365
- const getNodeOutputPort = (n: AnyNode) => {
1366
- // Different nodes have different widths
1367
- const widths: Record<string, number> = {
1368
- CHARACTER: 340,
1369
- MERGE: 420,
1370
- BACKGROUND: 320,
1371
- CLOTHES: 320,
1372
- BLEND: 320,
1373
- EDIT: 320,
1374
- CAMERA: 360,
1375
- AGE: 280,
1376
- FACE: 340,
1377
- };
1378
- const width = widths[n.type] || 320;
1379
- return { x: n.x + width - 10, y: n.y + 25 };
1380
- };
1381
-
1382
- const getNodeInputPort = (n: AnyNode) => ({ x: n.x + 10, y: n.y + 25 });
1383
-
1384
- const createPath = (x1: number, y1: number, x2: number, y2: number) => {
1385
- const dx = x2 - x1;
1386
- const dy = y2 - y1;
1387
- const distance = Math.sqrt(dx * dx + dy * dy);
1388
- const controlOffset = Math.min(200, Math.max(50, distance * 0.3));
1389
- return `M ${x1} ${y1} C ${x1 + controlOffset} ${y1}, ${x2 - controlOffset} ${y2}, ${x2} ${y2}`;
1390
- };
1391
-
1392
- const paths: { path: string; active?: boolean; processing?: boolean }[] = [];
1393
-
1394
- // Handle all connections
1395
- for (const node of nodes) {
1396
- if (node.type === "MERGE") {
1397
- // MERGE node with multiple inputs
1398
- const merge = node as MergeNode;
1399
- for (const inputId of merge.inputs) {
1400
- const inputNode = nodes.find(n => n.id === inputId);
1401
- if (inputNode) {
1402
- const start = getNodeOutputPort(inputNode);
1403
- const end = getNodeInputPort(node);
1404
- const isProcessing = merge.isRunning || (inputNode as any).isRunning;
1405
- paths.push({
1406
- path: createPath(start.x, start.y, end.x, end.y),
1407
- processing: isProcessing
1408
- });
1409
- }
1410
- }
1411
- } else if ((node as any).input) {
1412
- // Single input nodes
1413
- const inputId = (node as any).input;
1414
- const inputNode = nodes.find(n => n.id === inputId);
1415
- if (inputNode) {
1416
- const start = getNodeOutputPort(inputNode);
1417
- const end = getNodeInputPort(node);
1418
- const isProcessing = (node as any).isRunning || (inputNode as any).isRunning;
1419
- paths.push({
1420
- path: createPath(start.x, start.y, end.x, end.y),
1421
- processing: isProcessing
1422
- });
1423
- }
1424
- }
1425
- }
1426
-
1427
- // Dragging path
1428
- if (draggingFrom && dragPos) {
1429
- const sourceNode = nodes.find(n => n.id === draggingFrom);
1430
- if (sourceNode) {
1431
- const start = getNodeOutputPort(sourceNode);
1432
- paths.push({
1433
- path: createPath(start.x, start.y, dragPos.x, dragPos.y),
1434
- active: true
1435
- });
1436
- }
1437
- }
1438
-
1439
- return paths;
1440
- }, [nodes, draggingFrom, dragPos]);
1441
-
1442
- // Panning & zooming
1443
- const isPanning = useRef(false);
1444
- const panStart = useRef<{ sx: number; sy: number; ox: number; oy: number } | null>(null);
1445
-
1446
- const onBackgroundPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
1447
- // Only pan if clicking directly on the background
1448
- if (e.target !== e.currentTarget && !((e.target as HTMLElement).tagName === "svg" || (e.target as HTMLElement).tagName === "line")) return;
1449
- isPanning.current = true;
1450
- panStart.current = { sx: e.clientX, sy: e.clientY, ox: tx, oy: ty };
1451
- (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
1452
- };
1453
- const onBackgroundPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
1454
- if (!isPanning.current || !panStart.current) return;
1455
- const dx = e.clientX - panStart.current.sx;
1456
- const dy = e.clientY - panStart.current.sy;
1457
- setTx(panStart.current.ox + dx);
1458
- setTy(panStart.current.oy + dy);
1459
- };
1460
- const onBackgroundPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
1461
- isPanning.current = false;
1462
- panStart.current = null;
1463
- (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
1464
- };
1465
-
1466
- const onWheel = (e: React.WheelEvent<HTMLDivElement>) => {
1467
- e.preventDefault();
1468
- const rect = containerRef.current!.getBoundingClientRect();
1469
- const oldScale = scaleRef.current;
1470
- const factor = Math.exp(-e.deltaY * 0.0015);
1471
- const newScale = Math.min(2.5, Math.max(0.25, oldScale * factor));
1472
- const { x: wx, y: wy } = screenToWorld(e.clientX, e.clientY, rect, tx, ty, oldScale);
1473
- // keep cursor anchored while zooming
1474
- const ntx = e.clientX - rect.left - wx * newScale;
1475
- const nty = e.clientY - rect.top - wy * newScale;
1476
- setTx(ntx);
1477
- setTy(nty);
1478
- setScale(newScale);
1479
- };
1480
-
1481
- // Context menu for adding nodes
1482
- const [menuOpen, setMenuOpen] = useState(false);
1483
- const [menuPos, setMenuPos] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
1484
- const [menuWorld, setMenuWorld] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
1485
-
1486
- const onContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
1487
- e.preventDefault();
1488
- const rect = containerRef.current!.getBoundingClientRect();
1489
- const world = screenToWorld(e.clientX, e.clientY, rect, tx, ty, scale);
1490
- setMenuWorld(world);
1491
- setMenuPos({ x: e.clientX - rect.left, y: e.clientY - rect.top });
1492
- setMenuOpen(true);
1493
- };
1494
-
1495
- const addFromMenu = (kind: NodeType) => {
1496
- const commonProps = {
1497
- id: uid(),
1498
- x: menuWorld.x,
1499
- y: menuWorld.y,
1500
- };
1501
-
1502
- switch(kind) {
1503
- case "CHARACTER":
1504
- addCharacter(menuWorld);
1505
- break;
1506
- case "MERGE":
1507
- addMerge(menuWorld);
1508
- break;
1509
- case "BACKGROUND":
1510
- setNodes(prev => [...prev, { ...commonProps, type: "BACKGROUND", backgroundType: "color" } as BackgroundNode]);
1511
- break;
1512
- case "CLOTHES":
1513
- setNodes(prev => [...prev, { ...commonProps, type: "CLOTHES" } as ClothesNode]);
1514
- break;
1515
- case "BLEND":
1516
- setNodes(prev => [...prev, { ...commonProps, type: "BLEND", blendStrength: 50 } as BlendNode]);
1517
- break;
1518
- case "STYLE":
1519
- setNodes(prev => [...prev, { ...commonProps, type: "STYLE", styleStrength: 50 } as StyleNode]);
1520
- break;
1521
- case "CAMERA":
1522
- setNodes(prev => [...prev, { ...commonProps, type: "CAMERA" } as CameraNode]);
1523
- break;
1524
- case "AGE":
1525
- setNodes(prev => [...prev, { ...commonProps, type: "AGE", targetAge: 30 } as AgeNode]);
1526
- break;
1527
- case "FACE":
1528
- setNodes(prev => [...prev, { ...commonProps, type: "FACE", faceOptions: {} } as FaceNode]);
1529
- break;
1530
- }
1531
- setMenuOpen(false);
1532
- };
1533
-
1534
- return (
1535
- <div className="min-h-[100svh] bg-background text-foreground">
1536
- <header className="flex items-center px-6 py-4 border-b border-border/60 bg-card/70 backdrop-blur">
1537
- <h1 className="text-lg font-semibold tracking-wide">
1538
- <span className="mr-2" aria-hidden>🍌</span>Nano Banana Editor
1539
- </h1>
1540
- </header>
1541
-
1542
- <div
1543
- ref={containerRef}
1544
- className="relative w-full h-[calc(100svh-56px)] overflow-hidden nb-canvas"
1545
- style={{
1546
- imageRendering: "auto",
1547
- transform: "translateZ(0)",
1548
- willChange: "contents"
1549
- }}
1550
- onContextMenu={onContextMenu}
1551
- onPointerDown={onBackgroundPointerDown}
1552
- onPointerMove={(e) => {
1553
- onBackgroundPointerMove(e);
1554
- handlePointerMove(e);
1555
- }}
1556
- onPointerUp={(e) => {
1557
- onBackgroundPointerUp(e);
1558
- handlePointerUp();
1559
- }}
1560
- onPointerLeave={(e) => {
1561
- onBackgroundPointerUp(e);
1562
- handlePointerUp();
1563
- }}
1564
- onWheel={onWheel}
1565
- >
1566
- <div
1567
- className="absolute left-0 top-0 will-change-transform"
1568
- style={{
1569
- transform: `translate3d(${tx}px, ${ty}px, 0) scale(${scale})`,
1570
- transformOrigin: "0 0",
1571
- transformStyle: "preserve-3d",
1572
- backfaceVisibility: "hidden"
1573
- }}
1574
- >
1575
- <svg
1576
- className="absolute pointer-events-none z-0"
1577
- style={{
1578
- left: `${svgBounds.x}px`,
1579
- top: `${svgBounds.y}px`,
1580
- width: `${svgBounds.width}px`,
1581
- height: `${svgBounds.height}px`
1582
- }}
1583
- viewBox={`${svgBounds.x} ${svgBounds.y} ${svgBounds.width} ${svgBounds.height}`}
1584
- >
1585
- <defs>
1586
- <filter id="glow">
1587
- <feGaussianBlur stdDeviation="3" result="coloredBlur"/>
1588
- <feMerge>
1589
- <feMergeNode in="coloredBlur"/>
1590
- <feMergeNode in="SourceGraphic"/>
1591
- </feMerge>
1592
- </filter>
1593
- </defs>
1594
- {connectionPaths.map((p, idx) => (
1595
- <path
1596
- key={idx}
1597
- className={p.processing ? "connection-processing connection-animated" : ""}
1598
- d={p.path}
1599
- fill="none"
1600
- stroke={p.processing ? undefined : (p.active ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))")}
1601
- strokeWidth={p.processing ? undefined : "2.5"}
1602
- strokeDasharray={p.active && !p.processing ? "5,5" : undefined}
1603
- style={p.active && !p.processing ? undefined : (!p.processing ? { opacity: 0.9 } : {})}
1604
- />
1605
- ))}
1606
- </svg>
1607
-
1608
- <div className="relative z-10">
1609
- {nodes.map((node) => {
1610
- switch (node.type) {
1611
- case "CHARACTER":
1612
- return (
1613
- <CharacterNodeView
1614
- key={node.id}
1615
- node={node as CharacterNode}
1616
- scaleRef={scaleRef}
1617
- onChangeImage={setCharacterImage}
1618
- onChangeLabel={setCharacterLabel}
1619
- onStartConnection={handleStartConnection}
1620
- onUpdatePosition={updateNodePosition}
1621
- onDelete={deleteNode}
1622
- />
1623
- );
1624
- case "MERGE":
1625
- return (
1626
- <MergeNodeView
1627
- key={node.id}
1628
- node={node as MergeNode}
1629
- scaleRef={scaleRef}
1630
- allNodes={nodes}
1631
- onDisconnect={disconnectFromMerge}
1632
- onRun={runMerge}
1633
- onEndConnection={handleEndConnection}
1634
- onStartConnection={handleStartConnection}
1635
- onUpdatePosition={updateNodePosition}
1636
- onDelete={deleteNode}
1637
- onClearConnections={clearMergeConnections}
1638
- />
1639
- );
1640
- case "BACKGROUND":
1641
- return (
1642
- <BackgroundNodeView
1643
- key={node.id}
1644
- node={node as BackgroundNode}
1645
- onDelete={deleteNode}
1646
- onUpdate={updateNode}
1647
- onStartConnection={handleStartConnection}
1648
- onEndConnection={handleEndSingleConnection}
1649
- onProcess={processNode}
1650
- onUpdatePosition={updateNodePosition}
1651
- />
1652
- );
1653
- case "CLOTHES":
1654
- return (
1655
- <ClothesNodeView
1656
- key={node.id}
1657
- node={node as ClothesNode}
1658
- onDelete={deleteNode}
1659
- onUpdate={updateNode}
1660
- onStartConnection={handleStartConnection}
1661
- onEndConnection={handleEndSingleConnection}
1662
- onProcess={processNode}
1663
- onUpdatePosition={updateNodePosition}
1664
- />
1665
- );
1666
- case "STYLE":
1667
- return (
1668
- <StyleNodeView
1669
- key={node.id}
1670
- node={node as StyleNode}
1671
- onDelete={deleteNode}
1672
- onUpdate={updateNode}
1673
- onStartConnection={handleStartConnection}
1674
- onEndConnection={handleEndSingleConnection}
1675
- onProcess={processNode}
1676
- onUpdatePosition={updateNodePosition}
1677
- />
1678
- );
1679
- case "EDIT":
1680
- return (
1681
- <EditNodeView
1682
- key={node.id}
1683
- node={node as EditNode}
1684
- onDelete={deleteNode}
1685
- onUpdate={updateNode}
1686
- onStartConnection={handleStartConnection}
1687
- onEndConnection={handleEndSingleConnection}
1688
- onProcess={processNode}
1689
- onUpdatePosition={updateNodePosition}
1690
- />
1691
- );
1692
- case "CAMERA":
1693
- return (
1694
- <CameraNodeView
1695
- key={node.id}
1696
- node={node as CameraNode}
1697
- onDelete={deleteNode}
1698
- onUpdate={updateNode}
1699
- onStartConnection={handleStartConnection}
1700
- onEndConnection={handleEndSingleConnection}
1701
- onProcess={processNode}
1702
- onUpdatePosition={updateNodePosition}
1703
- />
1704
- );
1705
- case "AGE":
1706
- return (
1707
- <AgeNodeView
1708
- key={node.id}
1709
- node={node as AgeNode}
1710
- onDelete={deleteNode}
1711
- onUpdate={updateNode}
1712
- onStartConnection={handleStartConnection}
1713
- onEndConnection={handleEndSingleConnection}
1714
- onProcess={processNode}
1715
- onUpdatePosition={updateNodePosition}
1716
- />
1717
- );
1718
- case "FACE":
1719
- return (
1720
- <FaceNodeView
1721
- key={node.id}
1722
- node={node as FaceNode}
1723
- onDelete={deleteNode}
1724
- onUpdate={updateNode}
1725
- onStartConnection={handleStartConnection}
1726
- onEndConnection={handleEndSingleConnection}
1727
- onProcess={processNode}
1728
- onUpdatePosition={updateNodePosition}
1729
- />
1730
- );
1731
- default:
1732
- return null;
1733
- }
1734
- })}
1735
- </div>
1736
- </div>
1737
-
1738
- {menuOpen && (
1739
- <div
1740
- className="absolute z-50 rounded-xl border border-white/10 bg-[#111]/95 backdrop-blur p-1 w-56 shadow-2xl"
1741
- style={{ left: menuPos.x, top: menuPos.y }}
1742
- onMouseLeave={() => setMenuOpen(false)}
1743
- >
1744
- <div className="px-3 py-2 text-xs text-white/60">Add node</div>
1745
- <div className="max-h-[400px] overflow-y-auto">
1746
- <button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("CHARACTER")}>CHARACTER</button>
1747
- <button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("MERGE")}>MERGE</button>
1748
- <button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("BACKGROUND")}>BACKGROUND</button>
1749
- <button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("CLOTHES")}>CLOTHES</button>
1750
- <button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("STYLE")}>STYLE</button>
1751
- <button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("EDIT")}>EDIT</button>
1752
- <button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("CAMERA")}>CAMERA</button>
1753
- <button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("AGE")}>AGE</button>
1754
- <button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("FACE")}>FACE</button>
1755
- </div>
1756
- </div>
1757
- )}
1758
- </div>
1759
- </div>
1760
- );
1761
- }
1762
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/{editor/nodes.tsx → nodes.tsx} RENAMED
@@ -1,13 +1,13 @@
1
  "use client";
2
 
3
  import React, { useState, useRef, useEffect } from "react";
4
- import { Button } from "../../components/ui/button";
5
- import { Select } from "../../components/ui/select";
6
- import { Textarea } from "../../components/ui/textarea";
7
- import { Label } from "../../components/ui/label";
8
- import { Slider } from "../../components/ui/slider";
9
- import { ColorPicker } from "../../components/ui/color-picker";
10
- import { Checkbox } from "../../components/ui/checkbox";
11
 
12
  // Helper function to download image
13
  function downloadImage(dataUrl: string, filename: string) {
 
1
  "use client";
2
 
3
  import React, { useState, useRef, useEffect } from "react";
4
+ import { Button } from "../components/ui/button";
5
+ import { Select } from "../components/ui/select";
6
+ import { Textarea } from "../components/ui/textarea";
7
+ import { Label } from "../components/ui/label";
8
+ import { Slider } from "../components/ui/slider";
9
+ import { ColorPicker } from "../components/ui/color-picker";
10
+ import { Checkbox } from "../components/ui/checkbox";
11
 
12
  // Helper function to download image
13
  function downloadImage(dataUrl: string, filename: string) {
app/page.tsx CHANGED
@@ -1,103 +1,1861 @@
1
- import Image from "next/image";
2
-
3
- export default function Home() {
4
- return (
5
- <div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
6
- <main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
7
- <Image
8
- className="dark:invert"
9
- src="/next.svg"
10
- alt="Next.js logo"
11
- width={180}
12
- height={38}
13
- priority
14
- />
15
- <ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
16
- <li className="mb-2 tracking-[-.01em]">
17
- Get started by editing{" "}
18
- <code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
19
- app/page.tsx
20
- </code>
21
- .
22
- </li>
23
- <li className="tracking-[-.01em]">
24
- Save and see your changes instantly.
25
- </li>
26
- </ol>
27
-
28
- <div className="flex gap-4 items-center flex-col sm:flex-row">
29
- <a
30
- className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
31
- href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
32
- target="_blank"
33
- rel="noopener noreferrer"
34
- >
35
- <Image
36
- className="dark:invert"
37
- src="/vercel.svg"
38
- alt="Vercel logomark"
39
- width={20}
40
- height={20}
41
- />
42
- Deploy now
43
- </a>
44
- <a
45
- className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
46
- href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
47
- target="_blank"
48
- rel="noopener noreferrer"
49
- >
50
- Read our docs
51
- </a>
52
- </div>
53
- </main>
54
- <footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
55
- <a
56
- className="flex items-center gap-2 hover:underline hover:underline-offset-4"
57
- href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
58
- target="_blank"
59
- rel="noopener noreferrer"
60
- >
61
- <Image
62
- aria-hidden
63
- src="/file.svg"
64
- alt="File icon"
65
- width={16}
66
- height={16}
67
- />
68
- Learn
69
- </a>
70
- <a
71
- className="flex items-center gap-2 hover:underline hover:underline-offset-4"
72
- href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
73
- target="_blank"
74
- rel="noopener noreferrer"
75
- >
76
- <Image
77
- aria-hidden
78
- src="/window.svg"
79
- alt="Window icon"
80
- width={16}
81
- height={16}
82
- />
83
- Examples
84
- </a>
85
- <a
86
- className="flex items-center gap-2 hover:underline hover:underline-offset-4"
87
- href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
88
- target="_blank"
89
- rel="noopener noreferrer"
90
- >
91
- <Image
92
- aria-hidden
93
- src="/globe.svg"
94
- alt="Globe icon"
95
- width={16}
96
- height={16}
97
- />
98
- Go to nextjs.org →
99
- </a>
100
- </footer>
101
- </div>
102
- );
103
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React, { useEffect, useMemo, useRef, useState } from "react";
4
+ import "./editor.css";
5
+ import {
6
+ BackgroundNodeView,
7
+ ClothesNodeView,
8
+ StyleNodeView,
9
+ EditNodeView,
10
+ CameraNodeView,
11
+ AgeNodeView,
12
+ FaceNodeView
13
+ } from "./nodes";
14
+ import { Button } from "../components/ui/button";
15
+ import { Input } from "../components/ui/input";
16
+
17
+ function cx(...args: Array<string | false | null | undefined>) {
18
+ return args.filter(Boolean).join(" ");
19
+ }
20
+
21
+ // Simple ID helper
22
+ const uid = () => Math.random().toString(36).slice(2, 9);
23
+
24
+ // Generate merge prompt based on number of inputs
25
+ function generateMergePrompt(characterData: { image: string; label: string }[]): string {
26
+ const count = characterData.length;
27
+
28
+ const labels = characterData.map((d, i) => `Image ${i + 1} (${d.label})`).join(", ");
29
+
30
+ return `MERGE TASK: Create a natural, cohesive group photo combining ALL subjects from ${count} provided images.
31
+
32
+ Images provided:
33
+ ${characterData.map((d, i) => `- Image ${i + 1}: ${d.label}`).join("\n")}
34
+
35
+ CRITICAL REQUIREMENTS:
36
+ 1. Extract ALL people/subjects from EACH image exactly as they appear
37
+ 2. Place them together in a SINGLE UNIFIED SCENE with:
38
+ - Consistent lighting direction and color temperature
39
+ - Matching shadows and ambient lighting
40
+ - Proper scale relationships (realistic relative sizes)
41
+ - Natural spacing as if they were photographed together
42
+ - Shared environment/background that looks cohesive
43
+
44
+ 3. Composition guidelines:
45
+ - Arrange subjects at similar depth (not one far behind another)
46
+ - Use natural group photo positioning (slight overlap is ok)
47
+ - Ensure all faces are clearly visible
48
+ - Create visual balance in the composition
49
+ - Apply consistent color grading across all subjects
50
+
51
+ 4. Environmental unity:
52
+ - Use a single, coherent background for all subjects
53
+ - Match the perspective as if taken with one camera
54
+ - Ensure ground plane continuity (all standing on same level)
55
+ - Apply consistent atmospheric effects (if any)
56
+
57
+ 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.`;
58
+ }
59
+
60
+ // Types
61
+ type NodeType = "CHARACTER" | "MERGE" | "BACKGROUND" | "CLOTHES" | "STYLE" | "EDIT" | "CAMERA" | "AGE" | "FACE" | "BLEND";
62
+
63
+ type NodeBase = {
64
+ id: string;
65
+ type: NodeType;
66
+ x: number; // world coords
67
+ y: number; // world coords
68
+ };
69
+
70
+ type CharacterNode = NodeBase & {
71
+ type: "CHARACTER";
72
+ image: string; // data URL or http URL
73
+ label?: string;
74
+ };
75
+
76
+ type MergeNode = NodeBase & {
77
+ type: "MERGE";
78
+ inputs: string[]; // node ids
79
+ output?: string | null; // data URL from merge
80
+ isRunning?: boolean;
81
+ error?: string | null;
82
+ };
83
+
84
+ type BackgroundNode = NodeBase & {
85
+ type: "BACKGROUND";
86
+ input?: string; // node id
87
+ output?: string;
88
+ backgroundType: "color" | "image" | "upload" | "custom";
89
+ backgroundColor?: string;
90
+ backgroundImage?: string;
91
+ customBackgroundImage?: string;
92
+ customPrompt?: string;
93
+ isRunning?: boolean;
94
+ error?: string | null;
95
+ };
96
+
97
+ type ClothesNode = NodeBase & {
98
+ type: "CLOTHES";
99
+ input?: string;
100
+ output?: string;
101
+ clothesImage?: string;
102
+ selectedPreset?: string;
103
+ clothesPrompt?: string;
104
+ isRunning?: boolean;
105
+ error?: string | null;
106
+ };
107
+
108
+ type StyleNode = NodeBase & {
109
+ type: "STYLE";
110
+ input?: string;
111
+ output?: string;
112
+ stylePreset?: string;
113
+ styleStrength?: number;
114
+ isRunning?: boolean;
115
+ error?: string | null;
116
+ };
117
+
118
+ type EditNode = NodeBase & {
119
+ type: "EDIT";
120
+ input?: string;
121
+ output?: string;
122
+ editPrompt?: string;
123
+ isRunning?: boolean;
124
+ error?: string | null;
125
+ };
126
+
127
+ type CameraNode = NodeBase & {
128
+ type: "CAMERA";
129
+ input?: string;
130
+ output?: string;
131
+ focalLength?: string;
132
+ aperture?: string;
133
+ shutterSpeed?: string;
134
+ whiteBalance?: string;
135
+ angle?: string;
136
+ iso?: string;
137
+ filmStyle?: string;
138
+ lighting?: string;
139
+ bokeh?: string;
140
+ composition?: string;
141
+ aspectRatio?: string;
142
+ isRunning?: boolean;
143
+ error?: string | null;
144
+ };
145
+
146
+ type AgeNode = NodeBase & {
147
+ type: "AGE";
148
+ input?: string;
149
+ output?: string;
150
+ targetAge?: number;
151
+ isRunning?: boolean;
152
+ error?: string | null;
153
+ };
154
+
155
+ type FaceNode = NodeBase & {
156
+ type: "FACE";
157
+ input?: string;
158
+ output?: string;
159
+ faceOptions?: {
160
+ removePimples?: boolean;
161
+ addSunglasses?: boolean;
162
+ addHat?: boolean;
163
+ changeHairstyle?: string;
164
+ facialExpression?: string;
165
+ beardStyle?: string;
166
+ };
167
+ isRunning?: boolean;
168
+ error?: string | null;
169
+ };
170
+
171
+ type BlendNode = NodeBase & {
172
+ type: "BLEND";
173
+ input?: string;
174
+ output?: string;
175
+ blendStrength?: number;
176
+ isRunning?: boolean;
177
+ error?: string | null;
178
+ };
179
+
180
+ type AnyNode = CharacterNode | MergeNode | BackgroundNode | ClothesNode | StyleNode | EditNode | CameraNode | AgeNode | FaceNode | BlendNode;
181
+
182
+ // Default placeholder portrait
183
+ const DEFAULT_PERSON =
184
+ "https://images.unsplash.com/photo-1527980965255-d3b416303d12?q=80&w=640&auto=format&fit=crop";
185
+
186
+ function toDataUrls(files: FileList | File[]): Promise<string[]> {
187
+ const arr = Array.from(files as File[]);
188
+ return Promise.all(
189
+ arr.map(
190
+ (file) =>
191
+ new Promise<string>((resolve, reject) => {
192
+ const r = new FileReader();
193
+ r.onload = () => resolve(r.result as string);
194
+ r.onerror = reject;
195
+ r.readAsDataURL(file);
196
+ })
197
+ )
198
+ );
199
+ }
200
+
201
+ // Viewport helpers
202
+ function screenToWorld(
203
+ clientX: number,
204
+ clientY: number,
205
+ container: DOMRect,
206
+ tx: number,
207
+ ty: number,
208
+ scale: number
209
+ ) {
210
+ const x = (clientX - container.left - tx) / scale;
211
+ const y = (clientY - container.top - ty) / scale;
212
+ return { x, y };
213
+ }
214
+
215
+ function useNodeDrag(
216
+ nodeId: string,
217
+ scaleRef: React.MutableRefObject<number>,
218
+ initial: { x: number; y: number },
219
+ onUpdatePosition: (id: string, x: number, y: number) => void
220
+ ) {
221
+ const [localPos, setLocalPos] = useState(initial);
222
+ const dragging = useRef(false);
223
+ const start = useRef<{ sx: number; sy: number; ox: number; oy: number } | null>(
224
+ null
225
+ );
226
+
227
+ useEffect(() => {
228
+ setLocalPos(initial);
229
+ }, [initial.x, initial.y]);
230
+
231
+ const onPointerDown = (e: React.PointerEvent) => {
232
+ e.stopPropagation();
233
+ dragging.current = true;
234
+ start.current = { sx: e.clientX, sy: e.clientY, ox: localPos.x, oy: localPos.y };
235
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
236
+ };
237
+ const onPointerMove = (e: React.PointerEvent) => {
238
+ if (!dragging.current || !start.current) return;
239
+ const dx = (e.clientX - start.current.sx) / scaleRef.current;
240
+ const dy = (e.clientY - start.current.sy) / scaleRef.current;
241
+ const newX = start.current.ox + dx;
242
+ const newY = start.current.oy + dy;
243
+ setLocalPos({ x: newX, y: newY });
244
+ onUpdatePosition(nodeId, newX, newY);
245
+ };
246
+ const onPointerUp = (e: React.PointerEvent) => {
247
+ dragging.current = false;
248
+ start.current = null;
249
+ (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
250
+ };
251
+ return { pos: localPos, onPointerDown, onPointerMove, onPointerUp };
252
+ }
253
+
254
+ function Port({
255
+ className,
256
+ nodeId,
257
+ isOutput,
258
+ onStartConnection,
259
+ onEndConnection
260
+ }: {
261
+ className?: string;
262
+ nodeId?: string;
263
+ isOutput?: boolean;
264
+ onStartConnection?: (nodeId: string) => void;
265
+ onEndConnection?: (nodeId: string) => void;
266
+ }) {
267
+ const handlePointerDown = (e: React.PointerEvent) => {
268
+ e.stopPropagation();
269
+ if (isOutput && nodeId && onStartConnection) {
270
+ onStartConnection(nodeId);
271
+ }
272
+ };
273
+
274
+ const handlePointerUp = (e: React.PointerEvent) => {
275
+ e.stopPropagation();
276
+ if (!isOutput && nodeId && onEndConnection) {
277
+ onEndConnection(nodeId);
278
+ }
279
+ };
280
+
281
+ return (
282
+ <div
283
+ className={cx("nb-port", className)}
284
+ onPointerDown={handlePointerDown}
285
+ onPointerUp={handlePointerUp}
286
+ onPointerEnter={handlePointerUp}
287
+ />
288
+ );
289
+ }
290
+
291
+ function CharacterNodeView({
292
+ node,
293
+ scaleRef,
294
+ onChangeImage,
295
+ onChangeLabel,
296
+ onStartConnection,
297
+ onUpdatePosition,
298
+ onDelete,
299
+ }: {
300
+ node: CharacterNode;
301
+ scaleRef: React.MutableRefObject<number>;
302
+ onChangeImage: (id: string, url: string) => void;
303
+ onChangeLabel: (id: string, label: string) => void;
304
+ onStartConnection: (nodeId: string) => void;
305
+ onUpdatePosition: (id: string, x: number, y: number) => void;
306
+ onDelete: (id: string) => void;
307
+ }) {
308
+ const { pos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(
309
+ node.id,
310
+ scaleRef,
311
+ { x: node.x, y: node.y },
312
+ onUpdatePosition
313
+ );
314
+
315
+ const onDrop = async (e: React.DragEvent) => {
316
+ e.preventDefault();
317
+ const f = e.dataTransfer.files;
318
+ if (f && f.length) {
319
+ const [first] = await toDataUrls(f);
320
+ if (first) onChangeImage(node.id, first);
321
+ }
322
+ };
323
+
324
+ const onPaste = async (e: React.ClipboardEvent) => {
325
+ const items = e.clipboardData.items;
326
+ const files: File[] = [];
327
+ for (let i = 0; i < items.length; i++) {
328
+ const it = items[i];
329
+ if (it.type.startsWith("image/")) {
330
+ const f = it.getAsFile();
331
+ if (f) files.push(f);
332
+ }
333
+ }
334
+ if (files.length) {
335
+ const [first] = await toDataUrls(files);
336
+ if (first) onChangeImage(node.id, first);
337
+ return;
338
+ }
339
+ const text = e.clipboardData.getData("text");
340
+ if (text && (text.startsWith("http") || text.startsWith("data:image"))) {
341
+ onChangeImage(node.id, text);
342
+ }
343
+ };
344
+
345
+ return (
346
+ <div
347
+ className="nb-node absolute text-white w-[340px] select-none"
348
+ style={{ left: pos.x, top: pos.y }}
349
+ onDrop={onDrop}
350
+ onDragOver={(e) => e.preventDefault()}
351
+ onPaste={onPaste}
352
+ >
353
+ <div
354
+ className="nb-header cursor-grab active:cursor-grabbing rounded-t-[14px] px-3 py-2 flex items-center justify-between"
355
+ onPointerDown={onPointerDown}
356
+ onPointerMove={onPointerMove}
357
+ onPointerUp={onPointerUp}
358
+ >
359
+ <input
360
+ className="bg-transparent outline-none text-sm font-semibold tracking-wide flex-1"
361
+ value={node.label || "CHARACTER"}
362
+ onChange={(e) => onChangeLabel(node.id, e.target.value)}
363
+ />
364
+ <div className="flex items-center gap-2">
365
+ <Button
366
+ variant="ghost" size="icon" className="text-destructive"
367
+ onClick={(e) => {
368
+ e.stopPropagation();
369
+ if (confirm('Delete MERGE node?')) {
370
+ onDelete(node.id);
371
+ }
372
+ }}
373
+ title="Delete node"
374
+ aria-label="Delete node"
375
+ >
376
+ ×
377
+ </Button>
378
+ <Port
379
+ className="out"
380
+ nodeId={node.id}
381
+ isOutput={true}
382
+ onStartConnection={onStartConnection}
383
+ />
384
+ </div>
385
+ </div>
386
+ <div className="p-3 space-y-3">
387
+ <div className="aspect-[4/5] w-full rounded-xl bg-black/40 grid place-items-center overflow-hidden">
388
+ <img
389
+ src={node.image}
390
+ alt="character"
391
+ className="h-full w-full object-contain"
392
+ draggable={false}
393
+ />
394
+ </div>
395
+ <div className="flex gap-2">
396
+ <label className="text-xs bg-white/10 hover:bg-white/20 rounded px-3 py-1 cursor-pointer">
397
+ Upload
398
+ <input
399
+ type="file"
400
+ accept="image/*"
401
+ className="hidden"
402
+ onChange={async (e) => {
403
+ const files = e.currentTarget.files;
404
+ if (files && files.length > 0) {
405
+ const [first] = await toDataUrls(files);
406
+ if (first) onChangeImage(node.id, first);
407
+ // Reset input safely
408
+ try {
409
+ e.currentTarget.value = "";
410
+ } catch {}
411
+ }
412
+ }}
413
+ />
414
+ </label>
415
+ <button
416
+ className="text-xs bg-white/10 hover:bg-white/20 rounded px-3 py-1"
417
+ onClick={async () => {
418
+ try {
419
+ const text = await navigator.clipboard.readText();
420
+ if (text && (text.startsWith("http") || text.startsWith("data:image"))) {
421
+ onChangeImage(node.id, text);
422
+ }
423
+ } catch {}
424
+ }}
425
+ >
426
+ Paste URL
427
+ </button>
428
+ </div>
429
+ </div>
430
+ </div>
431
+ );
432
+ }
433
+
434
+ function MergeNodeView({
435
+ node,
436
+ scaleRef,
437
+ allNodes,
438
+ onDisconnect,
439
+ onRun,
440
+ onEndConnection,
441
+ onStartConnection,
442
+ onUpdatePosition,
443
+ onDelete,
444
+ onClearConnections,
445
+ }: {
446
+ node: MergeNode;
447
+ scaleRef: React.MutableRefObject<number>;
448
+ allNodes: AnyNode[];
449
+ onDisconnect: (mergeId: string, nodeId: string) => void;
450
+ onRun: (mergeId: string) => void;
451
+ onEndConnection: (mergeId: string) => void;
452
+ onStartConnection: (nodeId: string) => void;
453
+ onUpdatePosition: (id: string, x: number, y: number) => void;
454
+ onDelete: (id: string) => void;
455
+ onClearConnections: (mergeId: string) => void;
456
+ }) {
457
+ const { pos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(
458
+ node.id,
459
+ scaleRef,
460
+ { x: node.x, y: node.y },
461
+ onUpdatePosition
462
+ );
463
+
464
+
465
+ return (
466
+ <div className="nb-node absolute text-white w-[420px]" style={{ left: pos.x, top: pos.y }}>
467
+ <div
468
+ className="nb-header cursor-grab active:cursor-grabbing rounded-t-[14px] px-3 py-2 flex items-center justify-between"
469
+ onPointerDown={onPointerDown}
470
+ onPointerMove={onPointerMove}
471
+ onPointerUp={onPointerUp}
472
+ >
473
+ <Port
474
+ className="in"
475
+ nodeId={node.id}
476
+ isOutput={false}
477
+ onEndConnection={onEndConnection}
478
+ />
479
+ <div className="font-semibold tracking-wide text-sm flex-1 text-center">MERGE</div>
480
+ <div className="flex items-center gap-2">
481
+ <button
482
+ 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"
483
+ onClick={(e) => {
484
+ e.stopPropagation();
485
+ if (confirm('Delete MERGE node?')) {
486
+ onDelete(node.id);
487
+ }
488
+ }}
489
+ title="Delete node"
490
+ >
491
+ ×
492
+ </button>
493
+ <Port
494
+ className="out"
495
+ nodeId={node.id}
496
+ isOutput={true}
497
+ onStartConnection={onStartConnection}
498
+ />
499
+ </div>
500
+ </div>
501
+ <div className="p-3 space-y-3">
502
+ <div className="text-xs text-white/70">Inputs</div>
503
+ <div className="flex flex-wrap gap-2">
504
+ {node.inputs.map((id) => {
505
+ const inputNode = allNodes.find((n) => n.id === id);
506
+ if (!inputNode) return null;
507
+
508
+ // Get image and label based on node type
509
+ let image: string | null = null;
510
+ let label = "";
511
+
512
+ if (inputNode.type === "CHARACTER") {
513
+ image = (inputNode as CharacterNode).image;
514
+ label = (inputNode as CharacterNode).label || "Character";
515
+ } else if ((inputNode as any).output) {
516
+ image = (inputNode as any).output;
517
+ label = `${inputNode.type}`;
518
+ } else if (inputNode.type === "MERGE" && (inputNode as MergeNode).output) {
519
+ const mergeOutput = (inputNode as MergeNode).output;
520
+ image = mergeOutput !== undefined ? mergeOutput : null;
521
+ label = "Merged";
522
+ } else {
523
+ // Node without output yet
524
+ label = `${inputNode.type} (pending)`;
525
+ }
526
+
527
+ return (
528
+ <div key={id} className="flex items-center gap-2 bg-white/10 rounded px-2 py-1">
529
+ {image && (
530
+ <div className="w-6 h-6 rounded overflow-hidden bg-black/20">
531
+ <img src={image} className="w-full h-full object-contain" alt="inp" />
532
+ </div>
533
+ )}
534
+ <span className="text-xs">{label}</span>
535
+ <button
536
+ className="text-[10px] text-red-300 hover:text-red-200"
537
+ onClick={() => onDisconnect(node.id, id)}
538
+ >
539
+ remove
540
+ </button>
541
+ </div>
542
+ );
543
+ })}
544
+ </div>
545
+ {node.inputs.length === 0 && (
546
+ <p className="text-xs text-white/40">Drag from any node's output port to connect</p>
547
+ )}
548
+ <div className="flex items-center gap-2">
549
+ {node.inputs.length > 0 && (
550
+ <Button
551
+ variant="destructive"
552
+ size="sm"
553
+ onClick={() => onClearConnections(node.id)}
554
+ title="Clear all connections"
555
+ >
556
+ Clear
557
+ </Button>
558
+ )}
559
+ <Button
560
+ size="sm"
561
+ onClick={() => onRun(node.id)}
562
+ disabled={node.isRunning || node.inputs.length < 2}
563
+ >
564
+ {node.isRunning ? "Merging…" : "Merge"}
565
+ </Button>
566
+ </div>
567
+
568
+ <div className="mt-2">
569
+ <div className="text-xs text-white/70 mb-1">Output</div>
570
+ <div className="w-full min-h-[200px] max-h-[400px] rounded-xl bg-black/40 grid place-items-center">
571
+ {node.output ? (
572
+ <img src={node.output} className="w-full h-auto max-h-[400px] object-contain rounded-xl" alt="output" />
573
+ ) : (
574
+ <span className="text-white/40 text-xs py-16">Run merge to see result</span>
575
+ )}
576
+ </div>
577
+ {node.output && (
578
+ <Button
579
+ className="w-full mt-2"
580
+ variant="secondary"
581
+ onClick={() => {
582
+ const link = document.createElement('a');
583
+ link.href = node.output as string;
584
+ link.download = `merge-${Date.now()}.png`;
585
+ document.body.appendChild(link);
586
+ link.click();
587
+ document.body.removeChild(link);
588
+ }}
589
+ >
590
+ 📥 Download Merged Image
591
+ </Button>
592
+ )}
593
+ {node.error && (
594
+ <div className="mt-2">
595
+ <div className="text-xs text-red-400">{node.error}</div>
596
+ {node.error.includes("API key") && (
597
+ <div className="text-xs text-white/50 mt-2 space-y-1">
598
+ <p>To fix this:</p>
599
+ <ol className="list-decimal list-inside space-y-1">
600
+ <li>Get key from: <a href="https://aistudio.google.com/app/apikey" target="_blank" className="text-blue-400 hover:underline">Google AI Studio</a></li>
601
+ <li>Edit .env.local file in project root</li>
602
+ <li>Replace placeholder with your key</li>
603
+ <li>Restart server (Ctrl+C, npm run dev)</li>
604
+ </ol>
605
+ </div>
606
+ )}
607
+ </div>
608
+ )}
609
+ </div>
610
+ </div>
611
+ </div>
612
+ );
613
+ }
614
+
615
+ export default function EditorPage() {
616
+ const [nodes, setNodes] = useState<AnyNode[]>(() => [
617
+ {
618
+ id: uid(),
619
+ type: "CHARACTER",
620
+ x: 80,
621
+ y: 120,
622
+ image: DEFAULT_PERSON,
623
+ label: "CHARACTER 1",
624
+ } as CharacterNode,
625
+ ]);
626
+
627
+
628
+ // Viewport state
629
+ const [scale, setScale] = useState(1);
630
+ const [tx, setTx] = useState(0);
631
+ const [ty, setTy] = useState(0);
632
+ const containerRef = useRef<HTMLDivElement>(null);
633
+ const scaleRef = useRef(scale);
634
+ useEffect(() => {
635
+ scaleRef.current = scale;
636
+ }, [scale]);
637
+
638
+ // Connection dragging state
639
+ const [draggingFrom, setDraggingFrom] = useState<string | null>(null);
640
+ const [dragPos, setDragPos] = useState<{x: number, y: number} | null>(null);
641
+
642
+ // API Token state
643
+ const [apiToken, setApiToken] = useState<string>("");
644
+ const [showHelpSidebar, setShowHelpSidebar] = useState(false);
645
+
646
+ const characters = nodes.filter((n) => n.type === "CHARACTER") as CharacterNode[];
647
+ const merges = nodes.filter((n) => n.type === "MERGE") as MergeNode[];
648
+
649
+ // Editor actions
650
+ const addCharacter = (at?: { x: number; y: number }) => {
651
+ setNodes((prev) => [
652
+ ...prev,
653
+ {
654
+ id: uid(),
655
+ type: "CHARACTER",
656
+ x: at ? at.x : 80 + Math.random() * 60,
657
+ y: at ? at.y : 120 + Math.random() * 60,
658
+ image: DEFAULT_PERSON,
659
+ label: `CHARACTER ${prev.filter((n) => n.type === "CHARACTER").length + 1}`,
660
+ } as CharacterNode,
661
+ ]);
662
+ };
663
+ const addMerge = (at?: { x: number; y: number }) => {
664
+ setNodes((prev) => [
665
+ ...prev,
666
+ {
667
+ id: uid(),
668
+ type: "MERGE",
669
+ x: at ? at.x : 520,
670
+ y: at ? at.y : 160,
671
+ inputs: [],
672
+ } as MergeNode,
673
+ ]);
674
+ };
675
+
676
+ const setCharacterImage = (id: string, url: string) => {
677
+ setNodes((prev) =>
678
+ prev.map((n) => (n.id === id && n.type === "CHARACTER" ? { ...n, image: url } : n))
679
+ );
680
+ };
681
+ const setCharacterLabel = (id: string, label: string) => {
682
+ setNodes((prev) => prev.map((n) => (n.id === id && n.type === "CHARACTER" ? { ...n, label } : n)));
683
+ };
684
+
685
+ const updateNodePosition = (id: string, x: number, y: number) => {
686
+ setNodes((prev) => prev.map((n) => (n.id === id ? { ...n, x, y } : n)));
687
+ };
688
+
689
+ const deleteNode = (id: string) => {
690
+ setNodes((prev) => {
691
+ // If it's a MERGE node, just remove it
692
+ // If it's a CHARACTER node, also remove it from all MERGE inputs
693
+ return prev
694
+ .filter((n) => n.id !== id)
695
+ .map((n) => {
696
+ if (n.type === "MERGE") {
697
+ const merge = n as MergeNode;
698
+ return {
699
+ ...merge,
700
+ inputs: merge.inputs.filter((inputId) => inputId !== id),
701
+ };
702
+ }
703
+ return n;
704
+ });
705
+ });
706
+ };
707
+
708
+ const clearMergeConnections = (mergeId: string) => {
709
+ setNodes((prev) =>
710
+ prev.map((n) =>
711
+ n.id === mergeId && n.type === "MERGE"
712
+ ? { ...n, inputs: [] }
713
+ : n
714
+ )
715
+ );
716
+ };
717
+
718
+ // Update any node's properties
719
+ const updateNode = (id: string, updates: any) => {
720
+ setNodes((prev) => prev.map((n) => (n.id === id ? { ...n, ...updates } : n)));
721
+ };
722
+
723
+ // Handle single input connections for new nodes
724
+ const handleEndSingleConnection = (nodeId: string) => {
725
+ if (draggingFrom) {
726
+ // Find the source node
727
+ const sourceNode = nodes.find(n => n.id === draggingFrom);
728
+ if (sourceNode) {
729
+ // Allow connections from ANY node that has an output port
730
+ // This includes:
731
+ // - CHARACTER nodes (always have an image)
732
+ // - MERGE nodes (can have output after merging)
733
+ // - Any processing node (BACKGROUND, CLOTHES, BLEND, etc.)
734
+ // - Even unprocessed nodes (for configuration chaining)
735
+
736
+ // All nodes can be connected for chaining
737
+ setNodes(prev => prev.map(n =>
738
+ n.id === nodeId ? { ...n, input: draggingFrom } : n
739
+ ));
740
+ }
741
+ setDraggingFrom(null);
742
+ setDragPos(null);
743
+ // Re-enable text selection
744
+ document.body.style.userSelect = '';
745
+ document.body.style.webkitUserSelect = '';
746
+ }
747
+ };
748
+
749
+ // Helper to count pending configurations in chain
750
+ const countPendingConfigurations = (startNodeId: string): number => {
751
+ let count = 0;
752
+ const visited = new Set<string>();
753
+
754
+ const traverse = (nodeId: string) => {
755
+ if (visited.has(nodeId)) return;
756
+ visited.add(nodeId);
757
+
758
+ const node = nodes.find(n => n.id === nodeId);
759
+ if (!node) return;
760
+
761
+ // Check if this node has configuration but no output
762
+ if (!(node as any).output && node.type !== "CHARACTER" && node.type !== "MERGE") {
763
+ const config = getNodeConfiguration(node);
764
+ if (Object.keys(config).length > 0) {
765
+ count++;
766
+ }
767
+ }
768
+
769
+ // Check upstream
770
+ const upstreamId = (node as any).input;
771
+ if (upstreamId) {
772
+ traverse(upstreamId);
773
+ }
774
+ };
775
+
776
+ traverse(startNodeId);
777
+ return count;
778
+ };
779
+
780
+ // Helper to extract configuration from a node
781
+ const getNodeConfiguration = (node: AnyNode): any => {
782
+ const config: any = {};
783
+
784
+ switch (node.type) {
785
+ case "BACKGROUND":
786
+ if ((node as BackgroundNode).backgroundType) {
787
+ config.backgroundType = (node as BackgroundNode).backgroundType;
788
+ config.backgroundColor = (node as BackgroundNode).backgroundColor;
789
+ config.backgroundImage = (node as BackgroundNode).backgroundImage;
790
+ config.customBackgroundImage = (node as BackgroundNode).customBackgroundImage;
791
+ config.customPrompt = (node as BackgroundNode).customPrompt;
792
+ }
793
+ break;
794
+ case "CLOTHES":
795
+ if ((node as ClothesNode).clothesImage) {
796
+ config.clothesImage = (node as ClothesNode).clothesImage;
797
+ config.selectedPreset = (node as ClothesNode).selectedPreset;
798
+ }
799
+ break;
800
+ case "STYLE":
801
+ if ((node as StyleNode).stylePreset) {
802
+ config.stylePreset = (node as StyleNode).stylePreset;
803
+ config.styleStrength = (node as StyleNode).styleStrength;
804
+ }
805
+ break;
806
+ case "EDIT":
807
+ if ((node as EditNode).editPrompt) {
808
+ config.editPrompt = (node as EditNode).editPrompt;
809
+ }
810
+ break;
811
+ case "CAMERA":
812
+ const cam = node as CameraNode;
813
+ if (cam.focalLength && cam.focalLength !== "None") config.focalLength = cam.focalLength;
814
+ if (cam.aperture && cam.aperture !== "None") config.aperture = cam.aperture;
815
+ if (cam.shutterSpeed && cam.shutterSpeed !== "None") config.shutterSpeed = cam.shutterSpeed;
816
+ if (cam.whiteBalance && cam.whiteBalance !== "None") config.whiteBalance = cam.whiteBalance;
817
+ if (cam.angle && cam.angle !== "None") config.angle = cam.angle;
818
+ if (cam.iso && cam.iso !== "None") config.iso = cam.iso;
819
+ if (cam.filmStyle && cam.filmStyle !== "None") config.filmStyle = cam.filmStyle;
820
+ if (cam.lighting && cam.lighting !== "None") config.lighting = cam.lighting;
821
+ if (cam.bokeh && cam.bokeh !== "None") config.bokeh = cam.bokeh;
822
+ if (cam.composition && cam.composition !== "None") config.composition = cam.composition;
823
+ if (cam.aspectRatio && cam.aspectRatio !== "None") config.aspectRatio = cam.aspectRatio;
824
+ break;
825
+ case "AGE":
826
+ if ((node as AgeNode).targetAge) {
827
+ config.targetAge = (node as AgeNode).targetAge;
828
+ }
829
+ break;
830
+ case "FACE":
831
+ const face = node as FaceNode;
832
+ if (face.faceOptions) {
833
+ const opts: any = {};
834
+ if (face.faceOptions.removePimples) opts.removePimples = true;
835
+ if (face.faceOptions.addSunglasses) opts.addSunglasses = true;
836
+ if (face.faceOptions.addHat) opts.addHat = true;
837
+ if (face.faceOptions.changeHairstyle && face.faceOptions.changeHairstyle !== "None") {
838
+ opts.changeHairstyle = face.faceOptions.changeHairstyle;
839
+ }
840
+ if (face.faceOptions.facialExpression && face.faceOptions.facialExpression !== "None") {
841
+ opts.facialExpression = face.faceOptions.facialExpression;
842
+ }
843
+ if (face.faceOptions.beardStyle && face.faceOptions.beardStyle !== "None") {
844
+ opts.beardStyle = face.faceOptions.beardStyle;
845
+ }
846
+ if (Object.keys(opts).length > 0) {
847
+ config.faceOptions = opts;
848
+ }
849
+ }
850
+ break;
851
+ }
852
+
853
+ return config;
854
+ };
855
+
856
+ // Process node with API
857
+ const processNode = async (nodeId: string) => {
858
+ const node = nodes.find(n => n.id === nodeId);
859
+ if (!node) {
860
+ console.error("Node not found:", nodeId);
861
+ return;
862
+ }
863
+
864
+ // Get input image and collect all configurations from chain
865
+ let inputImage: string | null = null;
866
+ let accumulatedParams: any = {};
867
+ const processedNodes: string[] = []; // Track which nodes' configs we're applying
868
+ const inputId = (node as any).input;
869
+
870
+ if (inputId) {
871
+ // Track unprocessed MERGE nodes that need to be executed
872
+ const unprocessedMerges: MergeNode[] = [];
873
+
874
+ // Find the source image by traversing the chain backwards
875
+ const findSourceImage = (currentNodeId: string, visited: Set<string> = new Set()): string | null => {
876
+ if (visited.has(currentNodeId)) return null;
877
+ visited.add(currentNodeId);
878
+
879
+ const currentNode = nodes.find(n => n.id === currentNodeId);
880
+ if (!currentNode) return null;
881
+
882
+ // If this is a CHARACTER node, return its image
883
+ if (currentNode.type === "CHARACTER") {
884
+ return (currentNode as CharacterNode).image;
885
+ }
886
+
887
+ // If this is a MERGE node with output, return its output
888
+ if (currentNode.type === "MERGE" && (currentNode as MergeNode).output) {
889
+ return (currentNode as MergeNode).output || null;
890
+ }
891
+
892
+ // If any node has been processed, return its output
893
+ if ((currentNode as any).output) {
894
+ return (currentNode as any).output;
895
+ }
896
+
897
+ // For MERGE nodes without output, we need to process them first
898
+ if (currentNode.type === "MERGE") {
899
+ const merge = currentNode as MergeNode;
900
+ if (!merge.output && merge.inputs.length >= 2) {
901
+ // Mark this merge for processing
902
+ unprocessedMerges.push(merge);
903
+ // For now, return null - we'll process the merge first
904
+ return null;
905
+ } else if (merge.inputs.length > 0) {
906
+ // Try to get image from first input if merge can't be executed
907
+ const firstInput = merge.inputs[0];
908
+ const inputImage = findSourceImage(firstInput, visited);
909
+ if (inputImage) return inputImage;
910
+ }
911
+ }
912
+
913
+ // Otherwise, check upstream
914
+ const upstreamId = (currentNode as any).input;
915
+ if (upstreamId) {
916
+ return findSourceImage(upstreamId, visited);
917
+ }
918
+
919
+ return null;
920
+ };
921
+
922
+ // Collect all configurations from unprocessed nodes in the chain
923
+ const collectConfigurations = (currentNodeId: string, visited: Set<string> = new Set()): any => {
924
+ if (visited.has(currentNodeId)) return {};
925
+ visited.add(currentNodeId);
926
+
927
+ const currentNode = nodes.find(n => n.id === currentNodeId);
928
+ if (!currentNode) return {};
929
+
930
+ let configs: any = {};
931
+
932
+ // First, collect from upstream nodes
933
+ const upstreamId = (currentNode as any).input;
934
+ if (upstreamId) {
935
+ configs = collectConfigurations(upstreamId, visited);
936
+ }
937
+
938
+ // Add this node's configuration only if:
939
+ // 1. It's the current node being processed, OR
940
+ // 2. It hasn't been processed yet (no output) AND it's not the current node
941
+ const shouldIncludeConfig =
942
+ currentNodeId === nodeId || // Always include current node's config
943
+ (!(currentNode as any).output && currentNodeId !== nodeId); // Include unprocessed intermediate nodes
944
+
945
+ if (shouldIncludeConfig) {
946
+ const nodeConfig = getNodeConfiguration(currentNode);
947
+ if (Object.keys(nodeConfig).length > 0) {
948
+ configs = { ...configs, ...nodeConfig };
949
+ // Track unprocessed intermediate nodes
950
+ if (currentNodeId !== nodeId && !(currentNode as any).output) {
951
+ processedNodes.push(currentNodeId);
952
+ }
953
+ }
954
+ }
955
+
956
+ return configs;
957
+ };
958
+
959
+ // Find the source image
960
+ inputImage = findSourceImage(inputId);
961
+
962
+ // If we found unprocessed merges, we need to execute them first
963
+ if (unprocessedMerges.length > 0 && !inputImage) {
964
+ console.log(`Found ${unprocessedMerges.length} unprocessed MERGE nodes in chain. Processing them first...`);
965
+
966
+ // Process each merge node
967
+ for (const merge of unprocessedMerges) {
968
+ // Set loading state for the merge
969
+ setNodes(prev => prev.map(n =>
970
+ n.id === merge.id ? { ...n, isRunning: true, error: null } : n
971
+ ));
972
+
973
+ try {
974
+ const mergeOutput = await executeMerge(merge);
975
+
976
+ // Update the merge node with output
977
+ setNodes(prev => prev.map(n =>
978
+ n.id === merge.id ? { ...n, output: mergeOutput || undefined, isRunning: false, error: null } : n
979
+ ));
980
+
981
+ // Track that we processed this merge as part of the chain
982
+ processedNodes.push(merge.id);
983
+
984
+ // Now use this as our input image if it's the direct input
985
+ if (inputId === merge.id) {
986
+ inputImage = mergeOutput;
987
+ }
988
+ } catch (e: any) {
989
+ console.error("Auto-merge error:", e);
990
+ setNodes(prev => prev.map(n =>
991
+ n.id === merge.id ? { ...n, isRunning: false, error: e?.message || "Merge failed" } : n
992
+ ));
993
+ // Abort the main processing if merge failed
994
+ setNodes(prev => prev.map(n =>
995
+ n.id === nodeId ? { ...n, error: "Failed to process upstream MERGE node", isRunning: false } : n
996
+ ));
997
+ return;
998
+ }
999
+ }
1000
+
1001
+ // After processing merges, try to find the source image again
1002
+ if (!inputImage) {
1003
+ inputImage = findSourceImage(inputId);
1004
+ }
1005
+ }
1006
+
1007
+ // Collect configurations from the chain
1008
+ accumulatedParams = collectConfigurations(inputId, new Set());
1009
+ }
1010
+
1011
+ if (!inputImage) {
1012
+ const errorMsg = inputId
1013
+ ? "No source image found in the chain. Connect to a CHARACTER node or processed node."
1014
+ : "No input connected. Connect an image source to this node.";
1015
+ setNodes(prev => prev.map(n =>
1016
+ n.id === nodeId ? { ...n, error: errorMsg, isRunning: false } : n
1017
+ ));
1018
+ return;
1019
+ }
1020
+
1021
+ // Add current node's configuration
1022
+ const currentNodeConfig = getNodeConfiguration(node);
1023
+ const params = { ...accumulatedParams, ...currentNodeConfig };
1024
+
1025
+ // Count how many unprocessed nodes we're combining
1026
+ const unprocessedNodeCount = Object.keys(params).length > 0 ?
1027
+ (processedNodes.length + 1) : 1;
1028
+
1029
+ // Show info about batch processing
1030
+ if (unprocessedNodeCount > 1) {
1031
+ console.log(`🚀 Combining ${unprocessedNodeCount} node transformations into ONE API call`);
1032
+ console.log("Combined parameters:", params);
1033
+ } else {
1034
+ console.log("Processing single node:", node.type);
1035
+ }
1036
+
1037
+ // Set loading state for all nodes being processed
1038
+ setNodes(prev => prev.map(n => {
1039
+ if (n.id === nodeId || processedNodes.includes(n.id)) {
1040
+ return { ...n, isRunning: true, error: null };
1041
+ }
1042
+ return n;
1043
+ }));
1044
+
1045
+ try {
1046
+ // Validate image data before sending
1047
+ if (inputImage && inputImage.length > 10 * 1024 * 1024) { // 10MB limit warning
1048
+ console.warn("Large input image detected, size:", (inputImage.length / (1024 * 1024)).toFixed(2) + "MB");
1049
+ }
1050
+
1051
+ // Check if params contains custom images and validate them
1052
+ if (params.clothesImage) {
1053
+ console.log("[Process] Clothes image size:", (params.clothesImage.length / 1024).toFixed(2) + "KB");
1054
+ // Validate it's a proper data URL
1055
+ if (!params.clothesImage.startsWith('data:') && !params.clothesImage.startsWith('http') && !params.clothesImage.startsWith('/')) {
1056
+ throw new Error("Invalid clothes image format. Please upload a valid image.");
1057
+ }
1058
+ }
1059
+
1060
+ if (params.customBackgroundImage) {
1061
+ console.log("[Process] Custom background size:", (params.customBackgroundImage.length / 1024).toFixed(2) + "KB");
1062
+ // Validate it's a proper data URL
1063
+ if (!params.customBackgroundImage.startsWith('data:') && !params.customBackgroundImage.startsWith('http') && !params.customBackgroundImage.startsWith('/')) {
1064
+ throw new Error("Invalid background image format. Please upload a valid image.");
1065
+ }
1066
+ }
1067
+
1068
+ // Log request details for debugging
1069
+ console.log("[Process] Sending request with:", {
1070
+ hasImage: !!inputImage,
1071
+ imageSize: inputImage ? (inputImage.length / 1024).toFixed(2) + "KB" : 0,
1072
+ paramsKeys: Object.keys(params),
1073
+ nodeType: node.type
1074
+ });
1075
+
1076
+ // Make a SINGLE API call with all accumulated parameters
1077
+ const res = await fetch("/api/process", {
1078
+ method: "POST",
1079
+ headers: { "Content-Type": "application/json" },
1080
+ body: JSON.stringify({
1081
+ type: "COMBINED", // Indicate this is a combined processing
1082
+ image: inputImage,
1083
+ params,
1084
+ apiToken: apiToken || undefined
1085
+ }),
1086
+ });
1087
+
1088
+ // Check if response is actually JSON before parsing
1089
+ const contentType = res.headers.get("content-type");
1090
+ if (!contentType || !contentType.includes("application/json")) {
1091
+ const textResponse = await res.text();
1092
+ console.error("Non-JSON response received:", textResponse);
1093
+ throw new Error("Server returned an error page instead of JSON. Check your API key configuration.");
1094
+ }
1095
+
1096
+ const data = await res.json();
1097
+ if (!res.ok) throw new Error(data.error || "Processing failed");
1098
+
1099
+ // Only update the current node with the output
1100
+ // Don't show output in intermediate nodes - they were just used for configuration
1101
+ setNodes(prev => prev.map(n => {
1102
+ if (n.id === nodeId) {
1103
+ // Only the current node gets the final output displayed
1104
+ return { ...n, output: data.image, isRunning: false, error: null };
1105
+ } else if (processedNodes.includes(n.id)) {
1106
+ // Mark intermediate nodes as no longer running but don't give them output
1107
+ // This way they remain unprocessed visually but their configs were used
1108
+ return { ...n, isRunning: false, error: null };
1109
+ }
1110
+ return n;
1111
+ }));
1112
+
1113
+ if (unprocessedNodeCount > 1) {
1114
+ console.log(`✅ Successfully applied ${unprocessedNodeCount} transformations in ONE API call!`);
1115
+ console.log(`Saved ${unprocessedNodeCount - 1} API calls by combining transformations`);
1116
+ }
1117
+ } catch (e: any) {
1118
+ console.error("Process error:", e);
1119
+ // Clear loading state for all nodes
1120
+ setNodes(prev => prev.map(n => {
1121
+ if (n.id === nodeId || processedNodes.includes(n.id)) {
1122
+ return { ...n, isRunning: false, error: e?.message || "Error" };
1123
+ }
1124
+ return n;
1125
+ }));
1126
+ }
1127
+ };
1128
+
1129
+ const connectToMerge = (mergeId: string, nodeId: string) => {
1130
+ setNodes((prev) =>
1131
+ prev.map((n) =>
1132
+ n.id === mergeId && n.type === "MERGE"
1133
+ ? { ...n, inputs: Array.from(new Set([...(n as MergeNode).inputs, nodeId])) }
1134
+ : n
1135
+ )
1136
+ );
1137
+ };
1138
+
1139
+ // Connection drag handlers
1140
+ const handleStartConnection = (nodeId: string) => {
1141
+ setDraggingFrom(nodeId);
1142
+ // Prevent text selection during dragging
1143
+ document.body.style.userSelect = 'none';
1144
+ document.body.style.webkitUserSelect = 'none';
1145
+ };
1146
+
1147
+ const handleEndConnection = (mergeId: string) => {
1148
+ if (draggingFrom) {
1149
+ // Allow connections from any node type that could have an output
1150
+ const sourceNode = nodes.find(n => n.id === draggingFrom);
1151
+ if (sourceNode) {
1152
+ // Allow connections from:
1153
+ // - CHARACTER nodes (always have an image)
1154
+ // - Any node with an output (processed nodes)
1155
+ // - Any processing node (for future processing)
1156
+ connectToMerge(mergeId, draggingFrom);
1157
+ }
1158
+ setDraggingFrom(null);
1159
+ setDragPos(null);
1160
+ // Re-enable text selection
1161
+ document.body.style.userSelect = '';
1162
+ document.body.style.webkitUserSelect = '';
1163
+ }
1164
+ };
1165
+
1166
+ const handlePointerMove = (e: React.PointerEvent) => {
1167
+ if (draggingFrom) {
1168
+ const rect = containerRef.current!.getBoundingClientRect();
1169
+ const world = screenToWorld(e.clientX, e.clientY, rect, tx, ty, scale);
1170
+ setDragPos(world);
1171
+ }
1172
+ };
1173
+
1174
+ const handlePointerUp = () => {
1175
+ if (draggingFrom) {
1176
+ setDraggingFrom(null);
1177
+ setDragPos(null);
1178
+ // Re-enable text selection
1179
+ document.body.style.userSelect = '';
1180
+ document.body.style.webkitUserSelect = '';
1181
+ }
1182
+ };
1183
+ const disconnectFromMerge = (mergeId: string, nodeId: string) => {
1184
+ setNodes((prev) =>
1185
+ prev.map((n) =>
1186
+ n.id === mergeId && n.type === "MERGE"
1187
+ ? { ...n, inputs: (n as MergeNode).inputs.filter((i) => i !== nodeId) }
1188
+ : n
1189
+ )
1190
+ );
1191
+ };
1192
+
1193
+ const executeMerge = async (merge: MergeNode): Promise<string | null> => {
1194
+ // Get images from merge inputs - now accepts any node type
1195
+ const mergeImages: string[] = [];
1196
+ const inputData: { image: string; label: string }[] = [];
1197
+
1198
+ for (const inputId of merge.inputs) {
1199
+ const inputNode = nodes.find(n => n.id === inputId);
1200
+ if (inputNode) {
1201
+ let image: string | null = null;
1202
+ let label = "";
1203
+
1204
+ if (inputNode.type === "CHARACTER") {
1205
+ image = (inputNode as CharacterNode).image;
1206
+ label = (inputNode as CharacterNode).label || "";
1207
+ } else if ((inputNode as any).output) {
1208
+ // Any processed node with output
1209
+ image = (inputNode as any).output;
1210
+ label = `${inputNode.type} Output`;
1211
+ } else if (inputNode.type === "MERGE" && (inputNode as MergeNode).output) {
1212
+ // Another merge node's output
1213
+ const mergeOutput = (inputNode as MergeNode).output;
1214
+ image = mergeOutput !== undefined ? mergeOutput : null;
1215
+ label = "Merged Image";
1216
+ }
1217
+
1218
+ if (image) {
1219
+ // Validate image format
1220
+ if (!image.startsWith('data:') && !image.startsWith('http') && !image.startsWith('/')) {
1221
+ console.error(`Invalid image format for ${label}:`, image.substring(0, 100));
1222
+ continue; // Skip invalid images
1223
+ }
1224
+ mergeImages.push(image);
1225
+ inputData.push({ image, label: label || `Input ${mergeImages.length}` });
1226
+ }
1227
+ }
1228
+ }
1229
+
1230
+ if (mergeImages.length < 2) {
1231
+ throw new Error("Not enough valid inputs for merge. Need at least 2 images.");
1232
+ }
1233
+
1234
+ // Log merge details for debugging
1235
+ console.log("[Merge] Processing merge with:", {
1236
+ imageCount: mergeImages.length,
1237
+ imageSizes: mergeImages.map(img => (img.length / 1024).toFixed(2) + "KB"),
1238
+ labels: inputData.map(d => d.label)
1239
+ });
1240
+
1241
+ const prompt = generateMergePrompt(inputData);
1242
+
1243
+ // Use the process route instead of merge route
1244
+ const res = await fetch("/api/process", {
1245
+ method: "POST",
1246
+ headers: { "Content-Type": "application/json" },
1247
+ body: JSON.stringify({
1248
+ type: "MERGE",
1249
+ images: mergeImages,
1250
+ prompt,
1251
+ apiToken: apiToken || undefined
1252
+ }),
1253
+ });
1254
+
1255
+ // Check if response is actually JSON before parsing
1256
+ const contentType = res.headers.get("content-type");
1257
+ if (!contentType || !contentType.includes("application/json")) {
1258
+ const textResponse = await res.text();
1259
+ console.error("Non-JSON response received:", textResponse);
1260
+ throw new Error("Server returned an error page instead of JSON. Check your API key configuration.");
1261
+ }
1262
+
1263
+ const data = await res.json();
1264
+ if (!res.ok) {
1265
+ throw new Error(data.error || "Merge failed");
1266
+ }
1267
+
1268
+ return data.image || (data.images?.[0] as string) || null;
1269
+ };
1270
+
1271
+ const runMerge = async (mergeId: string) => {
1272
+ setNodes((prev) => prev.map((n) => (n.id === mergeId && n.type === "MERGE" ? { ...n, isRunning: true, error: null } : n)));
1273
+ try {
1274
+ const merge = (nodes.find((n) => n.id === mergeId) as MergeNode) || null;
1275
+ if (!merge) return;
1276
+
1277
+ // Get input nodes with their labels - now accepts any node type
1278
+ const inputData = merge.inputs
1279
+ .map((id, index) => {
1280
+ const inputNode = nodes.find((n) => n.id === id);
1281
+ if (!inputNode) return null;
1282
+
1283
+ // Support CHARACTER nodes, processed nodes, and MERGE outputs
1284
+ let image: string | null = null;
1285
+ let label = "";
1286
+
1287
+ if (inputNode.type === "CHARACTER") {
1288
+ image = (inputNode as CharacterNode).image;
1289
+ label = (inputNode as CharacterNode).label || `CHARACTER ${index + 1}`;
1290
+ } else if ((inputNode as any).output) {
1291
+ // Any processed node with output
1292
+ image = (inputNode as any).output;
1293
+ label = `${inputNode.type} Output ${index + 1}`;
1294
+ } else if (inputNode.type === "MERGE" && (inputNode as MergeNode).output) {
1295
+ // Another merge node's output
1296
+ const mergeOutput = (inputNode as MergeNode).output;
1297
+ image = mergeOutput !== undefined ? mergeOutput : null;
1298
+ label = `Merged Image ${index + 1}`;
1299
+ }
1300
+
1301
+ if (!image) return null;
1302
+
1303
+ return { image, label };
1304
+ })
1305
+ .filter(Boolean) as { image: string; label: string }[];
1306
+
1307
+ if (inputData.length < 2) throw new Error("Connect at least two nodes with images (CHARACTER nodes or processed nodes).");
1308
+
1309
+ // Debug: Log what we're sending
1310
+ console.log("🔄 Merging nodes:", inputData.map(d => d.label).join(", "));
1311
+ console.log("📷 Image URLs being sent:", inputData.map(d => d.image.substring(0, 100) + "..."));
1312
+
1313
+ // Generate dynamic prompt based on number of inputs
1314
+ const prompt = generateMergePrompt(inputData);
1315
+ const imgs = inputData.map(d => d.image);
1316
+
1317
+ // Use the process route with MERGE type
1318
+ const res = await fetch("/api/process", {
1319
+ method: "POST",
1320
+ headers: { "Content-Type": "application/json" },
1321
+ body: JSON.stringify({
1322
+ type: "MERGE",
1323
+ images: imgs,
1324
+ prompt,
1325
+ apiToken: apiToken || undefined
1326
+ }),
1327
+ });
1328
+
1329
+ // Check if response is actually JSON before parsing
1330
+ const contentType = res.headers.get("content-type");
1331
+ if (!contentType || !contentType.includes("application/json")) {
1332
+ const textResponse = await res.text();
1333
+ console.error("Non-JSON response received:", textResponse);
1334
+ throw new Error("Server returned an error page instead of JSON. Check your API key configuration.");
1335
+ }
1336
+
1337
+ const js = await res.json();
1338
+ if (!res.ok) {
1339
+ // Show more helpful error messages
1340
+ const errorMsg = js.error || "Merge failed";
1341
+ if (errorMsg.includes("API key")) {
1342
+ throw new Error("API key not configured. Add GOOGLE_API_KEY to .env.local");
1343
+ }
1344
+ throw new Error(errorMsg);
1345
+ }
1346
+ const out = js.image || (js.images?.[0] as string) || null;
1347
+ setNodes((prev) => prev.map((n) => (n.id === mergeId && n.type === "MERGE" ? { ...n, output: out, isRunning: false } : n)));
1348
+ } catch (e: any) {
1349
+ console.error("Merge error:", e);
1350
+ setNodes((prev) => prev.map((n) => (n.id === mergeId && n.type === "MERGE" ? { ...n, isRunning: false, error: e?.message || "Error" } : n)));
1351
+ }
1352
+ };
1353
+
1354
+ // Calculate SVG bounds for connection lines
1355
+ const svgBounds = useMemo(() => {
1356
+ let minX = 0, minY = 0, maxX = 1000, maxY = 1000;
1357
+ nodes.forEach(node => {
1358
+ minX = Math.min(minX, node.x - 100);
1359
+ minY = Math.min(minY, node.y - 100);
1360
+ maxX = Math.max(maxX, node.x + 500);
1361
+ maxY = Math.max(maxY, node.y + 500);
1362
+ });
1363
+ return {
1364
+ x: minX,
1365
+ y: minY,
1366
+ width: maxX - minX,
1367
+ height: maxY - minY
1368
+ };
1369
+ }, [nodes]);
1370
+
1371
+ // Connection paths with bezier curves
1372
+ const connectionPaths = useMemo(() => {
1373
+ const getNodeOutputPort = (n: AnyNode) => {
1374
+ // Different nodes have different widths
1375
+ const widths: Record<string, number> = {
1376
+ CHARACTER: 340,
1377
+ MERGE: 420,
1378
+ BACKGROUND: 320,
1379
+ CLOTHES: 320,
1380
+ BLEND: 320,
1381
+ EDIT: 320,
1382
+ CAMERA: 360,
1383
+ AGE: 280,
1384
+ FACE: 340,
1385
+ };
1386
+ const width = widths[n.type] || 320;
1387
+ return { x: n.x + width - 10, y: n.y + 25 };
1388
+ };
1389
+
1390
+ const getNodeInputPort = (n: AnyNode) => ({ x: n.x + 10, y: n.y + 25 });
1391
+
1392
+ const createPath = (x1: number, y1: number, x2: number, y2: number) => {
1393
+ const dx = x2 - x1;
1394
+ const dy = y2 - y1;
1395
+ const distance = Math.sqrt(dx * dx + dy * dy);
1396
+ const controlOffset = Math.min(200, Math.max(50, distance * 0.3));
1397
+ return `M ${x1} ${y1} C ${x1 + controlOffset} ${y1}, ${x2 - controlOffset} ${y2}, ${x2} ${y2}`;
1398
+ };
1399
+
1400
+ const paths: { path: string; active?: boolean; processing?: boolean }[] = [];
1401
+
1402
+ // Handle all connections
1403
+ for (const node of nodes) {
1404
+ if (node.type === "MERGE") {
1405
+ // MERGE node with multiple inputs
1406
+ const merge = node as MergeNode;
1407
+ for (const inputId of merge.inputs) {
1408
+ const inputNode = nodes.find(n => n.id === inputId);
1409
+ if (inputNode) {
1410
+ const start = getNodeOutputPort(inputNode);
1411
+ const end = getNodeInputPort(node);
1412
+ const isProcessing = merge.isRunning || (inputNode as any).isRunning;
1413
+ paths.push({
1414
+ path: createPath(start.x, start.y, end.x, end.y),
1415
+ processing: isProcessing
1416
+ });
1417
+ }
1418
+ }
1419
+ } else if ((node as any).input) {
1420
+ // Single input nodes
1421
+ const inputId = (node as any).input;
1422
+ const inputNode = nodes.find(n => n.id === inputId);
1423
+ if (inputNode) {
1424
+ const start = getNodeOutputPort(inputNode);
1425
+ const end = getNodeInputPort(node);
1426
+ const isProcessing = (node as any).isRunning || (inputNode as any).isRunning;
1427
+ paths.push({
1428
+ path: createPath(start.x, start.y, end.x, end.y),
1429
+ processing: isProcessing
1430
+ });
1431
+ }
1432
+ }
1433
+ }
1434
+
1435
+ // Dragging path
1436
+ if (draggingFrom && dragPos) {
1437
+ const sourceNode = nodes.find(n => n.id === draggingFrom);
1438
+ if (sourceNode) {
1439
+ const start = getNodeOutputPort(sourceNode);
1440
+ paths.push({
1441
+ path: createPath(start.x, start.y, dragPos.x, dragPos.y),
1442
+ active: true
1443
+ });
1444
+ }
1445
+ }
1446
+
1447
+ return paths;
1448
+ }, [nodes, draggingFrom, dragPos]);
1449
+
1450
+ // Panning & zooming
1451
+ const isPanning = useRef(false);
1452
+ const panStart = useRef<{ sx: number; sy: number; ox: number; oy: number } | null>(null);
1453
+
1454
+ const onBackgroundPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
1455
+ // Only pan if clicking directly on the background
1456
+ if (e.target !== e.currentTarget && !((e.target as HTMLElement).tagName === "svg" || (e.target as HTMLElement).tagName === "line")) return;
1457
+ isPanning.current = true;
1458
+ panStart.current = { sx: e.clientX, sy: e.clientY, ox: tx, oy: ty };
1459
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
1460
+ };
1461
+ const onBackgroundPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
1462
+ if (!isPanning.current || !panStart.current) return;
1463
+ const dx = e.clientX - panStart.current.sx;
1464
+ const dy = e.clientY - panStart.current.sy;
1465
+ setTx(panStart.current.ox + dx);
1466
+ setTy(panStart.current.oy + dy);
1467
+ };
1468
+ const onBackgroundPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
1469
+ isPanning.current = false;
1470
+ panStart.current = null;
1471
+ (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
1472
+ };
1473
+
1474
+ const onWheel = (e: React.WheelEvent<HTMLDivElement>) => {
1475
+ e.preventDefault();
1476
+ const rect = containerRef.current!.getBoundingClientRect();
1477
+ const oldScale = scaleRef.current;
1478
+ const factor = Math.exp(-e.deltaY * 0.0015);
1479
+ const newScale = Math.min(2.5, Math.max(0.25, oldScale * factor));
1480
+ const { x: wx, y: wy } = screenToWorld(e.clientX, e.clientY, rect, tx, ty, oldScale);
1481
+ // keep cursor anchored while zooming
1482
+ const ntx = e.clientX - rect.left - wx * newScale;
1483
+ const nty = e.clientY - rect.top - wy * newScale;
1484
+ setTx(ntx);
1485
+ setTy(nty);
1486
+ setScale(newScale);
1487
+ };
1488
+
1489
+ // Context menu for adding nodes
1490
+ const [menuOpen, setMenuOpen] = useState(false);
1491
+ const [menuPos, setMenuPos] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
1492
+ const [menuWorld, setMenuWorld] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
1493
+
1494
+ const onContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
1495
+ e.preventDefault();
1496
+ const rect = containerRef.current!.getBoundingClientRect();
1497
+ const world = screenToWorld(e.clientX, e.clientY, rect, tx, ty, scale);
1498
+ setMenuWorld(world);
1499
+ setMenuPos({ x: e.clientX - rect.left, y: e.clientY - rect.top });
1500
+ setMenuOpen(true);
1501
+ };
1502
+
1503
+ const addFromMenu = (kind: NodeType) => {
1504
+ const commonProps = {
1505
+ id: uid(),
1506
+ x: menuWorld.x,
1507
+ y: menuWorld.y,
1508
+ };
1509
+
1510
+ switch(kind) {
1511
+ case "CHARACTER":
1512
+ addCharacter(menuWorld);
1513
+ break;
1514
+ case "MERGE":
1515
+ addMerge(menuWorld);
1516
+ break;
1517
+ case "BACKGROUND":
1518
+ setNodes(prev => [...prev, { ...commonProps, type: "BACKGROUND", backgroundType: "color" } as BackgroundNode]);
1519
+ break;
1520
+ case "CLOTHES":
1521
+ setNodes(prev => [...prev, { ...commonProps, type: "CLOTHES" } as ClothesNode]);
1522
+ break;
1523
+ case "BLEND":
1524
+ setNodes(prev => [...prev, { ...commonProps, type: "BLEND", blendStrength: 50 } as BlendNode]);
1525
+ break;
1526
+ case "STYLE":
1527
+ setNodes(prev => [...prev, { ...commonProps, type: "STYLE", styleStrength: 50 } as StyleNode]);
1528
+ break;
1529
+ case "CAMERA":
1530
+ setNodes(prev => [...prev, { ...commonProps, type: "CAMERA" } as CameraNode]);
1531
+ break;
1532
+ case "AGE":
1533
+ setNodes(prev => [...prev, { ...commonProps, type: "AGE", targetAge: 30 } as AgeNode]);
1534
+ break;
1535
+ case "FACE":
1536
+ setNodes(prev => [...prev, { ...commonProps, type: "FACE", faceOptions: {} } as FaceNode]);
1537
+ break;
1538
+ }
1539
+ setMenuOpen(false);
1540
+ };
1541
+
1542
+ return (
1543
+ <div className="min-h-[100svh] bg-background text-foreground">
1544
+ <header className="flex items-center justify-between px-6 py-4 border-b border-border/60 bg-card/70 backdrop-blur">
1545
+ <h1 className="text-lg font-semibold tracking-wide">
1546
+ <span className="mr-2" aria-hidden>🍌</span>Nano Banana Editor
1547
+ </h1>
1548
+ <div className="flex items-center gap-2">
1549
+ <label htmlFor="api-token" className="text-sm font-medium text-muted-foreground">
1550
+ API Token:
1551
+ </label>
1552
+ <Input
1553
+ id="api-token"
1554
+ type="password"
1555
+ placeholder="Enter your Google Gemini API token"
1556
+ value={apiToken}
1557
+ onChange={(e) => setApiToken(e.target.value)}
1558
+ className="w-64"
1559
+ />
1560
+ <Button
1561
+ variant="ghost"
1562
+ size="sm"
1563
+ className="h-8 w-8 p-0 rounded-full hover:bg-red-50 dark:hover:bg-red-900/20"
1564
+ type="button"
1565
+ onClick={() => setShowHelpSidebar(true)}
1566
+ >
1567
+ <span className="text-sm font-medium text-red-500 hover:text-red-600">?</span>
1568
+ </Button>
1569
+ </div>
1570
+ </header>
1571
+
1572
+ {/* Help Sidebar */}
1573
+ {showHelpSidebar && (
1574
+ <>
1575
+ {/* Backdrop */}
1576
+ <div
1577
+ className="fixed inset-0 bg-black/50 z-[9998]"
1578
+ onClick={() => setShowHelpSidebar(false)}
1579
+ />
1580
+ {/* Sidebar */}
1581
+ <div className="fixed right-0 top-0 h-full w-96 bg-card/95 backdrop-blur border-l border-border/60 shadow-xl z-[9999] overflow-y-auto">
1582
+ <div className="p-6">
1583
+ <div className="flex items-center justify-between mb-6">
1584
+ <h2 className="text-xl font-semibold text-foreground">Help & Guide</h2>
1585
+ <Button
1586
+ variant="ghost"
1587
+ size="sm"
1588
+ className="h-8 w-8 p-0"
1589
+ onClick={() => setShowHelpSidebar(false)}
1590
+ >
1591
+ <span className="text-lg">×</span>
1592
+ </Button>
1593
+ </div>
1594
+
1595
+ <div className="space-y-6">
1596
+ <div>
1597
+ <h3 className="font-semibold mb-3 text-foreground">🔑 API Token Setup</h3>
1598
+ <div className="text-sm text-muted-foreground space-y-3">
1599
+ <div className="p-3 bg-primary/10 border border-primary/20 rounded-lg">
1600
+ <p className="font-medium text-primary mb-2">Step 1: Get Your API Key</p>
1601
+ <p>Visit <a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline font-medium">Google AI Studio</a> to create your free Gemini API key.</p>
1602
+ </div>
1603
+ <div className="p-3 bg-secondary border border-border rounded-lg">
1604
+ <p className="font-medium text-secondary-foreground mb-2">Step 2: Add Your Token</p>
1605
+ <p>Paste your API key in the "API Token" field in the top navigation bar.</p>
1606
+ </div>
1607
+ <div className="p-3 bg-accent border border-border rounded-lg">
1608
+ <p className="font-medium text-accent-foreground mb-2">Step 3: Start Creating</p>
1609
+ <p>Your token enables all AI features: image generation, merging, editing, and style transfers.</p>
1610
+ </div>
1611
+ </div>
1612
+ </div>
1613
+
1614
+ <div>
1615
+ <h3 className="font-semibold mb-3 text-foreground">🎨 How to Use the Editor</h3>
1616
+ <div className="text-sm text-muted-foreground space-y-2">
1617
+ <p>• <strong>Adding Nodes:</strong> Right-click on the editor canvas and choose the node type you want, then drag and drop to position it</p>
1618
+ <p>• <strong>Character Nodes:</strong> Upload or drag images to create character nodes</p>
1619
+ <p>• <strong>Merge Nodes:</strong> Connect multiple characters to create group photos</p>
1620
+ <p>• <strong>Style Nodes:</strong> Apply artistic styles and filters</p>
1621
+ <p>• <strong>Background Nodes:</strong> Change or generate new backgrounds</p>
1622
+ <p>• <strong>Edit Nodes:</strong> Make specific modifications with text prompts</p>
1623
+ </div>
1624
+ </div>
1625
+
1626
+ <div className="p-4 bg-muted border border-border rounded-lg">
1627
+ <h4 className="font-semibold text-foreground mb-2">🔒 Privacy & Security</h4>
1628
+ <div className="text-sm text-muted-foreground space-y-1">
1629
+ <p>• Your API token is stored locally in your browser</p>
1630
+ <p>• Tokens are never sent to our servers</p>
1631
+ <p>• Keep your API key secure and don't share it</p>
1632
+ <p>• You can revoke keys anytime in Google AI Studio</p>
1633
+ </div>
1634
+ </div>
1635
+ </div>
1636
+ </div>
1637
+ </div>
1638
+ </>
1639
+ )}
1640
+
1641
+ <div
1642
+ ref={containerRef}
1643
+ className="relative w-full h-[calc(100svh-56px)] overflow-hidden nb-canvas"
1644
+ style={{
1645
+ imageRendering: "auto",
1646
+ transform: "translateZ(0)",
1647
+ willChange: "contents"
1648
+ }}
1649
+ onContextMenu={onContextMenu}
1650
+ onPointerDown={onBackgroundPointerDown}
1651
+ onPointerMove={(e) => {
1652
+ onBackgroundPointerMove(e);
1653
+ handlePointerMove(e);
1654
+ }}
1655
+ onPointerUp={(e) => {
1656
+ onBackgroundPointerUp(e);
1657
+ handlePointerUp();
1658
+ }}
1659
+ onPointerLeave={(e) => {
1660
+ onBackgroundPointerUp(e);
1661
+ handlePointerUp();
1662
+ }}
1663
+ onWheel={onWheel}
1664
+ >
1665
+ <div
1666
+ className="absolute left-0 top-0 will-change-transform"
1667
+ style={{
1668
+ transform: `translate3d(${tx}px, ${ty}px, 0) scale(${scale})`,
1669
+ transformOrigin: "0 0",
1670
+ transformStyle: "preserve-3d",
1671
+ backfaceVisibility: "hidden"
1672
+ }}
1673
+ >
1674
+ <svg
1675
+ className="absolute pointer-events-none z-0"
1676
+ style={{
1677
+ left: `${svgBounds.x}px`,
1678
+ top: `${svgBounds.y}px`,
1679
+ width: `${svgBounds.width}px`,
1680
+ height: `${svgBounds.height}px`
1681
+ }}
1682
+ viewBox={`${svgBounds.x} ${svgBounds.y} ${svgBounds.width} ${svgBounds.height}`}
1683
+ >
1684
+ <defs>
1685
+ <filter id="glow">
1686
+ <feGaussianBlur stdDeviation="3" result="coloredBlur"/>
1687
+ <feMerge>
1688
+ <feMergeNode in="coloredBlur"/>
1689
+ <feMergeNode in="SourceGraphic"/>
1690
+ </feMerge>
1691
+ </filter>
1692
+ </defs>
1693
+ {connectionPaths.map((p, idx) => (
1694
+ <path
1695
+ key={idx}
1696
+ className={p.processing ? "connection-processing connection-animated" : ""}
1697
+ d={p.path}
1698
+ fill="none"
1699
+ stroke={p.processing ? undefined : (p.active ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))")}
1700
+ strokeWidth={p.processing ? undefined : "2.5"}
1701
+ strokeDasharray={p.active && !p.processing ? "5,5" : undefined}
1702
+ style={p.active && !p.processing ? undefined : (!p.processing ? { opacity: 0.9 } : {})}
1703
+ />
1704
+ ))}
1705
+ </svg>
1706
+
1707
+ <div className="relative z-10">
1708
+ {nodes.map((node) => {
1709
+ switch (node.type) {
1710
+ case "CHARACTER":
1711
+ return (
1712
+ <CharacterNodeView
1713
+ key={node.id}
1714
+ node={node as CharacterNode}
1715
+ scaleRef={scaleRef}
1716
+ onChangeImage={setCharacterImage}
1717
+ onChangeLabel={setCharacterLabel}
1718
+ onStartConnection={handleStartConnection}
1719
+ onUpdatePosition={updateNodePosition}
1720
+ onDelete={deleteNode}
1721
+ />
1722
+ );
1723
+ case "MERGE":
1724
+ return (
1725
+ <MergeNodeView
1726
+ key={node.id}
1727
+ node={node as MergeNode}
1728
+ scaleRef={scaleRef}
1729
+ allNodes={nodes}
1730
+ onDisconnect={disconnectFromMerge}
1731
+ onRun={runMerge}
1732
+ onEndConnection={handleEndConnection}
1733
+ onStartConnection={handleStartConnection}
1734
+ onUpdatePosition={updateNodePosition}
1735
+ onDelete={deleteNode}
1736
+ onClearConnections={clearMergeConnections}
1737
+ />
1738
+ );
1739
+ case "BACKGROUND":
1740
+ return (
1741
+ <BackgroundNodeView
1742
+ key={node.id}
1743
+ node={node as BackgroundNode}
1744
+ onDelete={deleteNode}
1745
+ onUpdate={updateNode}
1746
+ onStartConnection={handleStartConnection}
1747
+ onEndConnection={handleEndSingleConnection}
1748
+ onProcess={processNode}
1749
+ onUpdatePosition={updateNodePosition}
1750
+ />
1751
+ );
1752
+ case "CLOTHES":
1753
+ return (
1754
+ <ClothesNodeView
1755
+ key={node.id}
1756
+ node={node as ClothesNode}
1757
+ onDelete={deleteNode}
1758
+ onUpdate={updateNode}
1759
+ onStartConnection={handleStartConnection}
1760
+ onEndConnection={handleEndSingleConnection}
1761
+ onProcess={processNode}
1762
+ onUpdatePosition={updateNodePosition}
1763
+ />
1764
+ );
1765
+ case "STYLE":
1766
+ return (
1767
+ <StyleNodeView
1768
+ key={node.id}
1769
+ node={node as StyleNode}
1770
+ onDelete={deleteNode}
1771
+ onUpdate={updateNode}
1772
+ onStartConnection={handleStartConnection}
1773
+ onEndConnection={handleEndSingleConnection}
1774
+ onProcess={processNode}
1775
+ onUpdatePosition={updateNodePosition}
1776
+ />
1777
+ );
1778
+ case "EDIT":
1779
+ return (
1780
+ <EditNodeView
1781
+ key={node.id}
1782
+ node={node as EditNode}
1783
+ onDelete={deleteNode}
1784
+ onUpdate={updateNode}
1785
+ onStartConnection={handleStartConnection}
1786
+ onEndConnection={handleEndSingleConnection}
1787
+ onProcess={processNode}
1788
+ onUpdatePosition={updateNodePosition}
1789
+ />
1790
+ );
1791
+ case "CAMERA":
1792
+ return (
1793
+ <CameraNodeView
1794
+ key={node.id}
1795
+ node={node as CameraNode}
1796
+ onDelete={deleteNode}
1797
+ onUpdate={updateNode}
1798
+ onStartConnection={handleStartConnection}
1799
+ onEndConnection={handleEndSingleConnection}
1800
+ onProcess={processNode}
1801
+ onUpdatePosition={updateNodePosition}
1802
+ />
1803
+ );
1804
+ case "AGE":
1805
+ return (
1806
+ <AgeNodeView
1807
+ key={node.id}
1808
+ node={node as AgeNode}
1809
+ onDelete={deleteNode}
1810
+ onUpdate={updateNode}
1811
+ onStartConnection={handleStartConnection}
1812
+ onEndConnection={handleEndSingleConnection}
1813
+ onProcess={processNode}
1814
+ onUpdatePosition={updateNodePosition}
1815
+ />
1816
+ );
1817
+ case "FACE":
1818
+ return (
1819
+ <FaceNodeView
1820
+ key={node.id}
1821
+ node={node as FaceNode}
1822
+ onDelete={deleteNode}
1823
+ onUpdate={updateNode}
1824
+ onStartConnection={handleStartConnection}
1825
+ onEndConnection={handleEndSingleConnection}
1826
+ onProcess={processNode}
1827
+ onUpdatePosition={updateNodePosition}
1828
+ />
1829
+ );
1830
+ default:
1831
+ return null;
1832
+ }
1833
+ })}
1834
+ </div>
1835
+ </div>
1836
+
1837
+ {menuOpen && (
1838
+ <div
1839
+ className="absolute z-50 rounded-xl border border-white/10 bg-[#111]/95 backdrop-blur p-1 w-56 shadow-2xl"
1840
+ style={{ left: menuPos.x, top: menuPos.y }}
1841
+ onMouseLeave={() => setMenuOpen(false)}
1842
+ >
1843
+ <div className="px-3 py-2 text-xs text-white/60">Add node</div>
1844
+ <div className="max-h-[400px] overflow-y-auto">
1845
+ <button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("CHARACTER")}>CHARACTER</button>
1846
+ <button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("MERGE")}>MERGE</button>
1847
+ <button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("BACKGROUND")}>BACKGROUND</button>
1848
+ <button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("CLOTHES")}>CLOTHES</button>
1849
+ <button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("STYLE")}>STYLE</button>
1850
+ <button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("EDIT")}>EDIT</button>
1851
+ <button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("CAMERA")}>CAMERA</button>
1852
+ <button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("AGE")}>AGE</button>
1853
+ <button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("FACE")}>FACE</button>
1854
+ </div>
1855
+ </div>
1856
+ )}
1857
+ </div>
1858
+ </div>
1859
+ );
1860
+ }
1861
+
app/try-on/page.tsx DELETED
@@ -1,414 +0,0 @@
1
- "use client";
2
-
3
- import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
-
5
- function readFilesAsDataUrls(files: FileList | File[]): Promise<string[]> {
6
- const arr = Array.from(files as File[]);
7
- return Promise.all(
8
- arr.map(
9
- (file) =>
10
- new Promise<string>((resolve, reject) => {
11
- const reader = new FileReader();
12
- reader.onload = () => resolve(reader.result as string);
13
- reader.onerror = (e) => reject(e);
14
- reader.readAsDataURL(file);
15
- })
16
- )
17
- );
18
- }
19
-
20
- type ZoneKind = "user" | "clothes";
21
-
22
- function UploadZone({
23
- title,
24
- description,
25
- onDropData,
26
- allowMultiple = false,
27
- }: {
28
- title: string;
29
- description: string;
30
- onDropData: (dataUrls: string[]) => void;
31
- allowMultiple?: boolean;
32
- }) {
33
- const ref = useRef<HTMLDivElement>(null);
34
- const handleFiles = useCallback(
35
- async (files: FileList | File[]) => {
36
- const urls = await readFilesAsDataUrls(files);
37
- onDropData(urls);
38
- },
39
- [onDropData]
40
- );
41
-
42
- const onDrop = useCallback(
43
- async (e: React.DragEvent<HTMLDivElement>) => {
44
- e.preventDefault();
45
- const files = e.dataTransfer.files;
46
- if (files && files.length) {
47
- await handleFiles(files);
48
- }
49
- },
50
- [handleFiles]
51
- );
52
-
53
- const onPaste = useCallback(
54
- async (e: React.ClipboardEvent<HTMLDivElement>) => {
55
- const items = e.clipboardData.items;
56
- const files: File[] = [];
57
- for (let i = 0; i < items.length; i++) {
58
- const it = items[i];
59
- if (it.type.startsWith("image/")) {
60
- const f = it.getAsFile();
61
- if (f) files.push(f);
62
- }
63
- }
64
-
65
- if (files.length) {
66
- await handleFiles(files);
67
- return;
68
- }
69
-
70
- // Fallback: if user pasted a URL (e.g., copied image address)
71
- const text = e.clipboardData.getData("text");
72
- if (text && (text.startsWith("http") || text.startsWith("data:image"))) {
73
- onDropData([text]);
74
- }
75
- },
76
- [handleFiles, onDropData]
77
- );
78
-
79
- return (
80
- <div
81
- ref={ref}
82
- tabIndex={0}
83
- onDrop={onDrop}
84
- onDragOver={(e) => e.preventDefault()}
85
- onPaste={onPaste}
86
- className="rounded-2xl border border-white/10 bg-black/40 text-white p-6 sm:p-8 transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500/60"
87
- >
88
- <div className="flex items-center justify-between mb-4">
89
- <h2 className="text-lg font-semibold tracking-wide">{title}</h2>
90
- <span className="text-xs text-white/60">drop • click • paste</span>
91
- </div>
92
- <label className="block" aria-label={`${title} uploader`}>
93
- <input
94
- type="file"
95
- accept="image/*"
96
- multiple={allowMultiple}
97
- className="hidden"
98
- onChange={async (e) => {
99
- if (e.currentTarget.files?.length) {
100
- await handleFiles(e.currentTarget.files);
101
- e.currentTarget.value = "";
102
- }
103
- }}
104
- />
105
- <div className="grid place-items-center rounded-xl border border-dashed border-white/20 hover:border-white/40 cursor-pointer px-6 py-12 sm:py-16 text-center select-none">
106
- <p className="text-sm leading-6 text-white/80">
107
- {description}
108
- </p>
109
- <p className="mt-3 text-xs text-white/50">
110
- Upload or paste an image. Tip: click this box and press Ctrl/Cmd+V
111
- </p>
112
- </div>
113
- </label>
114
- </div>
115
- );
116
- }
117
-
118
- export default function TryOnPage() {
119
- const [userImage, setUserImage] = useState<string | null>(null);
120
- const [clothingImages, setClothingImages] = useState<string[]>([]);
121
- const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
122
-
123
- const selectedClothing = useMemo(
124
- () => (selectedIndex != null ? clothingImages[selectedIndex] : null),
125
- [selectedIndex, clothingImages]
126
- );
127
-
128
- // Overlay controls
129
- const [scale, setScale] = useState(1);
130
- const [offsetX, setOffsetX] = useState(0);
131
- const [offsetY, setOffsetY] = useState(0);
132
- const [rotation, setRotation] = useState(0);
133
-
134
- const dragRef = useRef<HTMLImageElement>(null);
135
- const dragging = useRef(false);
136
- const start = useRef<{ x: number; y: number; ox: number; oy: number } | null>(
137
- null
138
- );
139
-
140
- const onPointerDown = (e: React.PointerEvent) => {
141
- if (!selectedClothing) return;
142
- dragging.current = true;
143
- start.current = {
144
- x: e.clientX,
145
- y: e.clientY,
146
- ox: offsetX,
147
- oy: offsetY,
148
- };
149
- (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
150
- };
151
- const onPointerMove = (e: React.PointerEvent) => {
152
- if (!dragging.current || !start.current) return;
153
- const dx = e.clientX - start.current.x;
154
- const dy = e.clientY - start.current.y;
155
- setOffsetX(start.current.ox + dx);
156
- setOffsetY(start.current.oy + dy);
157
- };
158
- const onPointerUp = (e: React.PointerEvent) => {
159
- dragging.current = false;
160
- start.current = null;
161
- (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
162
- };
163
-
164
- const addUserImages = (urls: string[]) => {
165
- setUserImage(urls[0] ?? null);
166
- };
167
- const addClothesImages = (urls: string[]) => {
168
- setClothingImages((prev) => [...urls, ...prev]);
169
- if (selectedIndex == null && urls.length) setSelectedIndex(0);
170
- };
171
-
172
- // Gemini generation
173
- const [genPrompt, setGenPrompt] = useState(
174
- "A front-facing fashion product (e.g., jacket, shirt, dress, sunglasses, or accessory) on a plain or transparent background, high quality, photo-realistic."
175
- );
176
- const [isGenerating, setIsGenerating] = useState(false);
177
- const [error, setError] = useState<string | null>(null);
178
-
179
- const generateWithGemini = async () => {
180
- try {
181
- setIsGenerating(true);
182
- setError(null);
183
- const res = await fetch("/api/generate", {
184
- method: "POST",
185
- headers: { "Content-Type": "application/json" },
186
- body: JSON.stringify({ prompt: genPrompt }),
187
- });
188
- if (!res.ok) {
189
- const js = await res.json().catch(() => ({}));
190
- throw new Error(js.error || `Request failed (${res.status})`);
191
- }
192
- const data = (await res.json()) as { images?: string[]; text?: string };
193
- const imgs = data.images ?? [];
194
- if (!imgs.length) {
195
- // Fallback: if model returned only text, just show an error message
196
- throw new Error(
197
- "Model returned no images. Try a different prompt, e.g., 'white t-shirt, product photo, front view'."
198
- );
199
- }
200
- addClothesImages(imgs);
201
- } catch (e: any) {
202
- setError(e?.message || "Failed to generate image");
203
- } finally {
204
- setIsGenerating(false);
205
- }
206
- };
207
-
208
- return (
209
- <div className="min-h-[100svh] bg-background text-foreground">
210
- <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-10">
211
- {/* Header */}
212
- <div className="flex items-center justify-between mb-10">
213
- <h1 className="text-xl sm:text-2xl font-semibold tracking-wide">
214
- Virtual Try-On (simple overlay)
215
- </h1>
216
- <a
217
- className="text-xs text-white/60 hover:text-white underline underline-offset-4"
218
- href="/"
219
- >
220
- Home
221
- </a>
222
- </div>
223
-
224
- {/* Two columns */}
225
- <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-8">
226
- <UploadZone
227
- title="Your photo"
228
- description="Drop, click to upload, or paste your portrait. Prefer front-facing photos for best results."
229
- onDropData={addUserImages}
230
- />
231
- <UploadZone
232
- title="Clothing / accessories"
233
- description="Add product shots you want to try. You can upload multiple, paste, or use Gemini to generate some."
234
- onDropData={addClothesImages}
235
- allowMultiple
236
- />
237
- </div>
238
-
239
- {/* Preview area */}
240
- <div className="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-8">
241
- <div className="rounded-2xl border border-white/10 bg-white/5 p-4 sm:p-6">
242
- <h3 className="mb-3 text-sm font-medium text-white/80">Your photo</h3>
243
- <div
244
- className="relative aspect-[4/5] w-full overflow-hidden rounded-xl bg-black grid place-items-center"
245
- onPointerDown={onPointerDown}
246
- onPointerMove={onPointerMove}
247
- onPointerUp={onPointerUp}
248
- >
249
- {userImage ? (
250
- <img
251
- src={userImage}
252
- alt="User"
253
- className="absolute inset-0 h-full w-full object-contain"
254
- />
255
- ) : (
256
- <p className="text-white/50 text-sm">No photo yet</p>
257
- )}
258
-
259
- {/* Overlay clothing */}
260
- {userImage && selectedClothing && (
261
- <img
262
- ref={dragRef}
263
- src={selectedClothing}
264
- alt="Clothing overlay"
265
- className="pointer-events-auto select-none"
266
- style={{
267
- position: "absolute",
268
- left: "50%",
269
- top: "50%",
270
- transform: `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY}px)) rotate(${rotation}deg) scale(${scale})`,
271
- transformOrigin: "center center",
272
- maxWidth: "70%",
273
- }}
274
- />
275
- )}
276
- </div>
277
-
278
- {/* Controls */}
279
- <div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
280
- <div>
281
- <label className="flex items-center justify-between text-xs text-white/70 mb-1">
282
- <span>Scale</span>
283
- <span>{scale.toFixed(2)}x</span>
284
- </label>
285
- <input
286
- type="range"
287
- min={0.3}
288
- max={3}
289
- step={0.01}
290
- value={scale}
291
- onChange={(e) => setScale(parseFloat(e.target.value))}
292
- className="w-full"
293
- />
294
- </div>
295
- <div>
296
- <label className="flex items-center justify-between text-xs text-white/70 mb-1">
297
- <span>Rotation</span>
298
- <span>{rotation.toFixed(0)}°</span>
299
- </label>
300
- <input
301
- type="range"
302
- min={-180}
303
- max={180}
304
- step={1}
305
- value={rotation}
306
- onChange={(e) => setRotation(parseFloat(e.target.value))}
307
- className="w-full"
308
- />
309
- </div>
310
- <div>
311
- <label className="flex items-center justify-between text-xs text-white/70 mb-1">
312
- <span>Offset X</span>
313
- <span>{offsetX.toFixed(0)}px</span>
314
- </label>
315
- <input
316
- type="range"
317
- min={-300}
318
- max={300}
319
- step={1}
320
- value={offsetX}
321
- onChange={(e) => setOffsetX(parseFloat(e.target.value))}
322
- className="w-full"
323
- />
324
- </div>
325
- <div>
326
- <label className="flex items-center justify-between text-xs text-white/70 mb-1">
327
- <span>Offset Y</span>
328
- <span>{offsetY.toFixed(0)}px</span>
329
- </label>
330
- <input
331
- type="range"
332
- min={-300}
333
- max={300}
334
- step={1}
335
- value={offsetY}
336
- onChange={(e) => setOffsetY(parseFloat(e.target.value))}
337
- className="w-full"
338
- />
339
- </div>
340
- </div>
341
- </div>
342
-
343
- <div className="rounded-2xl border border-white/10 bg-white/5 p-4 sm:p-6">
344
- <h3 className="mb-3 text-sm font-medium text-white/80">
345
- Clothing library
346
- </h3>
347
- {clothingImages.length === 0 ? (
348
- <p className="text-white/50 text-sm">No clothing images yet</p>
349
- ) : (
350
- <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-3">
351
- {clothingImages.map((src, idx) => (
352
- <button
353
- key={idx}
354
- onClick={() => setSelectedIndex(idx)}
355
- className={`relative aspect-square overflow-hidden rounded-lg border ${
356
- selectedIndex === idx
357
- ? "border-indigo-400"
358
- : "border-white/10 hover:border-white/30"
359
- }`}
360
- title="Select for overlay"
361
- >
362
- <img
363
- src={src}
364
- alt={`Clothing ${idx + 1}`}
365
- className="h-full w-full object-cover"
366
- />
367
- </button>
368
- ))}
369
- </div>
370
- )}
371
-
372
- {/* Gemini generator */}
373
- <div className="mt-6 rounded-xl bg-black/40 border border-white/10 p-4">
374
- <p className="text-sm text-white/80 font-medium mb-2">
375
- Generate with Gemini
376
- </p>
377
- <div className="flex flex-col gap-3">
378
- <textarea
379
- value={genPrompt}
380
- onChange={(e) => setGenPrompt(e.target.value)}
381
- rows={3}
382
- className="w-full rounded-lg bg-black/60 border border-white/10 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500/60"
383
- />
384
- <div className="flex items-center gap-3">
385
- <button
386
- onClick={generateWithGemini}
387
- disabled={isGenerating}
388
- className="inline-flex items-center justify-center rounded-md bg-indigo-500 hover:bg-indigo-400 disabled:opacity-60 text-white text-sm font-medium px-4 py-2"
389
- >
390
- {isGenerating ? "Generating…" : "Generate"}
391
- </button>
392
- {error && (
393
- <span className="text-xs text-red-400">{error}</span>
394
- )}
395
- </div>
396
- <p className="text-[11px] text-white/50">
397
- Tip: Ask for a single front-view item with plain background, e.g.
398
- "black leather jacket, product photo, front view".
399
- </p>
400
- </div>
401
- </div>
402
- </div>
403
- </div>
404
-
405
- <div className="mt-10 text-xs text-white/50">
406
- Note: This demo performs a simple 2D overlay for exploration. True
407
- virtual try-on (warping to body shape, segmentation) requires
408
- specialized vision models not included here.
409
- </div>
410
- </div>
411
- </div>
412
- );
413
- }
414
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
next.config.ts CHANGED
@@ -2,6 +2,8 @@ import type { NextConfig } from "next";
2
 
3
  const nextConfig: NextConfig = {
4
  /* config options here */
 
 
5
  // Increase body size limit for API routes to handle large images
6
  serverRuntimeConfig: {
7
  bodySizeLimit: '50mb',
 
2
 
3
  const nextConfig: NextConfig = {
4
  /* config options here */
5
+ // Enable standalone output for Docker deployment
6
+ output: 'standalone',
7
  // Increase body size limit for API routes to handle large images
8
  serverRuntimeConfig: {
9
  bodySizeLimit: '50mb',