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