Reubencf commited on
Commit
f09b9f0
·
verified ·
1 Parent(s): 77db9f2

Upload 38 files

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ public/sukajan.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.*
7
+ .yarn/*
8
+ !.yarn/patches
9
+ !.yarn/plugins
10
+ !.yarn/releases
11
+ !.yarn/versions
12
+
13
+ # testing
14
+ /coverage
15
+
16
+ # next.js
17
+ /.next/
18
+ /out/
19
+
20
+ # production
21
+ /build
22
+
23
+ # misc
24
+ .DS_Store
25
+ *.pem
26
+
27
+ # debug
28
+ npm-debug.log*
29
+ yarn-debug.log*
30
+ yarn-error.log*
31
+ .pnpm-debug.log*
32
+
33
+ # env files (can opt-in for committing if needed)
34
+ .env*
35
+
36
+ # vercel
37
+ .vercel
38
+
39
+ # typescript
40
+ *.tsbuildinfo
41
+ next-env.d.ts
42
+
43
+ .vercel
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
@@ -1,12 +1,43 @@
1
  ---
2
- title: Nano Banana Editor
3
- emoji: 📚
4
- colorFrom: purple
5
- colorTo: indigo
6
  sdk: docker
7
  pinned: false
8
- license: mit
9
- short_description: Play with Nano Banana using Nodes
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Nano Banana Node Editor
3
+ emoji: 🍌
4
+ colorFrom: yellow
5
+ colorTo: yellow
6
  sdk: docker
7
  pinned: false
8
+ app_port: 3000
 
9
  ---
10
 
11
+ # Nano Banana Node Editor 🍌
12
+
13
+ A powerful visual node editor for AI-powered image generation and manipulation. Create complex workflows by connecting nodes for image generation, merging, editing, and style transfers.
14
+
15
+ ## Features
16
+
17
+ - 🎨 **Visual Node Editor**: Drag-and-drop interface for building image processing workflows
18
+ - 🤖 **AI-Powered**: Uses Google's Gemini API for intelligent image generation and editing
19
+ - 🖼️ **Multiple Node Types**: Character nodes, merge nodes, edit nodes, and style transfer nodes
20
+ - 🔄 **Real-time Processing**: See results as you build your workflow
21
+ - 🎯 **Intuitive Interface**: Right-click to add nodes, drag to connect them
22
+ - 🔒 **Privacy-First**: Your API tokens are stored locally in your browser
23
+
24
+ ## Getting Started
25
+
26
+ 1. **Get Your API Key**: Visit [Google AI Studio](https://aistudio.google.com/app/apikey) to create your free Gemini API key
27
+ 2. **Add Your Token**: Paste your API key in the "API Token" field in the top navigation
28
+ 3. **Start Creating**: Right-click on the canvas to add nodes and start building your workflow
29
+
30
+ ## How to Use
31
+
32
+ - **Adding Nodes**: Right-click on the editor canvas and choose the node type you want
33
+ - **Character Nodes**: Upload or drag images to create character nodes
34
+ - **Connecting Nodes**: Drag from output ports to input ports to create connections
35
+ - **Processing**: Click the process button to run your workflow
36
+ - **Downloading**: Right-click on result nodes to download generated images
37
+
38
+ ## Privacy & Security
39
+
40
+ - Your API token is stored locally in your browser
41
+ - Tokens are never sent to our servers
42
+ - All processing happens through Google's official API
43
+ - No data is stored on our servers
app/api/generate/route.ts ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { GoogleGenAI } from "@google/genai";
3
+
4
+ export const runtime = "nodejs"; // Ensure Node runtime for SDK
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" },
12
+ { status: 400 }
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
+ }
24
+
25
+ const ai = new GoogleGenAI({ apiKey });
26
+
27
+ const response = await ai.models.generateContent({
28
+ model: "gemini-2.5-flash-image-preview",
29
+ contents: prompt,
30
+ });
31
+
32
+ const parts = (response as any)?.candidates?.[0]?.content?.parts ?? [];
33
+ const images: string[] = [];
34
+ const texts: string[] = [];
35
+
36
+ for (const part of parts) {
37
+ if (part?.inlineData?.data) {
38
+ images.push(`data:image/png;base64,${part.inlineData.data}`);
39
+ } else if (part?.text) {
40
+ texts.push(part.text as string);
41
+ }
42
+ }
43
+
44
+ return NextResponse.json({ images, text: texts.join("\n") });
45
+ } catch (err) {
46
+ console.error("/api/generate error", err);
47
+ return NextResponse.json(
48
+ { error: "Failed to generate image" },
49
+ { status: 500 }
50
+ );
51
+ }
52
+ }
53
+
app/api/merge/route.ts ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { GoogleGenAI } from "@google/genai";
3
+
4
+ export const runtime = "nodejs";
5
+
6
+ function parseDataUrl(dataUrl: string): { mimeType: string; data: string } | null {
7
+ // data:[<mediatype>][;base64],<data>
8
+ const match = dataUrl.match(/^data:(.*?);base64,(.*)$/);
9
+ if (!match) return null;
10
+ return { mimeType: match[1] || "image/png", data: match[2] };
11
+ }
12
+
13
+ async function toInlineData(url: string): Promise<{ mimeType: string; data: string } | null> {
14
+ try {
15
+ if (url.startsWith('data:')) {
16
+ return parseDataUrl(url);
17
+ }
18
+ if (url.startsWith('http')) {
19
+ // Fetch HTTP URL and convert to base64
20
+ const res = await fetch(url);
21
+ const buf = await res.arrayBuffer();
22
+ const base64 = Buffer.from(buf).toString('base64');
23
+ const mimeType = res.headers.get('content-type') || 'image/jpeg';
24
+ return { mimeType, data: base64 };
25
+ }
26
+ return null;
27
+ } catch (e) {
28
+ console.error('Failed to process image URL:', url.substring(0, 100), e);
29
+ return null;
30
+ }
31
+ }
32
+
33
+ export async function POST(req: NextRequest) {
34
+ try {
35
+ const body = (await req.json()) as {
36
+ images?: string[]; // data URLs
37
+ prompt?: string;
38
+ apiToken?: string;
39
+ };
40
+
41
+ const imgs = body.images?.filter(Boolean) ?? [];
42
+ if (imgs.length < 2) {
43
+ return NextResponse.json(
44
+ { error: "MERGE requires at least two images" },
45
+ { status: 400 }
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
+ }
57
+
58
+ const ai = new GoogleGenAI({ apiKey });
59
+
60
+ // Build parts array: first the text prompt, then image inlineData parts
61
+ // If no custom prompt, use default extraction-focused prompt
62
+ let prompt = body.prompt;
63
+
64
+ if (!prompt) {
65
+ prompt = `MERGE TASK: Create a natural, cohesive group photo combining ALL subjects from ${imgs.length} provided images.
66
+
67
+ CRITICAL REQUIREMENTS:
68
+ 1. Extract ALL people/subjects from EACH image exactly as they appear
69
+ 2. Place them together in a SINGLE UNIFIED SCENE with:
70
+ - Consistent lighting direction and color temperature
71
+ - Matching shadows and ambient lighting
72
+ - Proper scale relationships (realistic relative sizes)
73
+ - Natural spacing as if they were photographed together
74
+ - Shared environment/background that looks cohesive
75
+
76
+ 3. Composition guidelines:
77
+ - Arrange subjects at similar depth (not one far behind another)
78
+ - Use natural group photo positioning (slight overlap is ok)
79
+ - Ensure all faces are clearly visible
80
+ - Create visual balance in the composition
81
+ - Apply consistent color grading across all subjects
82
+
83
+ 4. Environmental unity:
84
+ - Use a single, coherent background for all subjects
85
+ - Match the perspective as if taken with one camera
86
+ - Ensure ground plane continuity (all standing on same level)
87
+ - Apply consistent atmospheric effects (if any)
88
+
89
+ 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.`;
90
+ } else {
91
+ // Even with custom prompt, append cohesion requirements
92
+ const enforcement = `\n\nIMPORTANT: Create a COHESIVE group photo where all subjects appear to be in the same scene with consistent lighting, scale, and environment. The result should look naturally photographed together, not composited.`;
93
+ prompt = `${prompt}${enforcement}`;
94
+ }
95
+
96
+ // Debug: Log what we're receiving
97
+ console.log(`[MERGE API] Received ${imgs.length} images to merge`);
98
+ console.log(`[MERGE API] Image types:`, imgs.map(img => {
99
+ if (img.startsWith('data:')) return 'data URL';
100
+ if (img.startsWith('http')) return 'HTTP URL';
101
+ return 'unknown';
102
+ }));
103
+
104
+ const parts: any[] = [{ text: prompt }];
105
+ for (const url of imgs) {
106
+ const parsed = await toInlineData(url);
107
+ if (!parsed) {
108
+ console.error('[MERGE API] Failed to parse image:', url.substring(0, 100));
109
+ continue;
110
+ }
111
+ parts.push({ inlineData: { mimeType: parsed.mimeType, data: parsed.data } });
112
+ }
113
+
114
+ console.log(`[MERGE API] Sending ${parts.length - 1} images to model (prompt + images)`);
115
+ console.log(`[MERGE API] Prompt preview:`, prompt.substring(0, 200));
116
+
117
+ const response = await ai.models.generateContent({
118
+ model: "gemini-2.5-flash-image-preview",
119
+ contents: parts,
120
+ });
121
+
122
+ const outParts = (response as any)?.candidates?.[0]?.content?.parts ?? [];
123
+ const images: string[] = [];
124
+ const texts: string[] = [];
125
+ for (const p of outParts) {
126
+ if (p?.inlineData?.data) {
127
+ images.push(`data:image/png;base64,${p.inlineData.data}`);
128
+ } else if (p?.text) {
129
+ texts.push(p.text);
130
+ }
131
+ }
132
+
133
+ if (!images.length) {
134
+ return NextResponse.json(
135
+ { error: "Model returned no image", text: texts.join("\n") },
136
+ { status: 500 }
137
+ );
138
+ }
139
+
140
+ return NextResponse.json({ images, text: texts.join("\n") });
141
+ } catch (err) {
142
+ console.error("/api/merge error", err);
143
+ return NextResponse.json({ error: "Failed to merge" }, { status: 500 });
144
+ }
145
+ }
146
+
app/api/process/route.ts ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { GoogleGenAI } from "@google/genai";
3
+
4
+ export const runtime = "nodejs";
5
+
6
+ // Increase the body size limit to 50MB for large images
7
+ export const maxDuration = 60; // 60 seconds timeout
8
+
9
+ function parseDataUrl(dataUrl: string): { mimeType: string; data: string } | null {
10
+ const match = dataUrl.match(/^data:(.*?);base64,(.*)$/);
11
+ if (!match) return null;
12
+ return { mimeType: match[1] || "image/png", data: match[2] };
13
+ }
14
+
15
+ export async function POST(req: NextRequest) {
16
+ try {
17
+ // Log request size for debugging
18
+ const contentLength = req.headers.get('content-length');
19
+ console.log(`[API] Request size: ${contentLength} bytes`);
20
+
21
+ let body: any;
22
+ try {
23
+ body = await req.json() as {
24
+ type: string;
25
+ image?: string;
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);
33
+ return NextResponse.json(
34
+ { error: "Invalid JSON in request body. This might be due to large image data or special characters." },
35
+ { status: 400 }
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
+ }
47
+
48
+ const ai = new GoogleGenAI({ apiKey });
49
+
50
+ // Helpers
51
+ const toInlineDataFromAny = async (url: string): Promise<{ mimeType: string; data: string } | null> => {
52
+ if (!url) return null;
53
+ try {
54
+ if (url.startsWith('data:')) {
55
+ return parseDataUrl(url);
56
+ }
57
+ if (url.startsWith('http')) {
58
+ const res = await fetch(url);
59
+ const buf = await res.arrayBuffer();
60
+ const base64 = Buffer.from(buf).toString('base64');
61
+ const mimeType = res.headers.get('content-type') || 'image/jpeg';
62
+ return { mimeType, data: base64 };
63
+ }
64
+ if (url.startsWith('/')) {
65
+ const host = req.headers.get('host') ?? 'localhost:3000';
66
+ const proto = req.headers.get('x-forwarded-proto') ?? 'http';
67
+ const absolute = `${proto}://${host}${url}`;
68
+ const res = await fetch(absolute);
69
+ const buf = await res.arrayBuffer();
70
+ const base64 = Buffer.from(buf).toString('base64');
71
+ const mimeType = res.headers.get('content-type') || 'image/png';
72
+ return { mimeType, data: base64 };
73
+ }
74
+ return null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ };
79
+
80
+ // Handle MERGE node type separately
81
+ if (body.type === "MERGE") {
82
+ const imgs = body.images?.filter(Boolean) ?? [];
83
+ if (imgs.length < 2) {
84
+ return NextResponse.json(
85
+ { error: "MERGE requires at least two images" },
86
+ { status: 400 }
87
+ );
88
+ }
89
+
90
+ // Build parts array for merge: first the text prompt, then image inlineData parts
91
+ let mergePrompt = body.prompt;
92
+
93
+ if (!mergePrompt) {
94
+ mergePrompt = `MERGE TASK: Create a natural, cohesive group photo combining ALL subjects from ${imgs.length} provided images.
95
+
96
+ CRITICAL REQUIREMENTS:
97
+ 1. Extract ALL people/subjects from EACH image exactly as they appear
98
+ 2. Place them together in a SINGLE UNIFIED SCENE with:
99
+ - Consistent lighting direction and color temperature
100
+ - Matching shadows and ambient lighting
101
+ - Proper scale relationships (realistic relative sizes)
102
+ - Natural spacing as if they were photographed together
103
+ - Shared environment/background that looks cohesive
104
+
105
+ 3. Composition guidelines:
106
+ - Arrange subjects at similar depth (not one far behind another)
107
+ - Use natural group photo positioning (slight overlap is ok)
108
+ - Ensure all faces are clearly visible
109
+ - Create visual balance in the composition
110
+ - Apply consistent color grading across all subjects
111
+
112
+ 4. Environmental unity:
113
+ - Use a single, coherent background for all subjects
114
+ - Match the perspective as if taken with one camera
115
+ - Ensure ground plane continuity (all standing on same level)
116
+ - Apply consistent atmospheric effects (if any)
117
+
118
+ 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.`;
119
+ } else {
120
+ // Even with custom prompt, append cohesion requirements
121
+ const enforcement = `\n\nIMPORTANT: Create a COHESIVE group photo where all subjects appear to be in the same scene with consistent lighting, scale, and environment. The result should look naturally photographed together, not composited.`;
122
+ mergePrompt = `${mergePrompt}${enforcement}`;
123
+ }
124
+
125
+ const mergeParts: any[] = [{ text: mergePrompt }];
126
+ for (let i = 0; i < imgs.length; i++) {
127
+ const url = imgs[i];
128
+ console.log(`[MERGE] Processing image ${i + 1}/${imgs.length}, type: ${typeof url}, length: ${url?.length || 0}`);
129
+
130
+ try {
131
+ const parsed = await toInlineDataFromAny(url);
132
+ if (!parsed) {
133
+ console.error(`[MERGE] Failed to parse image ${i + 1}:`, url.substring(0, 100));
134
+ continue;
135
+ }
136
+ mergeParts.push({ inlineData: { mimeType: parsed.mimeType, data: parsed.data } });
137
+ console.log(`[MERGE] Successfully processed image ${i + 1}`);
138
+ } catch (error) {
139
+ console.error(`[MERGE] Error processing image ${i + 1}:`, error);
140
+ }
141
+ }
142
+
143
+ console.log(`[MERGE] Sending ${mergeParts.length - 1} images to model`);
144
+
145
+ const response = await ai.models.generateContent({
146
+ model: "gemini-2.5-flash-image-preview",
147
+ contents: mergeParts,
148
+ });
149
+
150
+ const outParts = (response as any)?.candidates?.[0]?.content?.parts ?? [];
151
+ const images: string[] = [];
152
+ const texts: string[] = [];
153
+ for (const p of outParts) {
154
+ if (p?.inlineData?.data) {
155
+ images.push(`data:image/png;base64,${p.inlineData.data}`);
156
+ } else if (p?.text) {
157
+ texts.push(p.text);
158
+ }
159
+ }
160
+
161
+ if (!images.length) {
162
+ return NextResponse.json(
163
+ { error: "Model returned no image", text: texts.join("\n") },
164
+ { status: 500 }
165
+ );
166
+ }
167
+
168
+ return NextResponse.json({ image: images[0], images, text: texts.join("\n") });
169
+ }
170
+
171
+ // Parse input image for non-merge nodes
172
+ let parsed = null as null | { mimeType: string; data: string };
173
+ if (body.image) {
174
+ parsed = await toInlineDataFromAny(body.image);
175
+ }
176
+
177
+ if (!parsed) {
178
+ return NextResponse.json({ error: "Invalid or missing image data. Please ensure an input is connected." }, { status: 400 });
179
+ }
180
+
181
+ // Build combined prompt from all accumulated parameters
182
+ const prompts: string[] = [];
183
+ const params = body.params || {};
184
+
185
+ // We'll collect additional inline image parts (references)
186
+ const referenceParts: { inlineData: { mimeType: string; data: string } }[] = [];
187
+
188
+ // Background modifications
189
+ if (params.backgroundType) {
190
+ const bgType = params.backgroundType;
191
+ if (bgType === "color") {
192
+ prompts.push(`Change the background to a solid ${params.backgroundColor || "white"} background.`);
193
+ } else if (bgType === "image") {
194
+ prompts.push(`Change the background to ${params.backgroundImage || "a beautiful beach scene"}.`);
195
+ } else if (bgType === "upload" && params.customBackgroundImage) {
196
+ prompts.push(`Replace the background using the provided custom background reference image (attached below). Ensure perspective and lighting match.`);
197
+ const bgRef = await toInlineDataFromAny(params.customBackgroundImage);
198
+ if (bgRef) referenceParts.push({ inlineData: bgRef });
199
+ } else if (params.customPrompt) {
200
+ prompts.push(params.customPrompt);
201
+ }
202
+ }
203
+
204
+ // Clothes modifications
205
+ if (params.clothesImage) {
206
+ console.log(`[API] Processing clothes image, type: ${typeof params.clothesImage}, length: ${params.clothesImage?.length || 0}`);
207
+
208
+ if (params.selectedPreset === "Sukajan") {
209
+ prompts.push("Replace the person's clothing with a Japanese sukajan jacket (embroidered designs). Use the clothes reference image if provided.");
210
+ } else if (params.selectedPreset === "Blazer") {
211
+ prompts.push("Replace the person's clothing with a professional blazer. Use the clothes reference image if provided.");
212
+ } else {
213
+ prompts.push(`Take the person shown in the first image and replace their entire outfit with the clothing items shown in the second reference image. The person's face, hair, body pose, and background should remain exactly the same. Only the clothing should change to match the reference clothing image. Ensure the new clothes fit naturally on the person's body with realistic proportions, proper fabric draping, and lighting that matches the original photo environment.`);
214
+ }
215
+
216
+ try {
217
+ const clothesRef = await toInlineDataFromAny(params.clothesImage);
218
+ if (clothesRef) {
219
+ console.log(`[API] Successfully processed clothes image`);
220
+ referenceParts.push({ inlineData: clothesRef });
221
+ } else {
222
+ console.error('[API] Failed to process clothes image - toInlineDataFromAny returned null');
223
+ }
224
+ } catch (error) {
225
+ console.error('[API] Error processing clothes image:', error);
226
+ }
227
+ }
228
+
229
+ // Style application
230
+ if (params.stylePreset) {
231
+ const strength = params.styleStrength || 50;
232
+ const styleMap: { [key: string]: string } = {
233
+ "90s-anime": "Convert the image to 90's anime art style with classic anime features: large expressive eyes, detailed hair, soft shading, nostalgic colors reminiscent of Studio Ghibli and classic anime productions",
234
+ "mha": "Transform the image into My Hero Academia anime style with modern crisp lines, vibrant colors, dynamic character design, and heroic aesthetics typical of the series",
235
+ "dbz": "Apply Dragon Ball Z anime style with sharp angular features, spiky hair, intense expressions, bold outlines, high contrast shading, and dramatic action-oriented aesthetics",
236
+ "ukiyo-e": "Render in traditional Japanese Ukiyo-e woodblock print style with flat colors, bold outlines, stylized waves and clouds, traditional Japanese artistic elements",
237
+ "cyberpunk": "Transform into cyberpunk aesthetic with neon colors (cyan, magenta, yellow), dark backgrounds, futuristic elements, holographic effects, tech-noir atmosphere",
238
+ "steampunk": "Apply steampunk style with Victorian-era brass and copper tones, mechanical gears, steam effects, vintage industrial aesthetic, sepia undertones",
239
+ "cubism": "Render in Cubist art style with geometric fragmentation, multiple perspectives shown simultaneously, abstract angular forms, Picasso-inspired decomposition",
240
+ "van-gogh": "Apply Post-Impressionist Van Gogh style with thick swirling brushstrokes, vibrant yellows and blues, expressive texture, starry night-like patterns",
241
+ "simpsons": "Convert to The Simpsons cartoon style with yellow skin tones, simple rounded features, bulging eyes, overbite, Matt Groening's distinctive character design",
242
+ "family-guy": "Transform into Family Guy animation style with rounded character design, simplified features, Seth MacFarlane's distinctive art style, bold outlines",
243
+ "arcane": "Apply Arcane (League of Legends) style with painterly brush-stroke textures, neon rim lighting, hand-painted feel, stylized realism, vibrant color grading",
244
+ "wildwest": "Render in Wild West style with dusty desert tones, sunset orange lighting, vintage film grain, cowboy aesthetic, sepia and brown color palette",
245
+ "stranger-things": "Apply Stranger Things 80s aesthetic with Kodak film push-process look, neon magenta backlight, grainy vignette, retro sci-fi horror atmosphere",
246
+ "breaking-bad": "Transform with Breaking Bad cinematography style featuring dusty New Mexico orange and teal color grading, 35mm film grain, desert atmosphere, dramatic lighting"
247
+ };
248
+
249
+ const styleDescription = styleMap[params.stylePreset];
250
+ if (styleDescription) {
251
+ prompts.push(`${styleDescription}. Apply this style transformation at ${strength}% intensity while preserving the core subject matter.`);
252
+ }
253
+ }
254
+
255
+ // Edit prompt
256
+ if (params.editPrompt) {
257
+ prompts.push(params.editPrompt);
258
+ }
259
+
260
+ // Camera settings
261
+ if (params.focalLength || params.aperture || params.shutterSpeed || params.whiteBalance || params.angle ||
262
+ params.iso || params.filmStyle || params.lighting || params.bokeh || params.composition) {
263
+ const cameraSettings: string[] = [];
264
+ if (params.focalLength) {
265
+ if (params.focalLength === "8mm fisheye") {
266
+ cameraSettings.push("Apply 8mm fisheye lens effect with 180-degree circular distortion");
267
+ } else {
268
+ cameraSettings.push(`Focal Length: ${params.focalLength}`);
269
+ }
270
+ }
271
+ if (params.aperture) cameraSettings.push(`Aperture: ${params.aperture}`);
272
+ if (params.shutterSpeed) cameraSettings.push(`Shutter Speed: ${params.shutterSpeed}`);
273
+ if (params.whiteBalance) cameraSettings.push(`White Balance: ${params.whiteBalance}`);
274
+ if (params.angle) cameraSettings.push(`Camera Angle: ${params.angle}`);
275
+ if (params.iso) cameraSettings.push(`${params.iso}`);
276
+ if (params.filmStyle) cameraSettings.push(`Film style: ${params.filmStyle}`);
277
+ if (params.lighting) cameraSettings.push(`Lighting: ${params.lighting}`);
278
+ if (params.bokeh) cameraSettings.push(`Bokeh effect: ${params.bokeh}`);
279
+ if (params.composition) cameraSettings.push(`Composition: ${params.composition}`);
280
+
281
+ if (cameraSettings.length > 0) {
282
+ prompts.push(`Apply professional photography settings: ${cameraSettings.join(", ")}`);
283
+ }
284
+ }
285
+
286
+ // Age transformation
287
+ if (params.targetAge) {
288
+ prompts.push(`Transform the person to look exactly ${params.targetAge} years old with age-appropriate features.`);
289
+ }
290
+
291
+ // Face modifications
292
+ if (params.faceOptions) {
293
+ const face = params.faceOptions;
294
+ const modifications: string[] = [];
295
+ if (face.removePimples) modifications.push("remove all pimples and blemishes");
296
+ if (face.addSunglasses) modifications.push("add stylish sunglasses");
297
+ if (face.addHat) modifications.push("add a fashionable hat");
298
+ if (face.changeHairstyle) modifications.push(`change hairstyle to ${face.changeHairstyle}`);
299
+ if (face.facialExpression) modifications.push(`change facial expression to ${face.facialExpression}`);
300
+ if (face.beardStyle) modifications.push(`add/change beard to ${face.beardStyle}`);
301
+
302
+ if (modifications.length > 0) {
303
+ prompts.push(`Face modifications: ${modifications.join(", ")}`);
304
+ }
305
+ }
306
+
307
+ // Combine all prompts
308
+ let prompt = prompts.length > 0
309
+ ? prompts.join("\n\n") + "\n\nApply all these modifications while maintaining the person's identity and keeping unspecified aspects unchanged."
310
+ : "Process this image with high quality output.";
311
+
312
+ // Add the custom prompt if provided
313
+ if (body.prompt) {
314
+ prompt = body.prompt + "\n\n" + prompt;
315
+ }
316
+
317
+ // Generate with Gemini
318
+ const parts = [
319
+ { text: prompt },
320
+ // Primary subject image (input) - this is the person whose clothes will be changed
321
+ { inlineData: { mimeType: parsed.mimeType, data: parsed.data } },
322
+ // Additional reference images to guide modifications (e.g., clothes to copy)
323
+ ...referenceParts,
324
+ ];
325
+
326
+ const response = await ai.models.generateContent({
327
+ model: "gemini-2.5-flash-image-preview",
328
+ contents: parts,
329
+ });
330
+
331
+ const outParts = (response as any)?.candidates?.[0]?.content?.parts ?? [];
332
+ const images: string[] = [];
333
+
334
+ for (const p of outParts) {
335
+ if (p?.inlineData?.data) {
336
+ images.push(`data:image/png;base64,${p.inlineData.data}`);
337
+ }
338
+ }
339
+
340
+ if (!images.length) {
341
+ return NextResponse.json(
342
+ { error: "No image generated. Try adjusting your parameters." },
343
+ { status: 500 }
344
+ );
345
+ }
346
+
347
+ return NextResponse.json({ image: images[0] });
348
+ } catch (err: any) {
349
+ console.error("/api/process error:", err);
350
+ console.error("Error stack:", err?.stack);
351
+
352
+ // Provide more specific error messages
353
+ if (err?.message?.includes('payload size')) {
354
+ return NextResponse.json(
355
+ { error: "Image data too large. Please use smaller images or reduce image quality." },
356
+ { status: 413 }
357
+ );
358
+ }
359
+
360
+ if (err?.message?.includes('JSON')) {
361
+ return NextResponse.json(
362
+ { error: "Invalid data format. Please ensure images are properly encoded." },
363
+ { status: 400 }
364
+ );
365
+ }
366
+
367
+ return NextResponse.json(
368
+ { error: `Failed to process image: ${err?.message || 'Unknown error'}` },
369
+ { status: 500 }
370
+ );
371
+ }
372
+ }
app/editor.css ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Node editor custom styles and animations */
2
+
3
+ /* Animated connection lines */
4
+ @keyframes flow {
5
+ 0% {
6
+ stroke-dashoffset: 0;
7
+ }
8
+ 100% {
9
+ stroke-dashoffset: -20;
10
+ }
11
+ }
12
+
13
+ .connection-animated {
14
+ animation: flow 1s linear infinite;
15
+ stroke-dasharray: 5, 5;
16
+ }
17
+
18
+ /* Processing pulse effect */
19
+ @keyframes processingPulse {
20
+ 0%, 100% {
21
+ opacity: 1;
22
+ }
23
+ 50% {
24
+ opacity: 0.6;
25
+ }
26
+ }
27
+
28
+ .connection-processing {
29
+ animation: processingPulse 1.5s ease-in-out infinite;
30
+ stroke: #22c55e;
31
+ stroke-width: 3;
32
+ filter: drop-shadow(0 0 3px rgba(34, 197, 94, 0.5));
33
+ }
34
+
35
+ /* Flow particles effect */
36
+ @keyframes flowParticle {
37
+ 0% {
38
+ offset-distance: 0%;
39
+ opacity: 0;
40
+ }
41
+ 10% {
42
+ opacity: 1;
43
+ }
44
+ 90% {
45
+ opacity: 1;
46
+ }
47
+ 100% {
48
+ offset-distance: 100%;
49
+ opacity: 0;
50
+ }
51
+ }
52
+
53
+ .flow-particle {
54
+ animation: flowParticle 2s linear infinite;
55
+ }
56
+
57
+ /* Node processing state */
58
+ .nb-node.processing {
59
+ animation: processingPulse 1.5s ease-in-out infinite;
60
+ }
61
+
62
+ .nb-node.processing .nb-header {
63
+ background: linear-gradient(90deg, rgba(34, 197, 94, 0.2), rgba(34, 197, 94, 0.1));
64
+ }
app/favicon.ico ADDED
app/globals.css ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+
3
+ /* shadcn theme tokens */
4
+ :root {
5
+ --background: 0 0% 100%;
6
+ --foreground: 222.2 84% 4.9%;
7
+
8
+ --card: 0 0% 100%;
9
+ --card-foreground: 222.2 84% 4.9%;
10
+
11
+ --popover: 0 0% 100%;
12
+ --popover-foreground: 222.2 84% 4.9%;
13
+
14
+ /* Brand: Orangish Red */
15
+ --primary: 14 90% 50%;
16
+ --primary-foreground: 0 0% 98%;
17
+
18
+ --secondary: 210 40% 96.1%;
19
+ --secondary-foreground: 222.2 47.4% 11.2%;
20
+
21
+ --muted: 210 40% 96.1%;
22
+ --muted-foreground: 215.4 16.3% 46.9%;
23
+
24
+ --accent: 14 95% 90%;
25
+ --accent-foreground: 14 90% 20%;
26
+
27
+ --destructive: 0 84.2% 60.2%;
28
+ --destructive-foreground: 210 40% 98%;
29
+
30
+ --border: 214.3 31.8% 91.4%;
31
+ --input: 214.3 31.8% 91.4%;
32
+ --ring: 14 90% 45%;
33
+
34
+ --radius: 0.75rem;
35
+
36
+ --chart-1: 12 76% 61%;
37
+ --chart-2: 173 58% 39%;
38
+ --chart-3: 197 37% 24%;
39
+ --chart-4: 43 74% 66%;
40
+ --chart-5: 27 87% 67%;
41
+ }
42
+
43
+ .dark {
44
+ --background: 240 10% 3.9%;
45
+ --foreground: 0 0% 98%;
46
+
47
+ --card: 240 10% 3.9%;
48
+ --card-foreground: 0 0% 98%;
49
+
50
+ --popover: 240 10% 3.9%;
51
+ --popover-foreground: 0 0% 98%;
52
+
53
+ /* Brand in dark mode */
54
+ --primary: 14 87% 60%;
55
+ --primary-foreground: 240 10% 3.9%;
56
+
57
+ --secondary: 240 3.7% 15.9%;
58
+ --secondary-foreground: 0 0% 98%;
59
+
60
+ --muted: 240 3.7% 15.9%;
61
+ --muted-foreground: 240 5% 64.9%;
62
+
63
+ --accent: 14 40% 20%;
64
+ --accent-foreground: 0 0% 98%;
65
+
66
+ --destructive: 0 62.8% 30.6%;
67
+ --destructive-foreground: 0 85.7% 97.3%;
68
+
69
+ --border: 240 3.7% 15.9%;
70
+ --input: 240 3.7% 15.9%;
71
+ --ring: 14 87% 55%;
72
+ }
73
+
74
+ @media (prefers-color-scheme: dark) {
75
+ :root:not(.light) {
76
+ --background: 240 10% 3.9%;
77
+ --foreground: 0 0% 98%;
78
+
79
+ --card: 240 10% 3.9%;
80
+ --card-foreground: 0 0% 98%;
81
+
82
+ --popover: 240 10% 3.9%;
83
+ --popover-foreground: 0 0% 98%;
84
+
85
+ /* Brand in dark mode */
86
+ --primary: 14 87% 60%;
87
+ --primary-foreground: 240 10% 3.9%;
88
+
89
+ --secondary: 240 3.7% 15.9%;
90
+ --secondary-foreground: 0 0% 98%;
91
+
92
+ --muted: 240 3.7% 15.9%;
93
+ --muted-foreground: 240 5% 64.9%;
94
+
95
+ --accent: 14 40% 20%;
96
+ --accent-foreground: 0 0% 98%;
97
+
98
+ --destructive: 0 62.8% 30.6%;
99
+ --destructive-foreground: 0 85.7% 97.3%;
100
+
101
+ --border: 240 3.7% 15.9%;
102
+ --input: 240 3.7% 15.9%;
103
+ --ring: 14 87% 55%;
104
+ }
105
+ }
106
+
107
+ @theme inline {
108
+ --color-background: hsl(var(--background));
109
+ --color-foreground: hsl(var(--foreground));
110
+ --color-card: hsl(var(--card));
111
+ --color-card-foreground: hsl(var(--card-foreground));
112
+ --color-popover: hsl(var(--popover));
113
+ --color-popover-foreground: hsl(var(--popover-foreground));
114
+ --color-primary: hsl(var(--primary));
115
+ --color-primary-foreground: hsl(var(--primary-foreground));
116
+ --color-secondary: hsl(var(--secondary));
117
+ --color-secondary-foreground: hsl(var(--secondary-foreground));
118
+ --color-muted: hsl(var(--muted));
119
+ --color-muted-foreground: hsl(var(--muted-foreground));
120
+ --color-accent: hsl(var(--accent));
121
+ --color-accent-foreground: hsl(var(--accent-foreground));
122
+ --color-destructive: hsl(var(--destructive));
123
+ --color-destructive-foreground: hsl(var(--destructive-foreground));
124
+ --color-border: hsl(var(--border));
125
+ --color-input: hsl(var(--input));
126
+ --color-ring: hsl(var(--ring));
127
+ --font-sans: var(--font-geist-sans);
128
+ --font-mono: var(--font-geist-mono);
129
+ }
130
+
131
+ body {
132
+ background: hsl(var(--background));
133
+ color: hsl(var(--foreground));
134
+ font-family: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
135
+ }
136
+
137
+ /* Custom scrollbar styling */
138
+ .scrollbar-thin::-webkit-scrollbar {
139
+ width: 6px;
140
+ height: 6px;
141
+ }
142
+
143
+ .scrollbar-thin::-webkit-scrollbar-track {
144
+ background: hsl(var(--background) / 0.1);
145
+ border-radius: 3px;
146
+ }
147
+
148
+ .scrollbar-thin::-webkit-scrollbar-thumb {
149
+ background: hsl(var(--muted-foreground) / 0.3);
150
+ border-radius: 3px;
151
+ }
152
+
153
+ .scrollbar-thin::-webkit-scrollbar-thumb:hover {
154
+ background: hsl(var(--muted-foreground) / 0.5);
155
+ }
156
+
157
+ /* Firefox */
158
+ .scrollbar-thin {
159
+ scrollbar-width: thin;
160
+ scrollbar-color: hsl(var(--muted-foreground) / 0.3) hsl(var(--background) / 0.1);
161
+ }
162
+
163
+ /* Nano Banana Editor - node visuals */
164
+ .nb-node {
165
+ background: hsl(var(--card) / 0.9);
166
+ border: 1px solid hsl(var(--border) / 0.6);
167
+ box-shadow: 0 10px 30px rgba(0,0,0,0.35);
168
+ border-radius: var(--radius);
169
+ backdrop-filter: blur(6px);
170
+ /* Prevent blurring on zoom */
171
+ image-rendering: -webkit-optimize-contrast;
172
+ image-rendering: crisp-edges;
173
+ transform: translateZ(0);
174
+ will-change: transform;
175
+ -webkit-font-smoothing: antialiased;
176
+ -moz-osx-font-smoothing: grayscale;
177
+ backface-visibility: hidden;
178
+ perspective: 1000px;
179
+ }
180
+
181
+ /* Prevent text selection on node elements except inputs */
182
+ .nb-node * {
183
+ user-select: none;
184
+ -webkit-user-select: none;
185
+ }
186
+
187
+ .nb-node input,
188
+ .nb-node textarea,
189
+ .nb-node select {
190
+ user-select: text;
191
+ -webkit-user-select: text;
192
+ }
193
+ .nb-node .nb-header {
194
+ background: linear-gradient(to bottom, hsl(var(--muted) / 0.35), hsl(var(--muted) / 0.08));
195
+ }
196
+ .nb-port {
197
+ width: 20px;
198
+ height: 20px;
199
+ border-radius: 9999px;
200
+ border: 3px solid rgba(255,255,255,0.6);
201
+ background: hsl(var(--popover));
202
+ cursor: crosshair;
203
+ transition: transform 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
204
+ position: relative;
205
+ user-select: none;
206
+ -webkit-user-select: none;
207
+ }
208
+ .nb-port:hover {
209
+ transform: scale(1.25);
210
+ background: hsl(var(--accent));
211
+ box-shadow: 0 0 12px hsl(var(--ring) / 0.4);
212
+ }
213
+ .nb-port.out {
214
+ border-color: hsl(var(--primary));
215
+ }
216
+ .nb-port.out:hover {
217
+ background: hsl(var(--primary));
218
+ box-shadow: 0 0 16px hsl(var(--primary) / 0.6);
219
+ }
220
+ .nb-port.in {
221
+ border-color: #34d399;
222
+ }
223
+ .nb-port.in:hover {
224
+ background: #34d399;
225
+ box-shadow: 0 0 16px rgba(52,211,153,0.6);
226
+ }
227
+
228
+ .nb-line {
229
+ stroke: #7c7c7c;
230
+ stroke-width: 2.5;
231
+ }
232
+ .nb-line.active { stroke: #8b5cf6; }
233
+
234
+ /* Canvas grid */
235
+ .nb-canvas {
236
+ background-color: hsl(var(--background));
237
+ background-image:
238
+ radial-gradient(circle at 1px 1px, hsl(var(--muted-foreground) / 0.18) 1px, transparent 0),
239
+ radial-gradient(circle at 1px 1px, hsl(var(--muted-foreground) / 0.09) 1px, transparent 0);
240
+ background-size: 20px 20px, 100px 100px;
241
+ }
242
+
243
+ /* Ensure crisp text and images at all zoom levels */
244
+ .nb-node * {
245
+ text-rendering: optimizeLegibility;
246
+ shape-rendering: crispEdges;
247
+ }
248
+
249
+ .nb-node img {
250
+ image-rendering: auto; /* Keep images smooth */
251
+ image-rendering: -webkit-optimize-contrast;
252
+ }
253
+
254
+ .nb-node input,
255
+ .nb-node select,
256
+ .nb-node textarea,
257
+ .nb-node button {
258
+ transform: translateZ(0);
259
+ -webkit-font-smoothing: subpixel-antialiased;
260
+ font-smoothing: subpixel-antialiased;
261
+ }
262
+
263
+ /* Force GPU acceleration for transforms */
264
+ .nb-canvas > div {
265
+ transform-style: preserve-3d;
266
+ -webkit-transform-style: preserve-3d;
267
+ }
268
+
269
+ /* Prevent blur on scaled elements */
270
+ @media screen and (-webkit-min-device-pixel-ratio: 0) {
271
+ .nb-node {
272
+ -webkit-backface-visibility: hidden;
273
+ -webkit-transform: translateZ(0) scale(1.0, 1.0);
274
+ }
275
+ }
app/layout.tsx ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Geist, Geist_Mono } from "next/font/google";
3
+ import "./globals.css";
4
+
5
+ const geistSans = Geist({
6
+ variable: "--font-geist-sans",
7
+ subsets: ["latin"],
8
+ });
9
+
10
+ const geistMono = Geist_Mono({
11
+ variable: "--font-geist-mono",
12
+ subsets: ["latin"],
13
+ });
14
+
15
+ export const metadata: Metadata = {
16
+ title: "Nano Banana Editor",
17
+ description: "Node-based photo editor for characters",
18
+ };
19
+
20
+ export default function RootLayout({
21
+ children,
22
+ }: Readonly<{
23
+ children: React.ReactNode;
24
+ }>) {
25
+ return (
26
+ <html lang="en">
27
+ <body
28
+ className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground font-sans`}
29
+ >
30
+ {children}
31
+ </body>
32
+ </html>
33
+ );
34
+ }
app/nodes.tsx ADDED
@@ -0,0 +1,1109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) {
14
+ const link = document.createElement('a');
15
+ link.href = dataUrl;
16
+ link.download = filename;
17
+ document.body.appendChild(link);
18
+ link.click();
19
+ document.body.removeChild(link);
20
+ }
21
+
22
+ // Import types (we'll need to export these from page.tsx)
23
+ type BackgroundNode = any;
24
+ type ClothesNode = any;
25
+ type BlendNode = any;
26
+ type EditNode = any;
27
+ type CameraNode = any;
28
+ type AgeNode = any;
29
+ type FaceNode = any;
30
+
31
+ function cx(...args: Array<string | false | null | undefined>) {
32
+ return args.filter(Boolean).join(" ");
33
+ }
34
+
35
+ // Reusable drag hook for all nodes
36
+ function useNodeDrag(node: any, onUpdatePosition?: (id: string, x: number, y: number) => void) {
37
+ const [localPos, setLocalPos] = useState({ x: node.x, y: node.y });
38
+ const dragging = useRef(false);
39
+ const start = useRef<{ sx: number; sy: number; ox: number; oy: number } | null>(null);
40
+
41
+ useEffect(() => {
42
+ setLocalPos({ x: node.x, y: node.y });
43
+ }, [node.x, node.y]);
44
+
45
+ const onPointerDown = (e: React.PointerEvent) => {
46
+ e.stopPropagation();
47
+ dragging.current = true;
48
+ start.current = { sx: e.clientX, sy: e.clientY, ox: localPos.x, oy: localPos.y };
49
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
50
+ };
51
+
52
+ const onPointerMove = (e: React.PointerEvent) => {
53
+ if (!dragging.current || !start.current) return;
54
+ const dx = e.clientX - start.current.sx;
55
+ const dy = e.clientY - start.current.sy;
56
+ const newX = start.current.ox + dx;
57
+ const newY = start.current.oy + dy;
58
+ setLocalPos({ x: newX, y: newY });
59
+ if (onUpdatePosition) onUpdatePosition(node.id, newX, newY);
60
+ };
61
+
62
+ const onPointerUp = (e: React.PointerEvent) => {
63
+ dragging.current = false;
64
+ start.current = null;
65
+ (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
66
+ };
67
+
68
+ return { localPos, onPointerDown, onPointerMove, onPointerUp };
69
+ }
70
+
71
+ function Port({
72
+ className,
73
+ nodeId,
74
+ isOutput,
75
+ onStartConnection,
76
+ onEndConnection
77
+ }: {
78
+ className?: string;
79
+ nodeId?: string;
80
+ isOutput?: boolean;
81
+ onStartConnection?: (nodeId: string) => void;
82
+ onEndConnection?: (nodeId: string) => void;
83
+ }) {
84
+ const handlePointerDown = (e: React.PointerEvent) => {
85
+ e.stopPropagation();
86
+ if (isOutput && nodeId && onStartConnection) {
87
+ onStartConnection(nodeId);
88
+ }
89
+ };
90
+
91
+ const handlePointerUp = (e: React.PointerEvent) => {
92
+ e.stopPropagation();
93
+ if (!isOutput && nodeId && onEndConnection) {
94
+ onEndConnection(nodeId);
95
+ }
96
+ };
97
+
98
+ return (
99
+ <div
100
+ className={cx("nb-port", className)}
101
+ onPointerDown={handlePointerDown}
102
+ onPointerUp={handlePointerUp}
103
+ onPointerEnter={handlePointerUp}
104
+ />
105
+ );
106
+ }
107
+
108
+ export function BackgroundNodeView({
109
+ node,
110
+ onDelete,
111
+ onUpdate,
112
+ onStartConnection,
113
+ onEndConnection,
114
+ onProcess,
115
+ onUpdatePosition,
116
+ }: any) {
117
+ const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
118
+
119
+ const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
120
+ if (e.target.files?.length) {
121
+ const reader = new FileReader();
122
+ reader.onload = () => {
123
+ onUpdate(node.id, { customBackgroundImage: reader.result });
124
+ };
125
+ reader.readAsDataURL(e.target.files[0]);
126
+ }
127
+ };
128
+
129
+ const handleImagePaste = (e: React.ClipboardEvent) => {
130
+ const items = e.clipboardData.items;
131
+ for (let i = 0; i < items.length; i++) {
132
+ if (items[i].type.startsWith("image/")) {
133
+ const file = items[i].getAsFile();
134
+ if (file) {
135
+ const reader = new FileReader();
136
+ reader.onload = () => {
137
+ onUpdate(node.id, { customBackgroundImage: reader.result });
138
+ };
139
+ reader.readAsDataURL(file);
140
+ return;
141
+ }
142
+ }
143
+ }
144
+ const text = e.clipboardData.getData("text");
145
+ if (text && (text.startsWith("http") || text.startsWith("data:image"))) {
146
+ onUpdate(node.id, { customBackgroundImage: text });
147
+ }
148
+ };
149
+
150
+ const handleDrop = (e: React.DragEvent) => {
151
+ e.preventDefault();
152
+ const files = e.dataTransfer.files;
153
+ if (files && files.length) {
154
+ const reader = new FileReader();
155
+ reader.onload = () => {
156
+ onUpdate(node.id, { customBackgroundImage: reader.result });
157
+ };
158
+ reader.readAsDataURL(files[0]);
159
+ }
160
+ };
161
+
162
+ return (
163
+ <div
164
+ className="nb-node absolute text-white w-[320px]"
165
+ style={{ left: localPos.x, top: localPos.y }}
166
+ onDrop={handleDrop}
167
+ onDragOver={(e) => e.preventDefault()}
168
+ onPaste={handleImagePaste}
169
+ >
170
+ <div
171
+ className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
172
+ onPointerDown={onPointerDown}
173
+ onPointerMove={onPointerMove}
174
+ onPointerUp={onPointerUp}
175
+ >
176
+ <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
177
+ <div className="font-semibold text-sm flex-1 text-center">BACKGROUND</div>
178
+ <div className="flex items-center gap-1">
179
+ <Button
180
+ variant="ghost"
181
+ size="icon"
182
+ className="text-destructive hover:bg-destructive/20 h-6 w-6"
183
+ onClick={(e) => {
184
+ e.stopPropagation();
185
+ e.preventDefault();
186
+ if (confirm('Delete this node?')) {
187
+ onDelete(node.id);
188
+ }
189
+ }}
190
+ onPointerDown={(e) => e.stopPropagation()}
191
+ title="Delete node"
192
+ aria-label="Delete node"
193
+ >
194
+ ×
195
+ </Button>
196
+ <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
197
+ </div>
198
+ </div>
199
+ <div className="p-3 space-y-3">
200
+ <Select
201
+ className="w-full"
202
+ value={node.backgroundType || "color"}
203
+ onChange={(e) => onUpdate(node.id, { backgroundType: (e.target as HTMLSelectElement).value })}
204
+ >
205
+ <option value="color">Solid Color</option>
206
+ <option value="image">Preset Background</option>
207
+ <option value="upload">Upload Image</option>
208
+ <option value="custom">Custom Prompt</option>
209
+ </Select>
210
+
211
+ {node.backgroundType === "color" && (
212
+ <ColorPicker
213
+ className="w-full"
214
+ value={node.backgroundColor || "#ffffff"}
215
+ onChange={(e) => onUpdate(node.id, { backgroundColor: (e.target as HTMLInputElement).value })}
216
+ />
217
+ )}
218
+
219
+ {node.backgroundType === "image" && (
220
+ <Select
221
+ className="w-full"
222
+ value={node.backgroundImage || ""}
223
+ onChange={(e) => onUpdate(node.id, { backgroundImage: (e.target as HTMLSelectElement).value })}
224
+ >
225
+ <option value="">Select Background</option>
226
+ <option value="beach">Beach</option>
227
+ <option value="office">Office</option>
228
+ <option value="studio">Studio</option>
229
+ <option value="nature">Nature</option>
230
+ <option value="city">City Skyline</option>
231
+ </Select>
232
+ )}
233
+
234
+ {node.backgroundType === "upload" && (
235
+ <div className="space-y-2">
236
+ {node.customBackgroundImage ? (
237
+ <div className="relative">
238
+ <img src={node.customBackgroundImage} className="w-full rounded" alt="Custom Background" />
239
+ <Button
240
+ variant="destructive"
241
+ size="sm"
242
+ className="absolute top-2 right-2"
243
+ onClick={() => onUpdate(node.id, { customBackgroundImage: null })}
244
+ >
245
+ Remove
246
+ </Button>
247
+ </div>
248
+ ) : (
249
+ <label className="block">
250
+ <input
251
+ type="file"
252
+ accept="image/*"
253
+ className="hidden"
254
+ onChange={handleImageUpload}
255
+ />
256
+ <div className="border-2 border-dashed border-white/20 rounded-lg p-4 text-center cursor-pointer hover:border-white/40">
257
+ <p className="text-xs text-white/60">Drop, upload, or paste background image</p>
258
+ <p className="text-xs text-white/40 mt-1">JPG, PNG, WEBP</p>
259
+ </div>
260
+ </label>
261
+ )}
262
+ </div>
263
+ )}
264
+
265
+ {node.backgroundType === "custom" && (
266
+ <Textarea
267
+ className="w-full"
268
+ placeholder="Describe the background..."
269
+ value={node.customPrompt || ""}
270
+ onChange={(e) => onUpdate(node.id, { customPrompt: (e.target as HTMLTextAreaElement).value })}
271
+ rows={2}
272
+ />
273
+ )}
274
+
275
+ <Button
276
+ className="w-full"
277
+ onClick={() => onProcess(node.id)}
278
+ disabled={node.isRunning}
279
+ title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
280
+ >
281
+ {node.isRunning ? "Processing..." : "Apply Background"}
282
+ </Button>
283
+
284
+ {node.output && (
285
+ <div className="space-y-2">
286
+ <img src={node.output} className="w-full rounded" alt="Output" />
287
+ <Button
288
+ className="w-full"
289
+ variant="secondary"
290
+ onClick={() => downloadImage(node.output, `background-${Date.now()}.png`)}
291
+ >
292
+ 📥 Download Output
293
+ </Button>
294
+ </div>
295
+ )}
296
+ {node.error && (
297
+ <div className="text-xs text-red-400 mt-2">{node.error}</div>
298
+ )}
299
+ </div>
300
+ </div>
301
+ );
302
+ }
303
+
304
+ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
305
+ const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
306
+
307
+ const presetClothes = [
308
+ { name: "Sukajan", path: "/sukajan.png" },
309
+ { name: "Blazer", path: "/blazzer.png" },
310
+ ];
311
+
312
+ const onDrop = async (e: React.DragEvent) => {
313
+ e.preventDefault();
314
+ const files = e.dataTransfer.files;
315
+ if (files && files.length) {
316
+ const reader = new FileReader();
317
+ reader.onload = () => onUpdate(node.id, { clothesImage: reader.result, selectedPreset: null });
318
+ reader.readAsDataURL(files[0]);
319
+ }
320
+ };
321
+
322
+ const onPaste = async (e: React.ClipboardEvent) => {
323
+ const items = e.clipboardData.items;
324
+ for (let i = 0; i < items.length; i++) {
325
+ if (items[i].type.startsWith("image/")) {
326
+ const file = items[i].getAsFile();
327
+ if (file) {
328
+ const reader = new FileReader();
329
+ reader.onload = () => onUpdate(node.id, { clothesImage: reader.result, selectedPreset: null });
330
+ reader.readAsDataURL(file);
331
+ return;
332
+ }
333
+ }
334
+ }
335
+ const text = e.clipboardData.getData("text");
336
+ if (text && (text.startsWith("http") || text.startsWith("data:image"))) {
337
+ onUpdate(node.id, { clothesImage: text, selectedPreset: null });
338
+ }
339
+ };
340
+
341
+ const selectPreset = (presetPath: string, presetName: string) => {
342
+ onUpdate(node.id, { clothesImage: presetPath, selectedPreset: presetName });
343
+ };
344
+
345
+ return (
346
+ <div
347
+ className="nb-node absolute text-white w-[320px]"
348
+ style={{ left: localPos.x, top: localPos.y }}
349
+ onDrop={onDrop}
350
+ onDragOver={(e) => e.preventDefault()}
351
+ onPaste={onPaste}
352
+ >
353
+ <div
354
+ className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
355
+ onPointerDown={onPointerDown}
356
+ onPointerMove={onPointerMove}
357
+ onPointerUp={onPointerUp}
358
+ >
359
+ <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
360
+ <div className="font-semibold text-sm flex-1 text-center">CLOTHES</div>
361
+ <div className="flex items-center gap-1">
362
+ <Button
363
+ variant="ghost"
364
+ size="icon"
365
+ className="text-destructive hover:bg-destructive/20 h-6 w-6"
366
+ onClick={(e) => {
367
+ e.stopPropagation();
368
+ e.preventDefault();
369
+ if (confirm('Delete this node?')) {
370
+ onDelete(node.id);
371
+ }
372
+ }}
373
+ onPointerDown={(e) => e.stopPropagation()}
374
+ title="Delete node"
375
+ aria-label="Delete node"
376
+ >
377
+ ×
378
+ </Button>
379
+ <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
380
+ </div>
381
+ </div>
382
+ <div className="p-3 space-y-3">
383
+ {node.input && (
384
+ <div className="flex justify-end mb-2">
385
+ <Button
386
+ variant="ghost"
387
+ size="sm"
388
+ onClick={() => onUpdate(node.id, { input: undefined })}
389
+ className="text-xs"
390
+ >
391
+ Clear Connection
392
+ </Button>
393
+ </div>
394
+ )}
395
+ <div className="text-xs text-white/70">Clothes Reference</div>
396
+
397
+ {/* Preset clothes options */}
398
+ <div className="flex gap-2">
399
+ {presetClothes.map((preset) => (
400
+ <button
401
+ key={preset.name}
402
+ className={`flex-1 p-2 rounded border ${
403
+ node.selectedPreset === preset.name
404
+ ? "border-indigo-400 bg-indigo-500/20"
405
+ : "border-white/20 hover:border-white/40"
406
+ }`}
407
+ onClick={() => selectPreset(preset.path, preset.name)}
408
+ >
409
+ <img src={preset.path} alt={preset.name} className="w-full h-16 object-cover rounded mb-1" />
410
+ <div className="text-xs">{preset.name}</div>
411
+ </button>
412
+ ))}
413
+ </div>
414
+
415
+ <div className="text-xs text-white/50 text-center">— or —</div>
416
+
417
+ {/* Custom image upload */}
418
+ {node.clothesImage && !node.selectedPreset ? (
419
+ <div className="relative">
420
+ <img src={node.clothesImage} className="w-full rounded" alt="Clothes" />
421
+ <Button
422
+ variant="destructive"
423
+ size="sm"
424
+ className="absolute top-2 right-2"
425
+ onClick={() => onUpdate(node.id, { clothesImage: null, selectedPreset: null })}
426
+ >
427
+ Remove
428
+ </Button>
429
+ </div>
430
+ ) : !node.selectedPreset ? (
431
+ <label className="block">
432
+ <input
433
+ type="file"
434
+ accept="image/*"
435
+ className="hidden"
436
+ onChange={(e) => {
437
+ if (e.target.files?.length) {
438
+ const reader = new FileReader();
439
+ reader.onload = () => onUpdate(node.id, { clothesImage: reader.result, selectedPreset: null });
440
+ reader.readAsDataURL(e.target.files[0]);
441
+ }
442
+ }}
443
+ />
444
+ <div className="border-2 border-dashed border-white/20 rounded-lg p-4 text-center cursor-pointer hover:border-white/40">
445
+ <p className="text-xs text-white/60">Drop, upload, or paste clothes image</p>
446
+ </div>
447
+ </label>
448
+ ) : null}
449
+
450
+ <Button
451
+ className="w-full"
452
+ onClick={() => onProcess(node.id)}
453
+ disabled={node.isRunning || !node.clothesImage}
454
+ title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
455
+ >
456
+ {node.isRunning ? "Processing..." : "Apply Clothes"}
457
+ </Button>
458
+ {node.output && (
459
+ <div className="space-y-2">
460
+ <img src={node.output} className="w-full rounded" alt="Output" />
461
+ <Button
462
+ className="w-full"
463
+ variant="secondary"
464
+ onClick={() => downloadImage(node.output, `clothes-${Date.now()}.png`)}
465
+ >
466
+ 📥 Download Output
467
+ </Button>
468
+ </div>
469
+ )}
470
+ {node.error && (
471
+ <div className="text-xs text-red-400 mt-2">{node.error}</div>
472
+ )}
473
+ </div>
474
+ </div>
475
+ );
476
+ }
477
+
478
+ export function AgeNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
479
+ const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
480
+
481
+ return (
482
+ <div className="nb-node absolute text-white w-[280px]" style={{ left: localPos.x, top: localPos.y }}>
483
+ <div
484
+ className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
485
+ onPointerDown={onPointerDown}
486
+ onPointerMove={onPointerMove}
487
+ onPointerUp={onPointerUp}
488
+ >
489
+ <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
490
+ <div className="font-semibold text-sm flex-1 text-center">AGE</div>
491
+ <div className="flex items-center gap-1">
492
+ <Button
493
+ variant="ghost"
494
+ size="icon"
495
+ className="text-destructive hover:bg-destructive/20 h-6 w-6"
496
+ onClick={(e) => {
497
+ e.stopPropagation();
498
+ e.preventDefault();
499
+ if (confirm('Delete this node?')) {
500
+ onDelete(node.id);
501
+ }
502
+ }}
503
+ onPointerDown={(e) => e.stopPropagation()}
504
+ title="Delete node"
505
+ aria-label="Delete node"
506
+ >
507
+ ×
508
+ </Button>
509
+ <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
510
+ </div>
511
+ </div>
512
+ <div className="p-3 space-y-3">
513
+ {node.input && (
514
+ <div className="flex justify-end mb-2">
515
+ <Button
516
+ variant="ghost"
517
+ size="sm"
518
+ onClick={() => onUpdate(node.id, { input: undefined })}
519
+ className="text-xs"
520
+ >
521
+ Clear Connection
522
+ </Button>
523
+ </div>
524
+ )}
525
+ <div>
526
+ <Slider
527
+ label="Target Age"
528
+ valueLabel={`${node.targetAge || 30} years`}
529
+ min={18}
530
+ max={100}
531
+ value={node.targetAge || 30}
532
+ onChange={(e) => onUpdate(node.id, { targetAge: parseInt((e.target as HTMLInputElement).value) })}
533
+ />
534
+ </div>
535
+ <Button
536
+ className="w-full"
537
+ onClick={() => onProcess(node.id)}
538
+ disabled={node.isRunning}
539
+ title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
540
+ >
541
+ {node.isRunning ? "Processing..." : "Apply Age"}
542
+ </Button>
543
+ {node.output && (
544
+ <div className="space-y-2">
545
+ <img src={node.output} className="w-full rounded" alt="Output" />
546
+ <Button
547
+ className="w-full"
548
+ variant="secondary"
549
+ onClick={() => downloadImage(node.output, `age-${Date.now()}.png`)}
550
+ >
551
+ 📥 Download Output
552
+ </Button>
553
+ </div>
554
+ )}
555
+ {node.error && (
556
+ <div className="text-xs text-red-400 mt-2">{node.error}</div>
557
+ )}
558
+ </div>
559
+ </div>
560
+ );
561
+ }
562
+
563
+ export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
564
+ const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
565
+ const focalLengths = ["None", "8mm fisheye", "12mm", "24mm", "35mm", "50mm", "85mm", "135mm", "200mm", "300mm", "400mm"];
566
+ const apertures = ["None", "f/0.95", "f/1.2", "f/1.4", "f/1.8", "f/2", "f/2.8", "f/4", "f/5.6", "f/8", "f/11", "f/16", "f/22"];
567
+ const shutterSpeeds = ["None", "1/8000s", "1/4000s", "1/2000s", "1/1000s", "1/500s", "1/250s", "1/125s", "1/60s", "1/30s", "1/15s", "1/8s", "1/4s", "1/2s", "1s", "2s", "5s", "10s", "30s"];
568
+ const whiteBalances = ["None", "2800K candlelight", "3200K tungsten", "4000K fluorescent", "5600K daylight", "6500K cloudy", "7000K shade", "8000K blue sky"];
569
+ const angles = ["None", "eye level", "low angle", "high angle", "Dutch tilt", "bird's eye", "worm's eye", "over the shoulder", "POV"];
570
+ const isoValues = ["None", "ISO 50", "ISO 100", "ISO 200", "ISO 400", "ISO 800", "ISO 1600", "ISO 3200", "ISO 6400", "ISO 12800"];
571
+ const filmStyles = ["None", "Kodak Portra", "Fuji Velvia", "Ilford HP5", "Cinestill 800T", "Lomography", "Cross Process", "Black & White", "Sepia", "Vintage", "Film Noir"];
572
+ const lightingTypes = ["None", "Natural Light", "Golden Hour", "Blue Hour", "Studio Lighting", "Rembrandt", "Split Lighting", "Butterfly Lighting", "Loop Lighting", "Rim Lighting", "Silhouette", "High Key", "Low Key"];
573
+ const bokehStyles = ["None", "Smooth Bokeh", "Swirly Bokeh", "Hexagonal Bokeh", "Cat Eye Bokeh", "Bubble Bokeh", "Creamy Bokeh"];
574
+ const compositions = ["None", "Rule of Thirds", "Golden Ratio", "Symmetrical", "Leading Lines", "Frame in Frame", "Fill the Frame", "Negative Space", "Patterns", "Diagonal"];
575
+ const aspectRatios = ["None", "1:1 Square", "3:2 Standard", "4:3 Classic", "16:9 Widescreen", "21:9 Cinematic", "9:16 Portrait", "4:5 Instagram", "2:3 Portrait"];
576
+
577
+ return (
578
+ <div className="nb-node absolute text-white w-[360px]" style={{ left: localPos.x, top: localPos.y }}>
579
+ <div
580
+ className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
581
+ onPointerDown={onPointerDown}
582
+ onPointerMove={onPointerMove}
583
+ onPointerUp={onPointerUp}
584
+ >
585
+ <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
586
+ <div className="font-semibold text-sm flex-1 text-center">CAMERA</div>
587
+ <div className="flex items-center gap-1">
588
+ <Button
589
+ variant="ghost"
590
+ size="icon"
591
+ className="text-destructive hover:bg-destructive/20 h-6 w-6"
592
+ onClick={(e) => {
593
+ e.stopPropagation();
594
+ e.preventDefault();
595
+ if (confirm('Delete this node?')) {
596
+ onDelete(node.id);
597
+ }
598
+ }}
599
+ onPointerDown={(e) => e.stopPropagation()}
600
+ title="Delete node"
601
+ aria-label="Delete node"
602
+ >
603
+ ×
604
+ </Button>
605
+ <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
606
+ </div>
607
+ </div>
608
+ <div className="p-3 space-y-2 max-h-[500px] overflow-y-auto scrollbar-thin">
609
+ {node.input && (
610
+ <div className="flex justify-end mb-2">
611
+ <Button
612
+ variant="ghost"
613
+ size="sm"
614
+ onClick={() => onUpdate(node.id, { input: undefined })}
615
+ className="text-xs"
616
+ >
617
+ Clear Connection
618
+ </Button>
619
+ </div>
620
+ )}
621
+ {/* Basic Camera Settings */}
622
+ <div className="text-xs text-white/50 font-semibold mb-1">Basic Settings</div>
623
+ <div className="grid grid-cols-2 gap-2">
624
+ <div>
625
+ <label className="text-xs text-white/70">Focal Length</label>
626
+ <Select
627
+ className="w-full"
628
+ value={node.focalLength || "None"}
629
+ onChange={(e) => onUpdate(node.id, { focalLength: (e.target as HTMLSelectElement).value })}
630
+ >
631
+ {focalLengths.map(f => <option key={f} value={f}>{f}</option>)}
632
+ </Select>
633
+ </div>
634
+ <div>
635
+ <label className="text-xs text-white/70">Aperture</label>
636
+ <Select
637
+ className="w-full"
638
+ value={node.aperture || "None"}
639
+ onChange={(e) => onUpdate(node.id, { aperture: (e.target as HTMLSelectElement).value })}
640
+ >
641
+ {apertures.map(a => <option key={a} value={a}>{a}</option>)}
642
+ </Select>
643
+ </div>
644
+ <div>
645
+ <label className="text-xs text-white/70">Shutter Speed</label>
646
+ <Select
647
+ className="w-full"
648
+ value={node.shutterSpeed || "None"}
649
+ onChange={(e) => onUpdate(node.id, { shutterSpeed: (e.target as HTMLSelectElement).value })}
650
+ >
651
+ {shutterSpeeds.map(s => <option key={s} value={s}>{s}</option>)}
652
+ </Select>
653
+ </div>
654
+ <div>
655
+ <label className="text-xs text-white/70">ISO</label>
656
+ <Select
657
+ className="w-full"
658
+ value={node.iso || "None"}
659
+ onChange={(e) => onUpdate(node.id, { iso: (e.target as HTMLSelectElement).value })}
660
+ >
661
+ {isoValues.map(i => <option key={i} value={i}>{i}</option>)}
662
+ </Select>
663
+ </div>
664
+ </div>
665
+
666
+ {/* Creative Settings */}
667
+ <div className="text-xs text-white/50 font-semibold mb-1 mt-3">Creative Settings</div>
668
+ <div className="grid grid-cols-2 gap-2">
669
+ <div>
670
+ <label className="text-xs text-white/70">White Balance</label>
671
+ <Select
672
+ className="w-full"
673
+ value={node.whiteBalance || "None"}
674
+ onChange={(e) => onUpdate(node.id, { whiteBalance: (e.target as HTMLSelectElement).value })}
675
+ >
676
+ {whiteBalances.map(w => <option key={w} value={w}>{w}</option>)}
677
+ </Select>
678
+ </div>
679
+ <div>
680
+ <label className="text-xs text-white/70">Film Style</label>
681
+ <Select
682
+ className="w-full"
683
+ value={node.filmStyle || "None"}
684
+ onChange={(e) => onUpdate(node.id, { filmStyle: (e.target as HTMLSelectElement).value })}
685
+ >
686
+ {filmStyles.map(f => <option key={f} value={f}>{f}</option>)}
687
+ </Select>
688
+ </div>
689
+ <div>
690
+ <label className="text-xs text-white/70">Lighting</label>
691
+ <Select
692
+ className="w-full"
693
+ value={node.lighting || "None"}
694
+ onChange={(e) => onUpdate(node.id, { lighting: (e.target as HTMLSelectElement).value })}
695
+ >
696
+ {lightingTypes.map(l => <option key={l} value={l}>{l}</option>)}
697
+ </Select>
698
+ </div>
699
+ <div>
700
+ <label className="text-xs text-white/70">Bokeh Style</label>
701
+ <Select
702
+ className="w-full"
703
+ value={node.bokeh || "None"}
704
+ onChange={(e) => onUpdate(node.id, { bokeh: (e.target as HTMLSelectElement).value })}
705
+ >
706
+ {bokehStyles.map(b => <option key={b} value={b}>{b}</option>)}
707
+ </Select>
708
+ </div>
709
+ </div>
710
+
711
+ {/* Composition Settings */}
712
+ <div className="text-xs text-white/50 font-semibold mb-1 mt-3">Composition</div>
713
+ <div className="grid grid-cols-2 gap-2">
714
+ <div>
715
+ <label className="text-xs text-white/70">Camera Angle</label>
716
+ <Select
717
+ className="w-full"
718
+ value={node.angle || "None"}
719
+ onChange={(e) => onUpdate(node.id, { angle: (e.target as HTMLSelectElement).value })}
720
+ >
721
+ {angles.map(a => <option key={a} value={a}>{a}</option>)}
722
+ </Select>
723
+ </div>
724
+ <div>
725
+ <label className="text-xs text-white/70">Composition</label>
726
+ <Select
727
+ className="w-full"
728
+ value={node.composition || "None"}
729
+ onChange={(e) => onUpdate(node.id, { composition: (e.target as HTMLSelectElement).value })}
730
+ >
731
+ {compositions.map(c => <option key={c} value={c}>{c}</option>)}
732
+ </Select>
733
+ </div>
734
+ <div>
735
+ <label className="text-xs text-white/70">Aspect Ratio</label>
736
+ <Select
737
+ className="w-full"
738
+ value={node.aspectRatio || "None"}
739
+ onChange={(e) => onUpdate(node.id, { aspectRatio: (e.target as HTMLSelectElement).value })}
740
+ >
741
+ {aspectRatios.map(a => <option key={a} value={a}>{a}</option>)}
742
+ </Select>
743
+ </div>
744
+ </div>
745
+ <Button
746
+ className="w-full"
747
+ onClick={() => onProcess(node.id)}
748
+ disabled={node.isRunning}
749
+ title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
750
+ >
751
+ {node.isRunning ? "Processing..." : "Apply Camera Settings"}
752
+ </Button>
753
+ {node.output && (
754
+ <div className="space-y-2 mt-2">
755
+ <img src={node.output} className="w-full rounded" alt="Output" />
756
+ <Button
757
+ className="w-full"
758
+ variant="secondary"
759
+ onClick={() => downloadImage(node.output, `camera-${Date.now()}.png`)}
760
+ >
761
+ 📥 Download Output
762
+ </Button>
763
+ </div>
764
+ )}
765
+ {node.error && (
766
+ <div className="text-xs text-red-400 mt-2">{node.error}</div>
767
+ )}
768
+ </div>
769
+ </div>
770
+ );
771
+ }
772
+
773
+ export function FaceNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
774
+ const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
775
+ const hairstyles = ["None", "short", "long", "curly", "straight", "bald", "mohawk", "ponytail"];
776
+ const expressions = ["None", "happy", "serious", "smiling", "laughing", "sad", "surprised", "angry"];
777
+ const beardStyles = ["None", "stubble", "goatee", "full beard", "mustache", "clean shaven"];
778
+
779
+ return (
780
+ <div className="nb-node absolute text-white w-[340px]" style={{ left: localPos.x, top: localPos.y }}>
781
+ <div
782
+ className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
783
+ onPointerDown={onPointerDown}
784
+ onPointerMove={onPointerMove}
785
+ onPointerUp={onPointerUp}
786
+ >
787
+ <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
788
+ <div className="font-semibold text-sm flex-1 text-center">FACE</div>
789
+ <div className="flex items-center gap-1">
790
+ <Button
791
+ variant="ghost"
792
+ size="icon"
793
+ className="text-destructive hover:bg-destructive/20 h-6 w-6"
794
+ onClick={(e) => {
795
+ e.stopPropagation();
796
+ e.preventDefault();
797
+ if (confirm('Delete this node?')) {
798
+ onDelete(node.id);
799
+ }
800
+ }}
801
+ onPointerDown={(e) => e.stopPropagation()}
802
+ title="Delete node"
803
+ aria-label="Delete node"
804
+ >
805
+ ×
806
+ </Button>
807
+ <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
808
+ </div>
809
+ </div>
810
+ <div className="p-3 space-y-2">
811
+ {node.input && (
812
+ <div className="flex justify-end mb-2">
813
+ <Button
814
+ variant="ghost"
815
+ size="sm"
816
+ onClick={() => onUpdate(node.id, { input: undefined })}
817
+ className="text-xs"
818
+ >
819
+ Clear Connection
820
+ </Button>
821
+ </div>
822
+ )}
823
+ <div className="space-y-2">
824
+ <label className="flex items-center gap-2 text-xs">
825
+ <Checkbox
826
+ checked={node.faceOptions?.removePimples || false}
827
+ onChange={(e) => onUpdate(node.id, {
828
+ faceOptions: { ...node.faceOptions, removePimples: (e.target as HTMLInputElement).checked }
829
+ })}
830
+ />
831
+ Remove pimples
832
+ </label>
833
+ <label className="flex items-center gap-2 text-xs">
834
+ <Checkbox
835
+ checked={node.faceOptions?.addSunglasses || false}
836
+ onChange={(e) => onUpdate(node.id, {
837
+ faceOptions: { ...node.faceOptions, addSunglasses: (e.target as HTMLInputElement).checked }
838
+ })}
839
+ />
840
+ Add sunglasses
841
+ </label>
842
+ <label className="flex items-center gap-2 text-xs">
843
+ <Checkbox
844
+ checked={node.faceOptions?.addHat || false}
845
+ onChange={(e) => onUpdate(node.id, {
846
+ faceOptions: { ...node.faceOptions, addHat: (e.target as HTMLInputElement).checked }
847
+ })}
848
+ />
849
+ Add hat
850
+ </label>
851
+ </div>
852
+
853
+ <div>
854
+ <label className="text-xs text-white/70">Hairstyle</label>
855
+ <Select
856
+ className="w-full"
857
+ value={node.faceOptions?.changeHairstyle || "None"}
858
+ onChange={(e) => onUpdate(node.id, {
859
+ faceOptions: { ...node.faceOptions, changeHairstyle: (e.target as HTMLSelectElement).value }
860
+ })}
861
+ >
862
+ {hairstyles.map(h => <option key={h} value={h}>{h}</option>)}
863
+ </Select>
864
+ </div>
865
+
866
+ <div>
867
+ <label className="text-xs text-white/70">Expression</label>
868
+ <Select
869
+ className="w-full"
870
+ value={node.faceOptions?.facialExpression || "None"}
871
+ onChange={(e) => onUpdate(node.id, {
872
+ faceOptions: { ...node.faceOptions, facialExpression: (e.target as HTMLSelectElement).value }
873
+ })}
874
+ >
875
+ {expressions.map(e => <option key={e} value={e}>{e}</option>)}
876
+ </Select>
877
+ </div>
878
+
879
+ <div>
880
+ <label className="text-xs text-white/70">Beard</label>
881
+ <Select
882
+ className="w-full"
883
+ value={node.faceOptions?.beardStyle || "None"}
884
+ onChange={(e) => onUpdate(node.id, {
885
+ faceOptions: { ...node.faceOptions, beardStyle: (e.target as HTMLSelectElement).value }
886
+ })}
887
+ >
888
+ {beardStyles.map(b => <option key={b} value={b}>{b}</option>)}
889
+ </Select>
890
+ </div>
891
+
892
+ <Button
893
+ className="w-full"
894
+ onClick={() => onProcess(node.id)}
895
+ disabled={node.isRunning}
896
+ title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
897
+ >
898
+ {node.isRunning ? "Processing..." : "Apply Face Changes"}
899
+ </Button>
900
+ {node.output && (
901
+ <div className="space-y-2 mt-2">
902
+ <img src={node.output} className="w-full rounded" alt="Output" />
903
+ <Button
904
+ className="w-full"
905
+ variant="secondary"
906
+ onClick={() => downloadImage(node.output, `face-${Date.now()}.png`)}
907
+ >
908
+ 📥 Download Output
909
+ </Button>
910
+ </div>
911
+ )}
912
+ {node.error && (
913
+ <div className="text-xs text-red-400 mt-2">{node.error}</div>
914
+ )}
915
+ </div>
916
+ </div>
917
+ );
918
+ }
919
+
920
+ export function StyleNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
921
+ const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
922
+
923
+ const styleOptions = [
924
+ { value: "90s-anime", label: "90's Anime Style" },
925
+ { value: "mha", label: "My Hero Academia Style" },
926
+ { value: "dbz", label: "Dragon Ball Z Style" },
927
+ { value: "ukiyo-e", label: "Ukiyo-e Style" },
928
+ { value: "cyberpunk", label: "Cyberpunk Style" },
929
+ { value: "steampunk", label: "Steampunk Style" },
930
+ { value: "cubism", label: "Cubism Style" },
931
+ { value: "van-gogh", label: "Post-Impressionist (Van Gogh) Style" },
932
+ { value: "simpsons", label: "Simpsons Style" },
933
+ { value: "family-guy", label: "Family Guy Style" },
934
+ { value: "arcane", label: "Arcane – Painterly + Neon Rim Light" },
935
+ { value: "wildwest", label: "Wild West Style" },
936
+ { value: "stranger-things", label: "Stranger Things – 80s Kodak Style" },
937
+ { value: "breaking-bad", label: "Breaking Bad – Dusty Orange & Teal" },
938
+ ];
939
+
940
+ return (
941
+ <div
942
+ className="nb-node absolute text-white w-[320px]"
943
+ style={{ left: localPos.x, top: localPos.y }}
944
+ >
945
+ <div
946
+ className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
947
+ onPointerDown={onPointerDown}
948
+ onPointerMove={onPointerMove}
949
+ onPointerUp={onPointerUp}
950
+ >
951
+ <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
952
+ <div className="font-semibold text-sm flex-1 text-center">STYLE</div>
953
+ <div className="flex items-center gap-1">
954
+ <Button
955
+ variant="ghost"
956
+ size="icon"
957
+ className="text-destructive hover:bg-destructive/20 h-6 w-6"
958
+ onClick={(e) => {
959
+ e.stopPropagation();
960
+ e.preventDefault();
961
+ if (confirm('Delete this node?')) {
962
+ onDelete(node.id);
963
+ }
964
+ }}
965
+ onPointerDown={(e) => e.stopPropagation()}
966
+ title="Delete node"
967
+ aria-label="Delete node"
968
+ >
969
+ ×
970
+ </Button>
971
+ <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
972
+ </div>
973
+ </div>
974
+ <div className="p-3 space-y-3">
975
+ {node.input && (
976
+ <div className="flex justify-end mb-2">
977
+ <Button
978
+ variant="ghost"
979
+ size="sm"
980
+ onClick={() => onUpdate(node.id, { input: undefined })}
981
+ className="text-xs"
982
+ >
983
+ Clear Connection
984
+ </Button>
985
+ </div>
986
+ )}
987
+ <div className="text-xs text-white/70">Art Style</div>
988
+ <div className="text-xs text-white/50 mb-2">Select an artistic style to apply to your image</div>
989
+ <Select
990
+ className="w-full bg-black border-white/20 text-white focus:border-white/40 [&>option]:bg-black [&>option]:text-white"
991
+ value={node.stylePreset || ""}
992
+ onChange={(e) => onUpdate(node.id, { stylePreset: (e.target as HTMLSelectElement).value })}
993
+ >
994
+ <option value="" className="bg-black">Select a style...</option>
995
+ {styleOptions.map(opt => (
996
+ <option key={opt.value} value={opt.value} className="bg-black">
997
+ {opt.label}
998
+ </option>
999
+ ))}
1000
+ </Select>
1001
+ <div>
1002
+ <Slider
1003
+ label="Style Strength"
1004
+ valueLabel={`${node.styleStrength || 50}%`}
1005
+ min={0}
1006
+ max={100}
1007
+ value={node.styleStrength || 50}
1008
+ onChange={(e) => onUpdate(node.id, { styleStrength: parseInt((e.target as HTMLInputElement).value) })}
1009
+ />
1010
+ </div>
1011
+ <Button
1012
+ className="w-full"
1013
+ onClick={() => onProcess(node.id)}
1014
+ disabled={node.isRunning || !node.stylePreset}
1015
+ title={!node.input ? "Connect an input first" : !node.stylePreset ? "Select a style first" : "Apply the style to your input image"}
1016
+ >
1017
+ {node.isRunning ? "Applying Style..." : "Apply Style Transfer"}
1018
+ </Button>
1019
+ {node.output && (
1020
+ <div className="space-y-2">
1021
+ <img src={node.output} className="w-full rounded" alt="Output" />
1022
+ <Button
1023
+ className="w-full"
1024
+ variant="secondary"
1025
+ onClick={() => downloadImage(node.output, `style-${Date.now()}.png`)}
1026
+ >
1027
+ 📥 Download Output
1028
+ </Button>
1029
+ </div>
1030
+ )}
1031
+ {node.error && (
1032
+ <div className="text-xs text-red-400 mt-2">{node.error}</div>
1033
+ )}
1034
+ </div>
1035
+ </div>
1036
+ );
1037
+ }
1038
+
1039
+ export function EditNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
1040
+ const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
1041
+
1042
+ return (
1043
+ <div className="nb-node absolute text-white w-[320px]" style={{ left: localPos.x, top: localPos.y }}>
1044
+ <div
1045
+ className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
1046
+ onPointerDown={onPointerDown}
1047
+ onPointerMove={onPointerMove}
1048
+ onPointerUp={onPointerUp}
1049
+ >
1050
+ <Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
1051
+ <div className="font-semibold text-sm flex-1 text-center">EDIT</div>
1052
+ <div className="flex items-center gap-1">
1053
+ <Button
1054
+ variant="ghost"
1055
+ size="icon"
1056
+ className="text-destructive hover:bg-destructive/20 h-6 w-6"
1057
+ onClick={() => onDelete(node.id)}
1058
+ title="Delete node"
1059
+ aria-label="Delete node"
1060
+ >
1061
+ ×
1062
+ </Button>
1063
+ <Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
1064
+ </div>
1065
+ </div>
1066
+ <div className="p-3 space-y-3">
1067
+ {node.input && (
1068
+ <div className="flex justify-end mb-2">
1069
+ <Button
1070
+ variant="ghost"
1071
+ size="sm"
1072
+ onClick={() => onUpdate(node.id, { input: undefined })}
1073
+ className="text-xs"
1074
+ >
1075
+ Clear Connection
1076
+ </Button>
1077
+ </div>
1078
+ )}
1079
+ <Textarea
1080
+ className="w-full"
1081
+ placeholder="Describe what to edit (e.g., 'make it brighter', 'add more contrast', 'make it look vintage')"
1082
+ value={node.editPrompt || ""}
1083
+ onChange={(e) => onUpdate(node.id, { editPrompt: (e.target as HTMLTextAreaElement).value })}
1084
+ rows={3}
1085
+ />
1086
+ <Button
1087
+ className="w-full"
1088
+ onClick={() => onProcess(node.id)}
1089
+ disabled={node.isRunning}
1090
+ title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
1091
+ >
1092
+ {node.isRunning ? "Processing..." : "Apply Edit"}
1093
+ </Button>
1094
+ {node.output && (
1095
+ <div className="space-y-2">
1096
+ <img src={node.output} className="w-full rounded" alt="Output" />
1097
+ <Button
1098
+ className="w-full"
1099
+ variant="secondary"
1100
+ onClick={() => downloadImage(node.output, `edit-${Date.now()}.png`)}
1101
+ >
1102
+ 📥 Download Output
1103
+ </Button>
1104
+ </div>
1105
+ )}
1106
+ </div>
1107
+ </div>
1108
+ );
1109
+ }
app/page.tsx ADDED
@@ -0,0 +1,1861 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
components.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "new-york",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "app/globals.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "iconLibrary": "lucide",
14
+ "aliases": {
15
+ "components": "@/components",
16
+ "utils": "@/lib/utils",
17
+ "ui": "@/components/ui",
18
+ "lib": "@/lib",
19
+ "hooks": "@/hooks"
20
+ },
21
+ "registries": {}
22
+ }
components/magicui/interactive-hover-button.tsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { ArrowRight } from "lucide-react";
3
+ import { cn } from "@/lib/utils";
4
+
5
+ interface InteractiveHoverButtonProps
6
+ extends React.ButtonHTMLAttributes<HTMLButtonElement> {}
7
+
8
+ export const InteractiveHoverButton = React.forwardRef<
9
+ HTMLButtonElement,
10
+ InteractiveHoverButtonProps
11
+ >(({ children, className, ...props }, ref) => {
12
+ return (
13
+ <button
14
+ ref={ref}
15
+ className={cn(
16
+ "group relative w-auto cursor-pointer overflow-hidden rounded-full border bg-background p-2 px-6 text-center font-semibold",
17
+ className,
18
+ )}
19
+ {...props}
20
+ >
21
+ <div className="flex items-center gap-2">
22
+ <div className="h-2 w-2 rounded-full bg-primary transition-all duration-300 group-hover:scale-[100.8]"></div>
23
+ <span className="inline-block transition-all duration-300 group-hover:translate-x-12 group-hover:opacity-0">
24
+ {children}
25
+ </span>
26
+ </div>
27
+ <div className="absolute top-0 z-10 flex h-full w-full translate-x-12 items-center justify-center gap-2 text-primary-foreground opacity-0 transition-all duration-300 group-hover:-translate-x-5 group-hover:opacity-100">
28
+ <span>{children}</span>
29
+ <ArrowRight />
30
+ </div>
31
+ </button>
32
+ );
33
+ });
34
+
35
+ InteractiveHoverButton.displayName = "InteractiveHoverButton";
components/ui/button.tsx ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { cva, type VariantProps } from "class-variance-authority";
5
+ import { cn } from "../../lib/utils";
6
+
7
+ const buttonVariants = cva(
8
+ "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default:
13
+ "bg-primary text-primary-foreground hover:bg-primary/90",
14
+ destructive:
15
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
16
+ outline:
17
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
18
+ secondary:
19
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
20
+ ghost: "hover:bg-accent hover:text-accent-foreground",
21
+ link: "text-primary underline-offset-4 hover:underline",
22
+ },
23
+ size: {
24
+ default: "h-9 px-4 py-2",
25
+ sm: "h-8 px-3",
26
+ lg: "h-10 px-6",
27
+ icon: "h-9 w-9",
28
+ },
29
+ },
30
+ defaultVariants: {
31
+ variant: "default",
32
+ size: "default",
33
+ },
34
+ }
35
+ );
36
+
37
+ export interface ButtonProps
38
+ extends React.ButtonHTMLAttributes<HTMLButtonElement>,
39
+ VariantProps<typeof buttonVariants> {}
40
+
41
+ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
42
+ ({ className, variant, size, ...props }, ref) => {
43
+ return (
44
+ <button
45
+ className={cn(buttonVariants({ variant, size }), className)}
46
+ ref={ref}
47
+ {...props}
48
+ />
49
+ );
50
+ }
51
+ );
52
+ Button.displayName = "Button";
53
+
54
+ export { Button, buttonVariants };
components/ui/checkbox.tsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { cn } from "../../lib/utils";
3
+
4
+ export interface CheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {}
5
+
6
+ export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
7
+ ({ className, ...props }, ref) => {
8
+ return (
9
+ <input
10
+ ref={ref}
11
+ type="checkbox"
12
+ className={cn(
13
+ "h-4 w-4 rounded border border-input bg-background cursor-pointer align-middle",
14
+ className
15
+ )}
16
+ style={{ accentColor: "hsl(var(--primary))" }}
17
+ {...props}
18
+ />
19
+ );
20
+ }
21
+ );
22
+ Checkbox.displayName = "Checkbox";
23
+
components/ui/color-picker.tsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { cn } from "../../lib/utils";
3
+
4
+ export interface ColorPickerProps extends React.InputHTMLAttributes<HTMLInputElement> {}
5
+
6
+ export const ColorPicker = React.forwardRef<HTMLInputElement, ColorPickerProps>(
7
+ ({ className, ...props }, ref) => {
8
+ return (
9
+ <input
10
+ ref={ref}
11
+ type="color"
12
+ className={cn(
13
+ "h-9 w-full rounded-md border border-input bg-background p-1 shadow-sm cursor-pointer",
14
+ className
15
+ )}
16
+ {...props}
17
+ />
18
+ );
19
+ }
20
+ );
21
+ ColorPicker.displayName = "ColorPicker";
22
+
components/ui/input.tsx ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { cn } from "../../lib/utils";
3
+
4
+ export interface InputProps
5
+ extends React.InputHTMLAttributes<HTMLInputElement> {}
6
+
7
+ const Input = React.forwardRef<HTMLInputElement, InputProps>(
8
+ ({ className, type, ...props }, ref) => {
9
+ return (
10
+ <input
11
+ type={type}
12
+ className={cn(
13
+ "flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
14
+ className
15
+ )}
16
+ ref={ref}
17
+ {...props}
18
+ />
19
+ );
20
+ }
21
+ );
22
+ Input.displayName = "Input";
23
+
24
+ export { Input };
components/ui/label.tsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { cn } from "../../lib/utils";
3
+
4
+ export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
5
+
6
+ const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
7
+ ({ className, ...props }, ref) => (
8
+ <label
9
+ ref={ref}
10
+ className={cn(
11
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
12
+ className
13
+ )}
14
+ {...props}
15
+ />
16
+ )
17
+ );
18
+ Label.displayName = "Label";
19
+
20
+ export { Label };
components/ui/select.tsx ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { cn } from "../../lib/utils";
3
+
4
+ export interface SelectProps
5
+ extends React.SelectHTMLAttributes<HTMLSelectElement> {}
6
+
7
+ const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
8
+ ({ className, children, ...props }, ref) => {
9
+ return (
10
+ <select
11
+ ref={ref}
12
+ className={cn(
13
+ "h-9 w-full rounded-md border border-input bg-background px-2 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
14
+ className
15
+ )}
16
+ {...props}
17
+ >
18
+ {children}
19
+ </select>
20
+ );
21
+ }
22
+ );
23
+ Select.displayName = "Select";
24
+
25
+ export { Select };
components/ui/slider.tsx ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { cn } from "../../lib/utils";
3
+
4
+ export interface SliderProps extends React.InputHTMLAttributes<HTMLInputElement> {
5
+ label?: string;
6
+ valueLabel?: string;
7
+ }
8
+
9
+ export const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
10
+ ({ className, label, valueLabel, min = 0, max = 100, step = 1, ...props }, ref) => {
11
+ return (
12
+ <div className={cn("w-full", className)}>
13
+ {(label || valueLabel) && (
14
+ <div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
15
+ <span>{label}</span>
16
+ <span>{valueLabel}</span>
17
+ </div>
18
+ )}
19
+ <input
20
+ ref={ref}
21
+ type="range"
22
+ min={min as number}
23
+ max={max as number}
24
+ step={step as number}
25
+ className={cn(
26
+ "w-full appearance-none h-2 rounded-lg bg-muted outline-none focus-visible:ring-2 focus-visible:ring-ring",
27
+ "[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full",
28
+ "[&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:cursor-pointer",
29
+ "[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary"
30
+ )}
31
+ {...props}
32
+ />
33
+ </div>
34
+ );
35
+ }
36
+ );
37
+ Slider.displayName = "Slider";
38
+
components/ui/textarea.tsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react";
2
+ import { cn } from "../../lib/utils";
3
+
4
+ export interface TextareaProps
5
+ extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
6
+
7
+ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
8
+ ({ className, ...props }, ref) => {
9
+ return (
10
+ <textarea
11
+ className={cn(
12
+ "flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
13
+ className
14
+ )}
15
+ ref={ref}
16
+ {...props}
17
+ />
18
+ );
19
+ }
20
+ );
21
+ Textarea.displayName = "Textarea";
22
+
23
+ export { Textarea };
eslint.config.mjs ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { dirname } from "path";
2
+ import { fileURLToPath } from "url";
3
+ import { FlatCompat } from "@eslint/eslintrc";
4
+
5
+ const __filename = fileURLToPath(import.meta.url);
6
+ const __dirname = dirname(__filename);
7
+
8
+ const compat = new FlatCompat({
9
+ baseDirectory: __dirname,
10
+ });
11
+
12
+ const eslintConfig = [
13
+ ...compat.extends("next/core-web-vitals", "next/typescript"),
14
+ {
15
+ ignores: [
16
+ "node_modules/**",
17
+ ".next/**",
18
+ "out/**",
19
+ "build/**",
20
+ "next-env.d.ts",
21
+ ],
22
+ },
23
+ {
24
+ rules: {
25
+ "@typescript-eslint/no-explicit-any": "warn",
26
+ "@typescript-eslint/no-unused-vars": "warn",
27
+ "@typescript-eslint/no-empty-object-type": "warn",
28
+ "@next/next/no-img-element": "warn",
29
+ "@next/next/no-html-link-for-pages": "warn",
30
+ "react/no-unescaped-entities": "warn",
31
+ "react-hooks/exhaustive-deps": "warn",
32
+ },
33
+ },
34
+ ];
35
+
36
+ export default eslintConfig;
lib/utils.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ import { clsx, type ClassValue } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
next-env.d.ts ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+ /// <reference path="./.next/types/routes.d.ts" />
4
+
5
+ // NOTE: This file should not be edited
6
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
next.config.ts ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { NextConfig } from "next";
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',
10
+ },
11
+ api: {
12
+ bodyParser: {
13
+ sizeLimit: '50mb',
14
+ },
15
+ },
16
+ };
17
+
18
+ export default nextConfig;
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "banana",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev --turbopack",
7
+ "build": "next build --turbopack",
8
+ "start": "next start",
9
+ "lint": "eslint"
10
+ },
11
+ "dependencies": {
12
+ "@google/genai": "^1.17.0",
13
+ "class-variance-authority": "^0.7.0",
14
+ "clsx": "^2.1.1",
15
+ "lucide-react": "^0.542.0",
16
+ "next": "15.5.2",
17
+ "react": "19.1.0",
18
+ "react-dom": "19.1.0",
19
+ "tailwind-merge": "^2.5.3"
20
+ },
21
+ "devDependencies": {
22
+ "@eslint/eslintrc": "^3",
23
+ "@tailwindcss/postcss": "^4",
24
+ "@types/node": "^20",
25
+ "@types/react": "^19",
26
+ "@types/react-dom": "^19",
27
+ "eslint": "^9",
28
+ "eslint-config-next": "15.5.2",
29
+ "tailwindcss": "^4",
30
+ "typescript": "^5"
31
+ }
32
+ }
postcss.config.mjs ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ const config = {
2
+ plugins: ["@tailwindcss/postcss"],
3
+ };
4
+
5
+ export default config;
public/blazzer.png ADDED
public/file.svg ADDED
public/globe.svg ADDED
public/next.svg ADDED
public/sukajan.png ADDED

Git LFS Details

  • SHA256: 55ddf339f96ccdc5c8304b28ec9734749b9f8d6b51537fccff9562ee7abdb8f4
  • Pointer size: 131 Bytes
  • Size of remote file: 106 kB
public/vercel.svg ADDED
public/window.svg ADDED
tsconfig.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2017",
4
+ "lib": ["dom", "dom.iterable", "esnext"],
5
+ "allowJs": true,
6
+ "skipLibCheck": true,
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "module": "esnext",
11
+ "moduleResolution": "bundler",
12
+ "resolveJsonModule": true,
13
+ "isolatedModules": true,
14
+ "jsx": "preserve",
15
+ "incremental": true,
16
+ "plugins": [
17
+ {
18
+ "name": "next"
19
+ }
20
+ ],
21
+ "paths": {
22
+ "@/*": ["./*"]
23
+ }
24
+ },
25
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26
+ "exclude": ["node_modules"]
27
+ }
tsconfig.tsbuildinfo ADDED
The diff for this file is too large to render. See raw diff