Spaces:
Running
Running
Upload 38 files
Browse files- .gitattributes +1 -0
- .gitignore +43 -0
- Dockerfile +57 -0
- README.md +38 -7
- app/api/generate/route.ts +53 -0
- app/api/merge/route.ts +146 -0
- app/api/process/route.ts +372 -0
- app/editor.css +64 -0
- app/favicon.ico +0 -0
- app/globals.css +275 -0
- app/layout.tsx +34 -0
- app/nodes.tsx +1109 -0
- app/page.tsx +1861 -0
- components.json +22 -0
- components/magicui/interactive-hover-button.tsx +35 -0
- components/ui/button.tsx +54 -0
- components/ui/checkbox.tsx +23 -0
- components/ui/color-picker.tsx +22 -0
- components/ui/input.tsx +24 -0
- components/ui/label.tsx +20 -0
- components/ui/select.tsx +25 -0
- components/ui/slider.tsx +38 -0
- components/ui/textarea.tsx +23 -0
- eslint.config.mjs +36 -0
- lib/utils.ts +6 -0
- next-env.d.ts +6 -0
- next.config.ts +18 -0
- package-lock.json +0 -0
- package.json +32 -0
- postcss.config.mjs +5 -0
- public/blazzer.png +0 -0
- public/file.svg +1 -0
- public/globe.svg +1 -0
- public/next.svg +1 -0
- public/sukajan.png +3 -0
- public/vercel.svg +1 -0
- public/window.svg +1 -0
- tsconfig.json +27 -0
- tsconfig.tsbuildinfo +0 -0
.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:
|
5 |
-
colorTo:
|
6 |
sdk: docker
|
7 |
pinned: false
|
8 |
-
|
9 |
-
short_description: Play with Nano Banana using Nodes
|
10 |
---
|
11 |
|
12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
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
|
|