Spaces:
Running
Running
the site is done i improved the UI/UX
Browse files- app/api/merge/route.ts +61 -18
- app/api/process/route.ts +53 -26
- app/editor/nodes.tsx +307 -153
- app/editor/page.tsx +104 -52
- app/globals.css +131 -27
- app/layout.tsx +1 -1
- app/try-on/page.tsx +1 -1
- package-lock.json +35 -1
- package.json +4 -1
app/api/merge/route.ts
CHANGED
|
@@ -10,6 +10,26 @@ function parseDataUrl(dataUrl: string): { mimeType: string; data: string } | nul
|
|
| 10 |
return { mimeType: match[1] || "image/png", data: match[2] };
|
| 11 |
}
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
export async function POST(req: NextRequest) {
|
| 14 |
try {
|
| 15 |
const body = (await req.json()) as {
|
|
@@ -36,29 +56,52 @@ export async function POST(req: NextRequest) {
|
|
| 36 |
const ai = new GoogleGenAI({ apiKey });
|
| 37 |
|
| 38 |
// Build parts array: first the text prompt, then image inlineData parts
|
| 39 |
-
//
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
Requirements:
|
| 46 |
-
- Include EVERY person from EVERY input image (if an image has multiple people, include all of them)
|
| 47 |
-
- Combine all people into a single scene where they appear together
|
| 48 |
-
- Arrange them naturally (standing side by side, in rows, or in a natural group formation)
|
| 49 |
-
- Ensure all people are clearly visible and recognizable
|
| 50 |
-
- Match the lighting, shadows, and proportions to look realistic
|
| 51 |
-
- Preserve each person's original appearance, clothing, and characteristics
|
| 52 |
-
- The final composition should look like a genuine group photograph
|
| 53 |
-
|
| 54 |
-
Output: One photorealistic image containing ALL people from ALL input images combined together.`;
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
const parts: any[] = [{ text: prompt }];
|
| 57 |
for (const url of imgs) {
|
| 58 |
-
const parsed =
|
| 59 |
-
if (!parsed)
|
|
|
|
|
|
|
|
|
|
| 60 |
parts.push({ inlineData: { mimeType: parsed.mimeType, data: parsed.data } });
|
| 61 |
}
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
const response = await ai.models.generateContent({
|
| 64 |
model: "gemini-2.5-flash-image-preview",
|
|
|
|
| 10 |
return { mimeType: match[1] || "image/png", data: match[2] };
|
| 11 |
}
|
| 12 |
|
| 13 |
+
async function toInlineData(url: string): Promise<{ mimeType: string; data: string } | null> {
|
| 14 |
+
try {
|
| 15 |
+
if (url.startsWith('data:')) {
|
| 16 |
+
return parseDataUrl(url);
|
| 17 |
+
}
|
| 18 |
+
if (url.startsWith('http')) {
|
| 19 |
+
// Fetch HTTP URL and convert to base64
|
| 20 |
+
const res = await fetch(url);
|
| 21 |
+
const buf = await res.arrayBuffer();
|
| 22 |
+
const base64 = Buffer.from(buf).toString('base64');
|
| 23 |
+
const mimeType = res.headers.get('content-type') || 'image/jpeg';
|
| 24 |
+
return { mimeType, data: base64 };
|
| 25 |
+
}
|
| 26 |
+
return null;
|
| 27 |
+
} catch (e) {
|
| 28 |
+
console.error('Failed to process image URL:', url.substring(0, 100), e);
|
| 29 |
+
return null;
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
export async function POST(req: NextRequest) {
|
| 34 |
try {
|
| 35 |
const body = (await req.json()) as {
|
|
|
|
| 56 |
const ai = new GoogleGenAI({ apiKey });
|
| 57 |
|
| 58 |
// Build parts array: first the text prompt, then image inlineData parts
|
| 59 |
+
// If no custom prompt, use default extraction-focused prompt
|
| 60 |
+
let prompt = body.prompt;
|
| 61 |
+
|
| 62 |
+
if (!prompt) {
|
| 63 |
+
prompt = `MERGE TASK: You are provided with exactly ${imgs.length} source images.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
+
INSTRUCTIONS:
|
| 66 |
+
1. EXTRACT the exact people/subjects from EACH provided image
|
| 67 |
+
2. DO NOT generate new people - use ONLY the people visible in the provided images
|
| 68 |
+
3. COMBINE all extracted people into ONE single group photo
|
| 69 |
+
4. The output must contain ALL people from ALL ${imgs.length} input images together
|
| 70 |
+
|
| 71 |
+
Requirements:
|
| 72 |
+
- Use the ACTUAL people from the provided images (do not create new ones)
|
| 73 |
+
- If an image has multiple people, include ALL of them
|
| 74 |
+
- Arrange everyone naturally in the same scene
|
| 75 |
+
- Match lighting and proportions realistically
|
| 76 |
+
- Output exactly ONE image with everyone combined
|
| 77 |
+
|
| 78 |
+
DO NOT create artistic interpretations or new people. EXTRACT and COMBINE the actual subjects from the provided photographs.`;
|
| 79 |
+
} else {
|
| 80 |
+
// Even with custom prompt, append extraction requirements
|
| 81 |
+
const enforcement = `\n\nIMPORTANT: Extract and use the EXACT people from the provided images. Do not generate new people or artistic interpretations. Combine the actual subjects from all ${imgs.length} images into one output.`;
|
| 82 |
+
prompt = `${prompt}${enforcement}`;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
// Debug: Log what we're receiving
|
| 86 |
+
console.log(`[MERGE API] Received ${imgs.length} images to merge`);
|
| 87 |
+
console.log(`[MERGE API] Image types:`, imgs.map(img => {
|
| 88 |
+
if (img.startsWith('data:')) return 'data URL';
|
| 89 |
+
if (img.startsWith('http')) return 'HTTP URL';
|
| 90 |
+
return 'unknown';
|
| 91 |
+
}));
|
| 92 |
+
|
| 93 |
const parts: any[] = [{ text: prompt }];
|
| 94 |
for (const url of imgs) {
|
| 95 |
+
const parsed = await toInlineData(url);
|
| 96 |
+
if (!parsed) {
|
| 97 |
+
console.error('[MERGE API] Failed to parse image:', url.substring(0, 100));
|
| 98 |
+
continue;
|
| 99 |
+
}
|
| 100 |
parts.push({ inlineData: { mimeType: parsed.mimeType, data: parsed.data } });
|
| 101 |
}
|
| 102 |
+
|
| 103 |
+
console.log(`[MERGE API] Sending ${parts.length - 1} images to model (prompt + images)`);
|
| 104 |
+
console.log(`[MERGE API] Prompt preview:`, prompt.substring(0, 200));
|
| 105 |
|
| 106 |
const response = await ai.models.generateContent({
|
| 107 |
model: "gemini-2.5-flash-image-preview",
|
app/api/process/route.ts
CHANGED
|
@@ -28,24 +28,40 @@ export async function POST(req: NextRequest) {
|
|
| 28 |
|
| 29 |
const ai = new GoogleGenAI({ apiKey });
|
| 30 |
|
| 31 |
-
//
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
const
|
| 41 |
-
const
|
| 42 |
-
const
|
| 43 |
-
|
| 44 |
-
parsed = { mimeType, data: base64 };
|
| 45 |
-
} catch (e) {
|
| 46 |
-
return NextResponse.json({ error: "Failed to fetch image from URL" }, { status: 400 });
|
| 47 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
}
|
| 50 |
|
| 51 |
if (!parsed) {
|
|
@@ -55,6 +71,9 @@ export async function POST(req: NextRequest) {
|
|
| 55 |
// Build combined prompt from all accumulated parameters
|
| 56 |
const prompts: string[] = [];
|
| 57 |
const params = body.params || {};
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
// Background modifications
|
| 60 |
if (params.backgroundType) {
|
|
@@ -64,7 +83,9 @@ export async function POST(req: NextRequest) {
|
|
| 64 |
} else if (bgType === "image") {
|
| 65 |
prompts.push(`Change the background to ${params.backgroundImage || "a beautiful beach scene"}.`);
|
| 66 |
} else if (bgType === "upload" && params.customBackgroundImage) {
|
| 67 |
-
prompts.push(`Replace the background
|
|
|
|
|
|
|
| 68 |
} else if (params.customPrompt) {
|
| 69 |
prompts.push(params.customPrompt);
|
| 70 |
}
|
|
@@ -72,21 +93,23 @@ export async function POST(req: NextRequest) {
|
|
| 72 |
|
| 73 |
// Clothes modifications
|
| 74 |
if (params.clothesImage) {
|
| 75 |
-
// If clothesImage is provided, we need to handle it differently
|
| 76 |
-
// For now, we'll create a descriptive prompt
|
| 77 |
if (params.selectedPreset === "Sukajan") {
|
| 78 |
-
prompts.push("
|
| 79 |
} else if (params.selectedPreset === "Blazer") {
|
| 80 |
-
prompts.push("
|
| 81 |
-
} else
|
| 82 |
-
prompts.push("
|
| 83 |
}
|
|
|
|
|
|
|
| 84 |
}
|
| 85 |
|
| 86 |
// Style blending
|
| 87 |
if (params.styleImage) {
|
| 88 |
const strength = params.blendStrength || 50;
|
| 89 |
-
prompts.push(`Apply artistic style blending at ${strength}% strength.`);
|
|
|
|
|
|
|
| 90 |
}
|
| 91 |
|
| 92 |
// Edit prompt
|
|
@@ -96,7 +119,7 @@ export async function POST(req: NextRequest) {
|
|
| 96 |
|
| 97 |
// Camera settings
|
| 98 |
if (params.focalLength || params.aperture || params.shutterSpeed || params.whiteBalance || params.angle ||
|
| 99 |
-
params.iso || params.filmStyle || params.lighting || params.bokeh || params.composition) {
|
| 100 |
const cameraSettings: string[] = [];
|
| 101 |
if (params.focalLength) {
|
| 102 |
if (params.focalLength === "8mm fisheye") {
|
|
@@ -114,6 +137,7 @@ export async function POST(req: NextRequest) {
|
|
| 114 |
if (params.lighting) cameraSettings.push(`Lighting: ${params.lighting}`);
|
| 115 |
if (params.bokeh) cameraSettings.push(`Bokeh effect: ${params.bokeh}`);
|
| 116 |
if (params.composition) cameraSettings.push(`Composition: ${params.composition}`);
|
|
|
|
| 117 |
|
| 118 |
if (cameraSettings.length > 0) {
|
| 119 |
prompts.push(`Apply professional photography settings: ${cameraSettings.join(", ")}`);
|
|
@@ -154,7 +178,10 @@ export async function POST(req: NextRequest) {
|
|
| 154 |
// Generate with Gemini
|
| 155 |
const parts = [
|
| 156 |
{ text: prompt },
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
| 158 |
];
|
| 159 |
|
| 160 |
const response = await ai.models.generateContent({
|
|
|
|
| 28 |
|
| 29 |
const ai = new GoogleGenAI({ apiKey });
|
| 30 |
|
| 31 |
+
// Helpers
|
| 32 |
+
const toInlineDataFromAny = async (url: string): Promise<{ mimeType: string; data: string } | null> => {
|
| 33 |
+
if (!url) return null;
|
| 34 |
+
try {
|
| 35 |
+
if (url.startsWith('data:')) {
|
| 36 |
+
return parseDataUrl(url);
|
| 37 |
+
}
|
| 38 |
+
if (url.startsWith('http')) {
|
| 39 |
+
const res = await fetch(url);
|
| 40 |
+
const buf = await res.arrayBuffer();
|
| 41 |
+
const base64 = Buffer.from(buf).toString('base64');
|
| 42 |
+
const mimeType = res.headers.get('content-type') || 'image/jpeg';
|
| 43 |
+
return { mimeType, data: base64 };
|
|
|
|
|
|
|
|
|
|
| 44 |
}
|
| 45 |
+
if (url.startsWith('/')) {
|
| 46 |
+
const host = req.headers.get('host') ?? 'localhost:3000';
|
| 47 |
+
const proto = req.headers.get('x-forwarded-proto') ?? 'http';
|
| 48 |
+
const absolute = `${proto}://${host}${url}`;
|
| 49 |
+
const res = await fetch(absolute);
|
| 50 |
+
const buf = await res.arrayBuffer();
|
| 51 |
+
const base64 = Buffer.from(buf).toString('base64');
|
| 52 |
+
const mimeType = res.headers.get('content-type') || 'image/png';
|
| 53 |
+
return { mimeType, data: base64 };
|
| 54 |
+
}
|
| 55 |
+
return null;
|
| 56 |
+
} catch {
|
| 57 |
+
return null;
|
| 58 |
}
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
// Parse input image
|
| 62 |
+
let parsed = null as null | { mimeType: string; data: string };
|
| 63 |
+
if (body.image) {
|
| 64 |
+
parsed = await toInlineDataFromAny(body.image);
|
| 65 |
}
|
| 66 |
|
| 67 |
if (!parsed) {
|
|
|
|
| 71 |
// Build combined prompt from all accumulated parameters
|
| 72 |
const prompts: string[] = [];
|
| 73 |
const params = body.params || {};
|
| 74 |
+
|
| 75 |
+
// We'll collect additional inline image parts (references)
|
| 76 |
+
const referenceParts: { inlineData: { mimeType: string; data: string } }[] = [];
|
| 77 |
|
| 78 |
// Background modifications
|
| 79 |
if (params.backgroundType) {
|
|
|
|
| 83 |
} else if (bgType === "image") {
|
| 84 |
prompts.push(`Change the background to ${params.backgroundImage || "a beautiful beach scene"}.`);
|
| 85 |
} else if (bgType === "upload" && params.customBackgroundImage) {
|
| 86 |
+
prompts.push(`Replace the background using the provided custom background reference image (attached below). Ensure perspective and lighting match.`);
|
| 87 |
+
const bgRef = await toInlineDataFromAny(params.customBackgroundImage);
|
| 88 |
+
if (bgRef) referenceParts.push({ inlineData: bgRef });
|
| 89 |
} else if (params.customPrompt) {
|
| 90 |
prompts.push(params.customPrompt);
|
| 91 |
}
|
|
|
|
| 93 |
|
| 94 |
// Clothes modifications
|
| 95 |
if (params.clothesImage) {
|
|
|
|
|
|
|
| 96 |
if (params.selectedPreset === "Sukajan") {
|
| 97 |
+
prompts.push("Replace the person's clothing with a Japanese sukajan jacket (embroidered designs). Use the clothes reference image if provided.");
|
| 98 |
} else if (params.selectedPreset === "Blazer") {
|
| 99 |
+
prompts.push("Replace the person's clothing with a professional blazer. Use the clothes reference image if provided.");
|
| 100 |
+
} else {
|
| 101 |
+
prompts.push("Replace the person's clothing to match the provided clothes reference image (attached below). Preserve body pose and identity.");
|
| 102 |
}
|
| 103 |
+
const clothesRef = await toInlineDataFromAny(params.clothesImage);
|
| 104 |
+
if (clothesRef) referenceParts.push({ inlineData: clothesRef });
|
| 105 |
}
|
| 106 |
|
| 107 |
// Style blending
|
| 108 |
if (params.styleImage) {
|
| 109 |
const strength = params.blendStrength || 50;
|
| 110 |
+
prompts.push(`Apply artistic style blending using the provided style reference image (attached below) at ${strength}% strength.`);
|
| 111 |
+
const styleRef = await toInlineDataFromAny(params.styleImage);
|
| 112 |
+
if (styleRef) referenceParts.push({ inlineData: styleRef });
|
| 113 |
}
|
| 114 |
|
| 115 |
// Edit prompt
|
|
|
|
| 119 |
|
| 120 |
// Camera settings
|
| 121 |
if (params.focalLength || params.aperture || params.shutterSpeed || params.whiteBalance || params.angle ||
|
| 122 |
+
params.iso || params.filmStyle || params.lighting || params.bokeh || params.composition || params.aspectRatio) {
|
| 123 |
const cameraSettings: string[] = [];
|
| 124 |
if (params.focalLength) {
|
| 125 |
if (params.focalLength === "8mm fisheye") {
|
|
|
|
| 137 |
if (params.lighting) cameraSettings.push(`Lighting: ${params.lighting}`);
|
| 138 |
if (params.bokeh) cameraSettings.push(`Bokeh effect: ${params.bokeh}`);
|
| 139 |
if (params.composition) cameraSettings.push(`Composition: ${params.composition}`);
|
| 140 |
+
if (params.aspectRatio) cameraSettings.push(`Aspect Ratio: ${params.aspectRatio}`);
|
| 141 |
|
| 142 |
if (cameraSettings.length > 0) {
|
| 143 |
prompts.push(`Apply professional photography settings: ${cameraSettings.join(", ")}`);
|
|
|
|
| 178 |
// Generate with Gemini
|
| 179 |
const parts = [
|
| 180 |
{ text: prompt },
|
| 181 |
+
// Primary subject image (input)
|
| 182 |
+
{ inlineData: { mimeType: parsed.mimeType, data: parsed.data } },
|
| 183 |
+
// Additional reference images to guide modifications
|
| 184 |
+
...referenceParts,
|
| 185 |
];
|
| 186 |
|
| 187 |
const response = await ai.models.generateContent({
|
app/editor/nodes.tsx
CHANGED
|
@@ -1,6 +1,13 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import React, { useState, useRef, useEffect } from "react";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
// Helper function to download image
|
| 6 |
function downloadImage(dataUrl: string, filename: string) {
|
|
@@ -170,36 +177,44 @@ export function BackgroundNodeView({
|
|
| 170 |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
|
| 171 |
<div className="font-semibold text-sm flex-1 text-center">BACKGROUND</div>
|
| 172 |
<div className="flex items-center gap-2">
|
| 173 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
<Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
|
| 175 |
</div>
|
| 176 |
</div>
|
| 177 |
<div className="p-3 space-y-3">
|
| 178 |
-
<
|
| 179 |
-
className="w-full
|
| 180 |
value={node.backgroundType || "color"}
|
| 181 |
-
onChange={(e) => onUpdate(node.id, { backgroundType: e.target.value })}
|
| 182 |
>
|
| 183 |
<option value="color">Solid Color</option>
|
| 184 |
<option value="image">Preset Background</option>
|
| 185 |
<option value="upload">Upload Image</option>
|
| 186 |
<option value="custom">Custom Prompt</option>
|
| 187 |
-
</
|
| 188 |
|
| 189 |
{node.backgroundType === "color" && (
|
| 190 |
-
<
|
| 191 |
-
|
| 192 |
-
className="w-full h-10 rounded"
|
| 193 |
value={node.backgroundColor || "#ffffff"}
|
| 194 |
-
onChange={(e) => onUpdate(node.id, { backgroundColor: e.target.value })}
|
| 195 |
/>
|
| 196 |
)}
|
| 197 |
|
| 198 |
{node.backgroundType === "image" && (
|
| 199 |
-
<
|
| 200 |
-
className="w-full
|
| 201 |
value={node.backgroundImage || ""}
|
| 202 |
-
onChange={(e) => onUpdate(node.id, { backgroundImage: e.target.value })}
|
| 203 |
>
|
| 204 |
<option value="">Select Background</option>
|
| 205 |
<option value="beach">Beach</option>
|
|
@@ -207,7 +222,7 @@ export function BackgroundNodeView({
|
|
| 207 |
<option value="studio">Studio</option>
|
| 208 |
<option value="nature">Nature</option>
|
| 209 |
<option value="city">City Skyline</option>
|
| 210 |
-
</
|
| 211 |
)}
|
| 212 |
|
| 213 |
{node.backgroundType === "upload" && (
|
|
@@ -215,12 +230,14 @@ export function BackgroundNodeView({
|
|
| 215 |
{node.customBackgroundImage ? (
|
| 216 |
<div className="relative">
|
| 217 |
<img src={node.customBackgroundImage} className="w-full rounded" alt="Custom Background" />
|
| 218 |
-
<
|
| 219 |
-
|
|
|
|
|
|
|
| 220 |
onClick={() => onUpdate(node.id, { customBackgroundImage: null })}
|
| 221 |
>
|
| 222 |
Remove
|
| 223 |
-
</
|
| 224 |
</div>
|
| 225 |
) : (
|
| 226 |
<label className="block">
|
|
@@ -240,33 +257,34 @@ export function BackgroundNodeView({
|
|
| 240 |
)}
|
| 241 |
|
| 242 |
{node.backgroundType === "custom" && (
|
| 243 |
-
<
|
| 244 |
-
className="w-full
|
| 245 |
placeholder="Describe the background..."
|
| 246 |
value={node.customPrompt || ""}
|
| 247 |
-
onChange={(e) => onUpdate(node.id, { customPrompt: e.target.value })}
|
| 248 |
rows={2}
|
| 249 |
/>
|
| 250 |
)}
|
| 251 |
|
| 252 |
-
<
|
| 253 |
-
className="w-full
|
| 254 |
onClick={() => onProcess(node.id)}
|
| 255 |
disabled={node.isRunning}
|
| 256 |
title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
|
| 257 |
>
|
| 258 |
{node.isRunning ? "Processing..." : "Apply Background"}
|
| 259 |
-
</
|
| 260 |
|
| 261 |
{node.output && (
|
| 262 |
<div className="space-y-2">
|
| 263 |
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 264 |
-
<
|
| 265 |
-
className="w-full
|
|
|
|
| 266 |
onClick={() => downloadImage(node.output, `background-${Date.now()}.png`)}
|
| 267 |
>
|
| 268 |
📥 Download Output
|
| 269 |
-
</
|
| 270 |
</div>
|
| 271 |
)}
|
| 272 |
{node.error && (
|
|
@@ -337,11 +355,32 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
|
|
| 337 |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
|
| 338 |
<div className="font-semibold text-sm flex-1 text-center">CLOTHES</div>
|
| 339 |
<div className="flex items-center gap-2">
|
| 340 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
<Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
|
| 342 |
</div>
|
| 343 |
</div>
|
| 344 |
<div className="p-3 space-y-3">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
{hasConfig && (
|
| 346 |
<div className="text-xs bg-yellow-500/20 border border-yellow-500/50 rounded px-2 py-1 text-yellow-300">
|
| 347 |
⚡ Config pending - will apply when downstream node processes
|
|
@@ -373,12 +412,14 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
|
|
| 373 |
{node.clothesImage && !node.selectedPreset ? (
|
| 374 |
<div className="relative">
|
| 375 |
<img src={node.clothesImage} className="w-full rounded" alt="Clothes" />
|
| 376 |
-
<
|
| 377 |
-
|
|
|
|
|
|
|
| 378 |
onClick={() => onUpdate(node.id, { clothesImage: null, selectedPreset: null })}
|
| 379 |
>
|
| 380 |
Remove
|
| 381 |
-
</
|
| 382 |
</div>
|
| 383 |
) : !node.selectedPreset ? (
|
| 384 |
<label className="block">
|
|
@@ -400,23 +441,24 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
|
|
| 400 |
</label>
|
| 401 |
) : null}
|
| 402 |
|
| 403 |
-
<
|
| 404 |
-
className="w-full
|
| 405 |
onClick={() => onProcess(node.id)}
|
| 406 |
disabled={node.isRunning || !node.clothesImage}
|
| 407 |
title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
|
| 408 |
>
|
| 409 |
{node.isRunning ? "Processing..." : "Apply Clothes"}
|
| 410 |
-
</
|
| 411 |
{node.output && (
|
| 412 |
<div className="space-y-2">
|
| 413 |
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 414 |
-
<
|
| 415 |
-
className="w-full
|
|
|
|
| 416 |
onClick={() => downloadImage(node.output, `clothes-${Date.now()}.png`)}
|
| 417 |
>
|
| 418 |
📥 Download Output
|
| 419 |
-
</
|
| 420 |
</div>
|
| 421 |
)}
|
| 422 |
{node.error && (
|
|
@@ -442,42 +484,60 @@ export function AgeNodeView({ node, onDelete, onUpdate, onStartConnection, onEnd
|
|
| 442 |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
|
| 443 |
<div className="font-semibold text-sm flex-1 text-center">AGE</div>
|
| 444 |
<div className="flex items-center gap-2">
|
| 445 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
<Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
|
| 447 |
</div>
|
| 448 |
</div>
|
| 449 |
<div className="p-3 space-y-3">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
<div>
|
| 451 |
-
<
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
</label>
|
| 455 |
-
<input
|
| 456 |
-
type="range"
|
| 457 |
min={18}
|
| 458 |
max={100}
|
| 459 |
value={node.targetAge || 30}
|
| 460 |
-
onChange={(e) => onUpdate(node.id, { targetAge: parseInt(e.target.value) })}
|
| 461 |
-
className="w-full"
|
| 462 |
/>
|
| 463 |
</div>
|
| 464 |
-
<
|
| 465 |
-
className="w-full
|
| 466 |
onClick={() => onProcess(node.id)}
|
| 467 |
disabled={node.isRunning}
|
| 468 |
title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
|
| 469 |
>
|
| 470 |
{node.isRunning ? "Processing..." : "Apply Age"}
|
| 471 |
-
</
|
| 472 |
{node.output && (
|
| 473 |
<div className="space-y-2">
|
| 474 |
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 475 |
-
<
|
| 476 |
-
className="w-full
|
|
|
|
| 477 |
onClick={() => downloadImage(node.output, `age-${Date.now()}.png`)}
|
| 478 |
>
|
| 479 |
📥 Download Output
|
| 480 |
-
</
|
| 481 |
</div>
|
| 482 |
)}
|
| 483 |
{node.error && (
|
|
@@ -500,6 +560,7 @@ export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, on
|
|
| 500 |
const lightingTypes = ["None", "Natural Light", "Golden Hour", "Blue Hour", "Studio Lighting", "Rembrandt", "Split Lighting", "Butterfly Lighting", "Loop Lighting", "Rim Lighting", "Silhouette", "High Key", "Low Key"];
|
| 501 |
const bokehStyles = ["None", "Smooth Bokeh", "Swirly Bokeh", "Hexagonal Bokeh", "Cat Eye Bokeh", "Bubble Bokeh", "Creamy Bokeh"];
|
| 502 |
const compositions = ["None", "Rule of Thirds", "Golden Ratio", "Symmetrical", "Leading Lines", "Frame in Frame", "Fill the Frame", "Negative Space", "Patterns", "Diagonal"];
|
|
|
|
| 503 |
|
| 504 |
return (
|
| 505 |
<div className="nb-node absolute text-white w-[360px]" style={{ left: localPos.x, top: localPos.y }}>
|
|
@@ -512,53 +573,74 @@ export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, on
|
|
| 512 |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
|
| 513 |
<div className="font-semibold text-sm flex-1 text-center">CAMERA</div>
|
| 514 |
<div className="flex items-center gap-2">
|
| 515 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 516 |
<Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
|
| 517 |
</div>
|
| 518 |
</div>
|
| 519 |
-
<div className="p-3 space-y-2 max-h-[500px] overflow-y-auto">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 520 |
{/* Basic Camera Settings */}
|
| 521 |
<div className="text-xs text-white/50 font-semibold mb-1">Basic Settings</div>
|
| 522 |
<div className="grid grid-cols-2 gap-2">
|
| 523 |
<div>
|
| 524 |
<label className="text-xs text-white/70">Focal Length</label>
|
| 525 |
-
<
|
| 526 |
-
className="w-full
|
| 527 |
value={node.focalLength || "None"}
|
| 528 |
-
onChange={(e) => onUpdate(node.id, { focalLength: e.target.value })}
|
| 529 |
>
|
| 530 |
{focalLengths.map(f => <option key={f} value={f}>{f}</option>)}
|
| 531 |
-
</
|
| 532 |
</div>
|
| 533 |
<div>
|
| 534 |
<label className="text-xs text-white/70">Aperture</label>
|
| 535 |
-
<
|
| 536 |
-
className="w-full
|
| 537 |
value={node.aperture || "None"}
|
| 538 |
-
onChange={(e) => onUpdate(node.id, { aperture: e.target.value })}
|
| 539 |
>
|
| 540 |
{apertures.map(a => <option key={a} value={a}>{a}</option>)}
|
| 541 |
-
</
|
| 542 |
</div>
|
| 543 |
<div>
|
| 544 |
<label className="text-xs text-white/70">Shutter Speed</label>
|
| 545 |
-
<
|
| 546 |
-
className="w-full
|
| 547 |
value={node.shutterSpeed || "None"}
|
| 548 |
-
onChange={(e) => onUpdate(node.id, { shutterSpeed: e.target.value })}
|
| 549 |
>
|
| 550 |
{shutterSpeeds.map(s => <option key={s} value={s}>{s}</option>)}
|
| 551 |
-
</
|
| 552 |
</div>
|
| 553 |
<div>
|
| 554 |
<label className="text-xs text-white/70">ISO</label>
|
| 555 |
-
<
|
| 556 |
-
className="w-full
|
| 557 |
value={node.iso || "None"}
|
| 558 |
-
onChange={(e) => onUpdate(node.id, { iso: e.target.value })}
|
| 559 |
>
|
| 560 |
{isoValues.map(i => <option key={i} value={i}>{i}</option>)}
|
| 561 |
-
</
|
| 562 |
</div>
|
| 563 |
</div>
|
| 564 |
|
|
@@ -567,43 +649,43 @@ export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, on
|
|
| 567 |
<div className="grid grid-cols-2 gap-2">
|
| 568 |
<div>
|
| 569 |
<label className="text-xs text-white/70">White Balance</label>
|
| 570 |
-
<
|
| 571 |
-
className="w-full
|
| 572 |
value={node.whiteBalance || "None"}
|
| 573 |
-
onChange={(e) => onUpdate(node.id, { whiteBalance: e.target.value })}
|
| 574 |
>
|
| 575 |
{whiteBalances.map(w => <option key={w} value={w}>{w}</option>)}
|
| 576 |
-
</
|
| 577 |
</div>
|
| 578 |
<div>
|
| 579 |
<label className="text-xs text-white/70">Film Style</label>
|
| 580 |
-
<
|
| 581 |
-
className="w-full
|
| 582 |
value={node.filmStyle || "None"}
|
| 583 |
-
onChange={(e) => onUpdate(node.id, { filmStyle: e.target.value })}
|
| 584 |
>
|
| 585 |
{filmStyles.map(f => <option key={f} value={f}>{f}</option>)}
|
| 586 |
-
</
|
| 587 |
</div>
|
| 588 |
<div>
|
| 589 |
<label className="text-xs text-white/70">Lighting</label>
|
| 590 |
-
<
|
| 591 |
-
className="w-full
|
| 592 |
value={node.lighting || "None"}
|
| 593 |
-
onChange={(e) => onUpdate(node.id, { lighting: e.target.value })}
|
| 594 |
>
|
| 595 |
{lightingTypes.map(l => <option key={l} value={l}>{l}</option>)}
|
| 596 |
-
</
|
| 597 |
</div>
|
| 598 |
<div>
|
| 599 |
<label className="text-xs text-white/70">Bokeh Style</label>
|
| 600 |
-
<
|
| 601 |
-
className="w-full
|
| 602 |
value={node.bokeh || "None"}
|
| 603 |
-
onChange={(e) => onUpdate(node.id, { bokeh: e.target.value })}
|
| 604 |
>
|
| 605 |
{bokehStyles.map(b => <option key={b} value={b}>{b}</option>)}
|
| 606 |
-
</
|
| 607 |
</div>
|
| 608 |
</div>
|
| 609 |
|
|
@@ -612,42 +694,53 @@ export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, on
|
|
| 612 |
<div className="grid grid-cols-2 gap-2">
|
| 613 |
<div>
|
| 614 |
<label className="text-xs text-white/70">Camera Angle</label>
|
| 615 |
-
<
|
| 616 |
-
className="w-full
|
| 617 |
value={node.angle || "None"}
|
| 618 |
-
onChange={(e) => onUpdate(node.id, { angle: e.target.value })}
|
| 619 |
>
|
| 620 |
{angles.map(a => <option key={a} value={a}>{a}</option>)}
|
| 621 |
-
</
|
| 622 |
</div>
|
| 623 |
<div>
|
| 624 |
<label className="text-xs text-white/70">Composition</label>
|
| 625 |
-
<
|
| 626 |
-
className="w-full
|
| 627 |
value={node.composition || "None"}
|
| 628 |
-
onChange={(e) => onUpdate(node.id, { composition: e.target.value })}
|
| 629 |
>
|
| 630 |
{compositions.map(c => <option key={c} value={c}>{c}</option>)}
|
| 631 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 632 |
</div>
|
| 633 |
</div>
|
| 634 |
-
<
|
| 635 |
-
className="w-full
|
| 636 |
onClick={() => onProcess(node.id)}
|
| 637 |
disabled={node.isRunning}
|
| 638 |
title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
|
| 639 |
>
|
| 640 |
{node.isRunning ? "Processing..." : "Apply Camera Settings"}
|
| 641 |
-
</
|
| 642 |
{node.output && (
|
| 643 |
<div className="space-y-2 mt-2">
|
| 644 |
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 645 |
-
<
|
| 646 |
-
className="w-full
|
|
|
|
| 647 |
onClick={() => downloadImage(node.output, `camera-${Date.now()}.png`)}
|
| 648 |
>
|
| 649 |
📥 Download Output
|
| 650 |
-
</
|
| 651 |
</div>
|
| 652 |
)}
|
| 653 |
{node.error && (
|
|
@@ -675,38 +768,56 @@ export function FaceNodeView({ node, onDelete, onUpdate, onStartConnection, onEn
|
|
| 675 |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
|
| 676 |
<div className="font-semibold text-sm flex-1 text-center">FACE</div>
|
| 677 |
<div className="flex items-center gap-2">
|
| 678 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 679 |
<Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
|
| 680 |
</div>
|
| 681 |
</div>
|
| 682 |
<div className="p-3 space-y-2">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 683 |
<div className="space-y-2">
|
| 684 |
<label className="flex items-center gap-2 text-xs">
|
| 685 |
-
<
|
| 686 |
-
type="checkbox"
|
| 687 |
checked={node.faceOptions?.removePimples || false}
|
| 688 |
onChange={(e) => onUpdate(node.id, {
|
| 689 |
-
faceOptions: { ...node.faceOptions, removePimples: e.target.checked }
|
| 690 |
})}
|
| 691 |
/>
|
| 692 |
Remove pimples
|
| 693 |
</label>
|
| 694 |
<label className="flex items-center gap-2 text-xs">
|
| 695 |
-
<
|
| 696 |
-
type="checkbox"
|
| 697 |
checked={node.faceOptions?.addSunglasses || false}
|
| 698 |
onChange={(e) => onUpdate(node.id, {
|
| 699 |
-
faceOptions: { ...node.faceOptions, addSunglasses: e.target.checked }
|
| 700 |
})}
|
| 701 |
/>
|
| 702 |
Add sunglasses
|
| 703 |
</label>
|
| 704 |
<label className="flex items-center gap-2 text-xs">
|
| 705 |
-
<
|
| 706 |
-
type="checkbox"
|
| 707 |
checked={node.faceOptions?.addHat || false}
|
| 708 |
onChange={(e) => onUpdate(node.id, {
|
| 709 |
-
faceOptions: { ...node.faceOptions, addHat: e.target.checked }
|
| 710 |
})}
|
| 711 |
/>
|
| 712 |
Add hat
|
|
@@ -715,60 +826,61 @@ export function FaceNodeView({ node, onDelete, onUpdate, onStartConnection, onEn
|
|
| 715 |
|
| 716 |
<div>
|
| 717 |
<label className="text-xs text-white/70">Hairstyle</label>
|
| 718 |
-
<
|
| 719 |
-
className="w-full
|
| 720 |
value={node.faceOptions?.changeHairstyle || "None"}
|
| 721 |
onChange={(e) => onUpdate(node.id, {
|
| 722 |
-
faceOptions: { ...node.faceOptions, changeHairstyle: e.target.value }
|
| 723 |
})}
|
| 724 |
>
|
| 725 |
{hairstyles.map(h => <option key={h} value={h}>{h}</option>)}
|
| 726 |
-
</
|
| 727 |
</div>
|
| 728 |
|
| 729 |
<div>
|
| 730 |
<label className="text-xs text-white/70">Expression</label>
|
| 731 |
-
<
|
| 732 |
-
className="w-full
|
| 733 |
value={node.faceOptions?.facialExpression || "None"}
|
| 734 |
onChange={(e) => onUpdate(node.id, {
|
| 735 |
-
faceOptions: { ...node.faceOptions, facialExpression: e.target.value }
|
| 736 |
})}
|
| 737 |
>
|
| 738 |
{expressions.map(e => <option key={e} value={e}>{e}</option>)}
|
| 739 |
-
</
|
| 740 |
</div>
|
| 741 |
|
| 742 |
<div>
|
| 743 |
<label className="text-xs text-white/70">Beard</label>
|
| 744 |
-
<
|
| 745 |
-
className="w-full
|
| 746 |
value={node.faceOptions?.beardStyle || "None"}
|
| 747 |
onChange={(e) => onUpdate(node.id, {
|
| 748 |
-
faceOptions: { ...node.faceOptions, beardStyle: e.target.value }
|
| 749 |
})}
|
| 750 |
>
|
| 751 |
{beardStyles.map(b => <option key={b} value={b}>{b}</option>)}
|
| 752 |
-
</
|
| 753 |
</div>
|
| 754 |
|
| 755 |
-
<
|
| 756 |
-
className="w-full
|
| 757 |
onClick={() => onProcess(node.id)}
|
| 758 |
disabled={node.isRunning}
|
| 759 |
title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
|
| 760 |
>
|
| 761 |
{node.isRunning ? "Processing..." : "Apply Face Changes"}
|
| 762 |
-
</
|
| 763 |
{node.output && (
|
| 764 |
<div className="space-y-2 mt-2">
|
| 765 |
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 766 |
-
<
|
| 767 |
-
className="w-full
|
|
|
|
| 768 |
onClick={() => downloadImage(node.output, `face-${Date.now()}.png`)}
|
| 769 |
>
|
| 770 |
📥 Download Output
|
| 771 |
-
</
|
| 772 |
</div>
|
| 773 |
)}
|
| 774 |
{node.error && (
|
|
@@ -828,12 +940,34 @@ export function BlendNodeView({ node, onDelete, onUpdate, onStartConnection, onE
|
|
| 828 |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
|
| 829 |
<div className="font-semibold text-sm flex-1 text-center">BLEND</div>
|
| 830 |
<div className="flex items-center gap-2">
|
| 831 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 832 |
<Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
|
| 833 |
</div>
|
| 834 |
</div>
|
| 835 |
<div className="p-3 space-y-3">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 836 |
<div className="text-xs text-white/70">Style Reference Image</div>
|
|
|
|
| 837 |
{node.styleImage ? (
|
| 838 |
<div className="relative">
|
| 839 |
<img src={node.styleImage} className="w-full rounded" alt="Style" />
|
|
@@ -860,40 +994,38 @@ export function BlendNodeView({ node, onDelete, onUpdate, onStartConnection, onE
|
|
| 860 |
/>
|
| 861 |
<div className="border-2 border-dashed border-white/20 rounded-lg p-4 text-center cursor-pointer hover:border-white/40">
|
| 862 |
<p className="text-xs text-white/60">Drop, upload, or paste style image</p>
|
|
|
|
| 863 |
</div>
|
| 864 |
</label>
|
| 865 |
)}
|
| 866 |
<div>
|
| 867 |
-
<
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
</label>
|
| 871 |
-
<input
|
| 872 |
-
type="range"
|
| 873 |
min={0}
|
| 874 |
max={100}
|
| 875 |
value={node.blendStrength || 50}
|
| 876 |
-
onChange={(e) => onUpdate(node.id, { blendStrength: parseInt(e.target.value) })}
|
| 877 |
-
className="w-full"
|
| 878 |
/>
|
| 879 |
</div>
|
| 880 |
-
<
|
| 881 |
-
className="w-full
|
| 882 |
onClick={() => onProcess(node.id)}
|
| 883 |
disabled={node.isRunning || !node.styleImage}
|
| 884 |
-
title={!node.input ? "Connect an input first" : !node.styleImage ? "Add a style image first" : "
|
| 885 |
>
|
| 886 |
-
{node.isRunning ? "
|
| 887 |
-
</
|
| 888 |
{node.output && (
|
| 889 |
<div className="space-y-2">
|
| 890 |
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 891 |
-
<
|
| 892 |
-
className="w-full
|
|
|
|
| 893 |
onClick={() => downloadImage(node.output, `blend-${Date.now()}.png`)}
|
| 894 |
>
|
| 895 |
📥 Download Output
|
| 896 |
-
</
|
| 897 |
</div>
|
| 898 |
)}
|
| 899 |
{node.error && (
|
|
@@ -918,35 +1050,57 @@ export function EditNodeView({ node, onDelete, onUpdate, onStartConnection, onEn
|
|
| 918 |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
|
| 919 |
<div className="font-semibold text-sm flex-1 text-center">EDIT</div>
|
| 920 |
<div className="flex items-center gap-2">
|
| 921 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 922 |
<Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
|
| 923 |
</div>
|
| 924 |
</div>
|
| 925 |
<div className="p-3 space-y-3">
|
| 926 |
-
|
| 927 |
-
className="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 928 |
placeholder="Describe what to edit (e.g., 'make it brighter', 'add more contrast', 'make it look vintage')"
|
| 929 |
value={node.editPrompt || ""}
|
| 930 |
-
onChange={(e) => onUpdate(node.id, { editPrompt: e.target.value })}
|
| 931 |
rows={3}
|
| 932 |
/>
|
| 933 |
-
<
|
| 934 |
-
className="w-full
|
| 935 |
onClick={() => onProcess(node.id)}
|
| 936 |
disabled={node.isRunning}
|
| 937 |
title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
|
| 938 |
>
|
| 939 |
{node.isRunning ? "Processing..." : "Apply Edit"}
|
| 940 |
-
</
|
| 941 |
{node.output && (
|
| 942 |
<div className="space-y-2">
|
| 943 |
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 944 |
-
<
|
| 945 |
-
className="w-full
|
|
|
|
| 946 |
onClick={() => downloadImage(node.output, `edit-${Date.now()}.png`)}
|
| 947 |
>
|
| 948 |
📥 Download Output
|
| 949 |
-
</
|
| 950 |
</div>
|
| 951 |
)}
|
| 952 |
</div>
|
|
|
|
| 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) {
|
|
|
|
| 177 |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
|
| 178 |
<div className="font-semibold text-sm flex-1 text-center">BACKGROUND</div>
|
| 179 |
<div className="flex items-center gap-2">
|
| 180 |
+
<Button
|
| 181 |
+
variant="ghost"
|
| 182 |
+
size="icon"
|
| 183 |
+
className="text-destructive"
|
| 184 |
+
onClick={() => onDelete(node.id)}
|
| 185 |
+
title="Delete node"
|
| 186 |
+
aria-label="Delete node"
|
| 187 |
+
>
|
| 188 |
+
×
|
| 189 |
+
</Button>
|
| 190 |
<Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
|
| 191 |
</div>
|
| 192 |
</div>
|
| 193 |
<div className="p-3 space-y-3">
|
| 194 |
+
<Select
|
| 195 |
+
className="w-full"
|
| 196 |
value={node.backgroundType || "color"}
|
| 197 |
+
onChange={(e) => onUpdate(node.id, { backgroundType: (e.target as HTMLSelectElement).value })}
|
| 198 |
>
|
| 199 |
<option value="color">Solid Color</option>
|
| 200 |
<option value="image">Preset Background</option>
|
| 201 |
<option value="upload">Upload Image</option>
|
| 202 |
<option value="custom">Custom Prompt</option>
|
| 203 |
+
</Select>
|
| 204 |
|
| 205 |
{node.backgroundType === "color" && (
|
| 206 |
+
<ColorPicker
|
| 207 |
+
className="w-full"
|
|
|
|
| 208 |
value={node.backgroundColor || "#ffffff"}
|
| 209 |
+
onChange={(e) => onUpdate(node.id, { backgroundColor: (e.target as HTMLInputElement).value })}
|
| 210 |
/>
|
| 211 |
)}
|
| 212 |
|
| 213 |
{node.backgroundType === "image" && (
|
| 214 |
+
<Select
|
| 215 |
+
className="w-full"
|
| 216 |
value={node.backgroundImage || ""}
|
| 217 |
+
onChange={(e) => onUpdate(node.id, { backgroundImage: (e.target as HTMLSelectElement).value })}
|
| 218 |
>
|
| 219 |
<option value="">Select Background</option>
|
| 220 |
<option value="beach">Beach</option>
|
|
|
|
| 222 |
<option value="studio">Studio</option>
|
| 223 |
<option value="nature">Nature</option>
|
| 224 |
<option value="city">City Skyline</option>
|
| 225 |
+
</Select>
|
| 226 |
)}
|
| 227 |
|
| 228 |
{node.backgroundType === "upload" && (
|
|
|
|
| 230 |
{node.customBackgroundImage ? (
|
| 231 |
<div className="relative">
|
| 232 |
<img src={node.customBackgroundImage} className="w-full rounded" alt="Custom Background" />
|
| 233 |
+
<Button
|
| 234 |
+
variant="destructive"
|
| 235 |
+
size="sm"
|
| 236 |
+
className="absolute top-2 right-2"
|
| 237 |
onClick={() => onUpdate(node.id, { customBackgroundImage: null })}
|
| 238 |
>
|
| 239 |
Remove
|
| 240 |
+
</Button>
|
| 241 |
</div>
|
| 242 |
) : (
|
| 243 |
<label className="block">
|
|
|
|
| 257 |
)}
|
| 258 |
|
| 259 |
{node.backgroundType === "custom" && (
|
| 260 |
+
<Textarea
|
| 261 |
+
className="w-full"
|
| 262 |
placeholder="Describe the background..."
|
| 263 |
value={node.customPrompt || ""}
|
| 264 |
+
onChange={(e) => onUpdate(node.id, { customPrompt: (e.target as HTMLTextAreaElement).value })}
|
| 265 |
rows={2}
|
| 266 |
/>
|
| 267 |
)}
|
| 268 |
|
| 269 |
+
<Button
|
| 270 |
+
className="w-full"
|
| 271 |
onClick={() => onProcess(node.id)}
|
| 272 |
disabled={node.isRunning}
|
| 273 |
title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
|
| 274 |
>
|
| 275 |
{node.isRunning ? "Processing..." : "Apply Background"}
|
| 276 |
+
</Button>
|
| 277 |
|
| 278 |
{node.output && (
|
| 279 |
<div className="space-y-2">
|
| 280 |
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 281 |
+
<Button
|
| 282 |
+
className="w-full"
|
| 283 |
+
variant="secondary"
|
| 284 |
onClick={() => downloadImage(node.output, `background-${Date.now()}.png`)}
|
| 285 |
>
|
| 286 |
📥 Download Output
|
| 287 |
+
</Button>
|
| 288 |
</div>
|
| 289 |
)}
|
| 290 |
{node.error && (
|
|
|
|
| 355 |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
|
| 356 |
<div className="font-semibold text-sm flex-1 text-center">CLOTHES</div>
|
| 357 |
<div className="flex items-center gap-2">
|
| 358 |
+
<Button
|
| 359 |
+
variant="ghost"
|
| 360 |
+
size="icon"
|
| 361 |
+
className="text-destructive"
|
| 362 |
+
onClick={() => onDelete(node.id)}
|
| 363 |
+
title="Delete node"
|
| 364 |
+
aria-label="Delete node"
|
| 365 |
+
>
|
| 366 |
+
×
|
| 367 |
+
</Button>
|
| 368 |
<Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
|
| 369 |
</div>
|
| 370 |
</div>
|
| 371 |
<div className="p-3 space-y-3">
|
| 372 |
+
{node.input && (
|
| 373 |
+
<div className="flex justify-end mb-2">
|
| 374 |
+
<Button
|
| 375 |
+
variant="ghost"
|
| 376 |
+
size="sm"
|
| 377 |
+
onClick={() => onUpdate(node.id, { input: undefined })}
|
| 378 |
+
className="text-xs"
|
| 379 |
+
>
|
| 380 |
+
Clear Connection
|
| 381 |
+
</Button>
|
| 382 |
+
</div>
|
| 383 |
+
)}
|
| 384 |
{hasConfig && (
|
| 385 |
<div className="text-xs bg-yellow-500/20 border border-yellow-500/50 rounded px-2 py-1 text-yellow-300">
|
| 386 |
⚡ Config pending - will apply when downstream node processes
|
|
|
|
| 412 |
{node.clothesImage && !node.selectedPreset ? (
|
| 413 |
<div className="relative">
|
| 414 |
<img src={node.clothesImage} className="w-full rounded" alt="Clothes" />
|
| 415 |
+
<Button
|
| 416 |
+
variant="destructive"
|
| 417 |
+
size="sm"
|
| 418 |
+
className="absolute top-2 right-2"
|
| 419 |
onClick={() => onUpdate(node.id, { clothesImage: null, selectedPreset: null })}
|
| 420 |
>
|
| 421 |
Remove
|
| 422 |
+
</Button>
|
| 423 |
</div>
|
| 424 |
) : !node.selectedPreset ? (
|
| 425 |
<label className="block">
|
|
|
|
| 441 |
</label>
|
| 442 |
) : null}
|
| 443 |
|
| 444 |
+
<Button
|
| 445 |
+
className="w-full"
|
| 446 |
onClick={() => onProcess(node.id)}
|
| 447 |
disabled={node.isRunning || !node.clothesImage}
|
| 448 |
title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
|
| 449 |
>
|
| 450 |
{node.isRunning ? "Processing..." : "Apply Clothes"}
|
| 451 |
+
</Button>
|
| 452 |
{node.output && (
|
| 453 |
<div className="space-y-2">
|
| 454 |
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 455 |
+
<Button
|
| 456 |
+
className="w-full"
|
| 457 |
+
variant="secondary"
|
| 458 |
onClick={() => downloadImage(node.output, `clothes-${Date.now()}.png`)}
|
| 459 |
>
|
| 460 |
📥 Download Output
|
| 461 |
+
</Button>
|
| 462 |
</div>
|
| 463 |
)}
|
| 464 |
{node.error && (
|
|
|
|
| 484 |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
|
| 485 |
<div className="font-semibold text-sm flex-1 text-center">AGE</div>
|
| 486 |
<div className="flex items-center gap-2">
|
| 487 |
+
<Button
|
| 488 |
+
variant="ghost"
|
| 489 |
+
size="icon"
|
| 490 |
+
className="text-destructive"
|
| 491 |
+
onClick={() => onDelete(node.id)}
|
| 492 |
+
title="Delete node"
|
| 493 |
+
aria-label="Delete node"
|
| 494 |
+
>
|
| 495 |
+
×
|
| 496 |
+
</Button>
|
| 497 |
<Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
|
| 498 |
</div>
|
| 499 |
</div>
|
| 500 |
<div className="p-3 space-y-3">
|
| 501 |
+
{node.input && (
|
| 502 |
+
<div className="flex justify-end mb-2">
|
| 503 |
+
<Button
|
| 504 |
+
variant="ghost"
|
| 505 |
+
size="sm"
|
| 506 |
+
onClick={() => onUpdate(node.id, { input: undefined })}
|
| 507 |
+
className="text-xs"
|
| 508 |
+
>
|
| 509 |
+
Clear Connection
|
| 510 |
+
</Button>
|
| 511 |
+
</div>
|
| 512 |
+
)}
|
| 513 |
<div>
|
| 514 |
+
<Slider
|
| 515 |
+
label="Target Age"
|
| 516 |
+
valueLabel={`${node.targetAge || 30} years`}
|
|
|
|
|
|
|
|
|
|
| 517 |
min={18}
|
| 518 |
max={100}
|
| 519 |
value={node.targetAge || 30}
|
| 520 |
+
onChange={(e) => onUpdate(node.id, { targetAge: parseInt((e.target as HTMLInputElement).value) })}
|
|
|
|
| 521 |
/>
|
| 522 |
</div>
|
| 523 |
+
<Button
|
| 524 |
+
className="w-full"
|
| 525 |
onClick={() => onProcess(node.id)}
|
| 526 |
disabled={node.isRunning}
|
| 527 |
title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
|
| 528 |
>
|
| 529 |
{node.isRunning ? "Processing..." : "Apply Age"}
|
| 530 |
+
</Button>
|
| 531 |
{node.output && (
|
| 532 |
<div className="space-y-2">
|
| 533 |
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 534 |
+
<Button
|
| 535 |
+
className="w-full"
|
| 536 |
+
variant="secondary"
|
| 537 |
onClick={() => downloadImage(node.output, `age-${Date.now()}.png`)}
|
| 538 |
>
|
| 539 |
📥 Download Output
|
| 540 |
+
</Button>
|
| 541 |
</div>
|
| 542 |
)}
|
| 543 |
{node.error && (
|
|
|
|
| 560 |
const lightingTypes = ["None", "Natural Light", "Golden Hour", "Blue Hour", "Studio Lighting", "Rembrandt", "Split Lighting", "Butterfly Lighting", "Loop Lighting", "Rim Lighting", "Silhouette", "High Key", "Low Key"];
|
| 561 |
const bokehStyles = ["None", "Smooth Bokeh", "Swirly Bokeh", "Hexagonal Bokeh", "Cat Eye Bokeh", "Bubble Bokeh", "Creamy Bokeh"];
|
| 562 |
const compositions = ["None", "Rule of Thirds", "Golden Ratio", "Symmetrical", "Leading Lines", "Frame in Frame", "Fill the Frame", "Negative Space", "Patterns", "Diagonal"];
|
| 563 |
+
const aspectRatios = ["None", "1:1 Square", "3:2 Standard", "4:3 Classic", "16:9 Widescreen", "21:9 Cinematic", "9:16 Portrait", "4:5 Instagram", "2:3 Portrait"];
|
| 564 |
|
| 565 |
return (
|
| 566 |
<div className="nb-node absolute text-white w-[360px]" style={{ left: localPos.x, top: localPos.y }}>
|
|
|
|
| 573 |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
|
| 574 |
<div className="font-semibold text-sm flex-1 text-center">CAMERA</div>
|
| 575 |
<div className="flex items-center gap-2">
|
| 576 |
+
<Button
|
| 577 |
+
variant="ghost"
|
| 578 |
+
size="icon"
|
| 579 |
+
className="text-destructive"
|
| 580 |
+
onClick={() => onDelete(node.id)}
|
| 581 |
+
title="Delete node"
|
| 582 |
+
aria-label="Delete node"
|
| 583 |
+
>
|
| 584 |
+
×
|
| 585 |
+
</Button>
|
| 586 |
<Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
|
| 587 |
</div>
|
| 588 |
</div>
|
| 589 |
+
<div className="p-3 space-y-2 max-h-[500px] overflow-y-auto scrollbar-thin">
|
| 590 |
+
{node.input && (
|
| 591 |
+
<div className="flex justify-end mb-2">
|
| 592 |
+
<Button
|
| 593 |
+
variant="ghost"
|
| 594 |
+
size="sm"
|
| 595 |
+
onClick={() => onUpdate(node.id, { input: undefined })}
|
| 596 |
+
className="text-xs"
|
| 597 |
+
>
|
| 598 |
+
Clear Connection
|
| 599 |
+
</Button>
|
| 600 |
+
</div>
|
| 601 |
+
)}
|
| 602 |
{/* Basic Camera Settings */}
|
| 603 |
<div className="text-xs text-white/50 font-semibold mb-1">Basic Settings</div>
|
| 604 |
<div className="grid grid-cols-2 gap-2">
|
| 605 |
<div>
|
| 606 |
<label className="text-xs text-white/70">Focal Length</label>
|
| 607 |
+
<Select
|
| 608 |
+
className="w-full"
|
| 609 |
value={node.focalLength || "None"}
|
| 610 |
+
onChange={(e) => onUpdate(node.id, { focalLength: (e.target as HTMLSelectElement).value })}
|
| 611 |
>
|
| 612 |
{focalLengths.map(f => <option key={f} value={f}>{f}</option>)}
|
| 613 |
+
</Select>
|
| 614 |
</div>
|
| 615 |
<div>
|
| 616 |
<label className="text-xs text-white/70">Aperture</label>
|
| 617 |
+
<Select
|
| 618 |
+
className="w-full"
|
| 619 |
value={node.aperture || "None"}
|
| 620 |
+
onChange={(e) => onUpdate(node.id, { aperture: (e.target as HTMLSelectElement).value })}
|
| 621 |
>
|
| 622 |
{apertures.map(a => <option key={a} value={a}>{a}</option>)}
|
| 623 |
+
</Select>
|
| 624 |
</div>
|
| 625 |
<div>
|
| 626 |
<label className="text-xs text-white/70">Shutter Speed</label>
|
| 627 |
+
<Select
|
| 628 |
+
className="w-full"
|
| 629 |
value={node.shutterSpeed || "None"}
|
| 630 |
+
onChange={(e) => onUpdate(node.id, { shutterSpeed: (e.target as HTMLSelectElement).value })}
|
| 631 |
>
|
| 632 |
{shutterSpeeds.map(s => <option key={s} value={s}>{s}</option>)}
|
| 633 |
+
</Select>
|
| 634 |
</div>
|
| 635 |
<div>
|
| 636 |
<label className="text-xs text-white/70">ISO</label>
|
| 637 |
+
<Select
|
| 638 |
+
className="w-full"
|
| 639 |
value={node.iso || "None"}
|
| 640 |
+
onChange={(e) => onUpdate(node.id, { iso: (e.target as HTMLSelectElement).value })}
|
| 641 |
>
|
| 642 |
{isoValues.map(i => <option key={i} value={i}>{i}</option>)}
|
| 643 |
+
</Select>
|
| 644 |
</div>
|
| 645 |
</div>
|
| 646 |
|
|
|
|
| 649 |
<div className="grid grid-cols-2 gap-2">
|
| 650 |
<div>
|
| 651 |
<label className="text-xs text-white/70">White Balance</label>
|
| 652 |
+
<Select
|
| 653 |
+
className="w-full"
|
| 654 |
value={node.whiteBalance || "None"}
|
| 655 |
+
onChange={(e) => onUpdate(node.id, { whiteBalance: (e.target as HTMLSelectElement).value })}
|
| 656 |
>
|
| 657 |
{whiteBalances.map(w => <option key={w} value={w}>{w}</option>)}
|
| 658 |
+
</Select>
|
| 659 |
</div>
|
| 660 |
<div>
|
| 661 |
<label className="text-xs text-white/70">Film Style</label>
|
| 662 |
+
<Select
|
| 663 |
+
className="w-full"
|
| 664 |
value={node.filmStyle || "None"}
|
| 665 |
+
onChange={(e) => onUpdate(node.id, { filmStyle: (e.target as HTMLSelectElement).value })}
|
| 666 |
>
|
| 667 |
{filmStyles.map(f => <option key={f} value={f}>{f}</option>)}
|
| 668 |
+
</Select>
|
| 669 |
</div>
|
| 670 |
<div>
|
| 671 |
<label className="text-xs text-white/70">Lighting</label>
|
| 672 |
+
<Select
|
| 673 |
+
className="w-full"
|
| 674 |
value={node.lighting || "None"}
|
| 675 |
+
onChange={(e) => onUpdate(node.id, { lighting: (e.target as HTMLSelectElement).value })}
|
| 676 |
>
|
| 677 |
{lightingTypes.map(l => <option key={l} value={l}>{l}</option>)}
|
| 678 |
+
</Select>
|
| 679 |
</div>
|
| 680 |
<div>
|
| 681 |
<label className="text-xs text-white/70">Bokeh Style</label>
|
| 682 |
+
<Select
|
| 683 |
+
className="w-full"
|
| 684 |
value={node.bokeh || "None"}
|
| 685 |
+
onChange={(e) => onUpdate(node.id, { bokeh: (e.target as HTMLSelectElement).value })}
|
| 686 |
>
|
| 687 |
{bokehStyles.map(b => <option key={b} value={b}>{b}</option>)}
|
| 688 |
+
</Select>
|
| 689 |
</div>
|
| 690 |
</div>
|
| 691 |
|
|
|
|
| 694 |
<div className="grid grid-cols-2 gap-2">
|
| 695 |
<div>
|
| 696 |
<label className="text-xs text-white/70">Camera Angle</label>
|
| 697 |
+
<Select
|
| 698 |
+
className="w-full"
|
| 699 |
value={node.angle || "None"}
|
| 700 |
+
onChange={(e) => onUpdate(node.id, { angle: (e.target as HTMLSelectElement).value })}
|
| 701 |
>
|
| 702 |
{angles.map(a => <option key={a} value={a}>{a}</option>)}
|
| 703 |
+
</Select>
|
| 704 |
</div>
|
| 705 |
<div>
|
| 706 |
<label className="text-xs text-white/70">Composition</label>
|
| 707 |
+
<Select
|
| 708 |
+
className="w-full"
|
| 709 |
value={node.composition || "None"}
|
| 710 |
+
onChange={(e) => onUpdate(node.id, { composition: (e.target as HTMLSelectElement).value })}
|
| 711 |
>
|
| 712 |
{compositions.map(c => <option key={c} value={c}>{c}</option>)}
|
| 713 |
+
</Select>
|
| 714 |
+
</div>
|
| 715 |
+
<div>
|
| 716 |
+
<label className="text-xs text-white/70">Aspect Ratio</label>
|
| 717 |
+
<Select
|
| 718 |
+
className="w-full"
|
| 719 |
+
value={node.aspectRatio || "None"}
|
| 720 |
+
onChange={(e) => onUpdate(node.id, { aspectRatio: (e.target as HTMLSelectElement).value })}
|
| 721 |
+
>
|
| 722 |
+
{aspectRatios.map(a => <option key={a} value={a}>{a}</option>)}
|
| 723 |
+
</Select>
|
| 724 |
</div>
|
| 725 |
</div>
|
| 726 |
+
<Button
|
| 727 |
+
className="w-full"
|
| 728 |
onClick={() => onProcess(node.id)}
|
| 729 |
disabled={node.isRunning}
|
| 730 |
title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
|
| 731 |
>
|
| 732 |
{node.isRunning ? "Processing..." : "Apply Camera Settings"}
|
| 733 |
+
</Button>
|
| 734 |
{node.output && (
|
| 735 |
<div className="space-y-2 mt-2">
|
| 736 |
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 737 |
+
<Button
|
| 738 |
+
className="w-full"
|
| 739 |
+
variant="secondary"
|
| 740 |
onClick={() => downloadImage(node.output, `camera-${Date.now()}.png`)}
|
| 741 |
>
|
| 742 |
📥 Download Output
|
| 743 |
+
</Button>
|
| 744 |
</div>
|
| 745 |
)}
|
| 746 |
{node.error && (
|
|
|
|
| 768 |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
|
| 769 |
<div className="font-semibold text-sm flex-1 text-center">FACE</div>
|
| 770 |
<div className="flex items-center gap-2">
|
| 771 |
+
<Button
|
| 772 |
+
variant="ghost"
|
| 773 |
+
size="icon"
|
| 774 |
+
className="text-destructive"
|
| 775 |
+
onClick={() => onDelete(node.id)}
|
| 776 |
+
title="Delete node"
|
| 777 |
+
aria-label="Delete node"
|
| 778 |
+
>
|
| 779 |
+
×
|
| 780 |
+
</Button>
|
| 781 |
<Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
|
| 782 |
</div>
|
| 783 |
</div>
|
| 784 |
<div className="p-3 space-y-2">
|
| 785 |
+
{node.input && (
|
| 786 |
+
<div className="flex justify-end mb-2">
|
| 787 |
+
<Button
|
| 788 |
+
variant="ghost"
|
| 789 |
+
size="sm"
|
| 790 |
+
onClick={() => onUpdate(node.id, { input: undefined })}
|
| 791 |
+
className="text-xs"
|
| 792 |
+
>
|
| 793 |
+
Clear Connection
|
| 794 |
+
</Button>
|
| 795 |
+
</div>
|
| 796 |
+
)}
|
| 797 |
<div className="space-y-2">
|
| 798 |
<label className="flex items-center gap-2 text-xs">
|
| 799 |
+
<Checkbox
|
|
|
|
| 800 |
checked={node.faceOptions?.removePimples || false}
|
| 801 |
onChange={(e) => onUpdate(node.id, {
|
| 802 |
+
faceOptions: { ...node.faceOptions, removePimples: (e.target as HTMLInputElement).checked }
|
| 803 |
})}
|
| 804 |
/>
|
| 805 |
Remove pimples
|
| 806 |
</label>
|
| 807 |
<label className="flex items-center gap-2 text-xs">
|
| 808 |
+
<Checkbox
|
|
|
|
| 809 |
checked={node.faceOptions?.addSunglasses || false}
|
| 810 |
onChange={(e) => onUpdate(node.id, {
|
| 811 |
+
faceOptions: { ...node.faceOptions, addSunglasses: (e.target as HTMLInputElement).checked }
|
| 812 |
})}
|
| 813 |
/>
|
| 814 |
Add sunglasses
|
| 815 |
</label>
|
| 816 |
<label className="flex items-center gap-2 text-xs">
|
| 817 |
+
<Checkbox
|
|
|
|
| 818 |
checked={node.faceOptions?.addHat || false}
|
| 819 |
onChange={(e) => onUpdate(node.id, {
|
| 820 |
+
faceOptions: { ...node.faceOptions, addHat: (e.target as HTMLInputElement).checked }
|
| 821 |
})}
|
| 822 |
/>
|
| 823 |
Add hat
|
|
|
|
| 826 |
|
| 827 |
<div>
|
| 828 |
<label className="text-xs text-white/70">Hairstyle</label>
|
| 829 |
+
<Select
|
| 830 |
+
className="w-full"
|
| 831 |
value={node.faceOptions?.changeHairstyle || "None"}
|
| 832 |
onChange={(e) => onUpdate(node.id, {
|
| 833 |
+
faceOptions: { ...node.faceOptions, changeHairstyle: (e.target as HTMLSelectElement).value }
|
| 834 |
})}
|
| 835 |
>
|
| 836 |
{hairstyles.map(h => <option key={h} value={h}>{h}</option>)}
|
| 837 |
+
</Select>
|
| 838 |
</div>
|
| 839 |
|
| 840 |
<div>
|
| 841 |
<label className="text-xs text-white/70">Expression</label>
|
| 842 |
+
<Select
|
| 843 |
+
className="w-full"
|
| 844 |
value={node.faceOptions?.facialExpression || "None"}
|
| 845 |
onChange={(e) => onUpdate(node.id, {
|
| 846 |
+
faceOptions: { ...node.faceOptions, facialExpression: (e.target as HTMLSelectElement).value }
|
| 847 |
})}
|
| 848 |
>
|
| 849 |
{expressions.map(e => <option key={e} value={e}>{e}</option>)}
|
| 850 |
+
</Select>
|
| 851 |
</div>
|
| 852 |
|
| 853 |
<div>
|
| 854 |
<label className="text-xs text-white/70">Beard</label>
|
| 855 |
+
<Select
|
| 856 |
+
className="w-full"
|
| 857 |
value={node.faceOptions?.beardStyle || "None"}
|
| 858 |
onChange={(e) => onUpdate(node.id, {
|
| 859 |
+
faceOptions: { ...node.faceOptions, beardStyle: (e.target as HTMLSelectElement).value }
|
| 860 |
})}
|
| 861 |
>
|
| 862 |
{beardStyles.map(b => <option key={b} value={b}>{b}</option>)}
|
| 863 |
+
</Select>
|
| 864 |
</div>
|
| 865 |
|
| 866 |
+
<Button
|
| 867 |
+
className="w-full"
|
| 868 |
onClick={() => onProcess(node.id)}
|
| 869 |
disabled={node.isRunning}
|
| 870 |
title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
|
| 871 |
>
|
| 872 |
{node.isRunning ? "Processing..." : "Apply Face Changes"}
|
| 873 |
+
</Button>
|
| 874 |
{node.output && (
|
| 875 |
<div className="space-y-2 mt-2">
|
| 876 |
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 877 |
+
<Button
|
| 878 |
+
className="w-full"
|
| 879 |
+
variant="secondary"
|
| 880 |
onClick={() => downloadImage(node.output, `face-${Date.now()}.png`)}
|
| 881 |
>
|
| 882 |
📥 Download Output
|
| 883 |
+
</Button>
|
| 884 |
</div>
|
| 885 |
)}
|
| 886 |
{node.error && (
|
|
|
|
| 940 |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
|
| 941 |
<div className="font-semibold text-sm flex-1 text-center">BLEND</div>
|
| 942 |
<div className="flex items-center gap-2">
|
| 943 |
+
<Button
|
| 944 |
+
variant="ghost"
|
| 945 |
+
size="icon"
|
| 946 |
+
className="text-destructive"
|
| 947 |
+
onClick={() => onDelete(node.id)}
|
| 948 |
+
title="Delete node"
|
| 949 |
+
aria-label="Delete node"
|
| 950 |
+
>
|
| 951 |
+
×
|
| 952 |
+
</Button>
|
| 953 |
<Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
|
| 954 |
</div>
|
| 955 |
</div>
|
| 956 |
<div className="p-3 space-y-3">
|
| 957 |
+
{node.input && (
|
| 958 |
+
<div className="flex justify-end mb-2">
|
| 959 |
+
<Button
|
| 960 |
+
variant="ghost"
|
| 961 |
+
size="sm"
|
| 962 |
+
onClick={() => onUpdate(node.id, { input: undefined })}
|
| 963 |
+
className="text-xs"
|
| 964 |
+
>
|
| 965 |
+
Clear Connection
|
| 966 |
+
</Button>
|
| 967 |
+
</div>
|
| 968 |
+
)}
|
| 969 |
<div className="text-xs text-white/70">Style Reference Image</div>
|
| 970 |
+
<div className="text-xs text-white/50">Upload an artistic style image to blend with your input</div>
|
| 971 |
{node.styleImage ? (
|
| 972 |
<div className="relative">
|
| 973 |
<img src={node.styleImage} className="w-full rounded" alt="Style" />
|
|
|
|
| 994 |
/>
|
| 995 |
<div className="border-2 border-dashed border-white/20 rounded-lg p-4 text-center cursor-pointer hover:border-white/40">
|
| 996 |
<p className="text-xs text-white/60">Drop, upload, or paste style image</p>
|
| 997 |
+
<p className="text-xs text-white/40 mt-1">Art style will be applied to input</p>
|
| 998 |
</div>
|
| 999 |
</label>
|
| 1000 |
)}
|
| 1001 |
<div>
|
| 1002 |
+
<Slider
|
| 1003 |
+
label="Blend Strength"
|
| 1004 |
+
valueLabel={`${node.blendStrength || 50}%`}
|
|
|
|
|
|
|
|
|
|
| 1005 |
min={0}
|
| 1006 |
max={100}
|
| 1007 |
value={node.blendStrength || 50}
|
| 1008 |
+
onChange={(e) => onUpdate(node.id, { blendStrength: parseInt((e.target as HTMLInputElement).value) })}
|
|
|
|
| 1009 |
/>
|
| 1010 |
</div>
|
| 1011 |
+
<Button
|
| 1012 |
+
className="w-full"
|
| 1013 |
onClick={() => onProcess(node.id)}
|
| 1014 |
disabled={node.isRunning || !node.styleImage}
|
| 1015 |
+
title={!node.input ? "Connect an input first" : !node.styleImage ? "Add a style image first" : "Blend the style with your input image"}
|
| 1016 |
>
|
| 1017 |
+
{node.isRunning ? "Blending..." : "Blend Style Transfer"}
|
| 1018 |
+
</Button>
|
| 1019 |
{node.output && (
|
| 1020 |
<div className="space-y-2">
|
| 1021 |
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 1022 |
+
<Button
|
| 1023 |
+
className="w-full"
|
| 1024 |
+
variant="secondary"
|
| 1025 |
onClick={() => downloadImage(node.output, `blend-${Date.now()}.png`)}
|
| 1026 |
>
|
| 1027 |
📥 Download Output
|
| 1028 |
+
</Button>
|
| 1029 |
</div>
|
| 1030 |
)}
|
| 1031 |
{node.error && (
|
|
|
|
| 1050 |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
|
| 1051 |
<div className="font-semibold text-sm flex-1 text-center">EDIT</div>
|
| 1052 |
<div className="flex items-center gap-2">
|
| 1053 |
+
<Button
|
| 1054 |
+
variant="ghost"
|
| 1055 |
+
size="icon"
|
| 1056 |
+
className="text-destructive"
|
| 1057 |
+
onClick={() => onDelete(node.id)}
|
| 1058 |
+
title="Delete node"
|
| 1059 |
+
aria-label="Delete node"
|
| 1060 |
+
>
|
| 1061 |
+
×
|
| 1062 |
+
</Button>
|
| 1063 |
<Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
|
| 1064 |
</div>
|
| 1065 |
</div>
|
| 1066 |
<div className="p-3 space-y-3">
|
| 1067 |
+
{node.input && (
|
| 1068 |
+
<div className="flex justify-end mb-2">
|
| 1069 |
+
<Button
|
| 1070 |
+
variant="ghost"
|
| 1071 |
+
size="sm"
|
| 1072 |
+
onClick={() => onUpdate(node.id, { input: undefined })}
|
| 1073 |
+
className="text-xs"
|
| 1074 |
+
>
|
| 1075 |
+
Clear Connection
|
| 1076 |
+
</Button>
|
| 1077 |
+
</div>
|
| 1078 |
+
)}
|
| 1079 |
+
<Textarea
|
| 1080 |
+
className="w-full"
|
| 1081 |
placeholder="Describe what to edit (e.g., 'make it brighter', 'add more contrast', 'make it look vintage')"
|
| 1082 |
value={node.editPrompt || ""}
|
| 1083 |
+
onChange={(e) => onUpdate(node.id, { editPrompt: (e.target as HTMLTextAreaElement).value })}
|
| 1084 |
rows={3}
|
| 1085 |
/>
|
| 1086 |
+
<Button
|
| 1087 |
+
className="w-full"
|
| 1088 |
onClick={() => onProcess(node.id)}
|
| 1089 |
disabled={node.isRunning}
|
| 1090 |
title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"}
|
| 1091 |
>
|
| 1092 |
{node.isRunning ? "Processing..." : "Apply Edit"}
|
| 1093 |
+
</Button>
|
| 1094 |
{node.output && (
|
| 1095 |
<div className="space-y-2">
|
| 1096 |
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 1097 |
+
<Button
|
| 1098 |
+
className="w-full"
|
| 1099 |
+
variant="secondary"
|
| 1100 |
onClick={() => downloadImage(node.output, `edit-${Date.now()}.png`)}
|
| 1101 |
>
|
| 1102 |
📥 Download Output
|
| 1103 |
+
</Button>
|
| 1104 |
</div>
|
| 1105 |
)}
|
| 1106 |
</div>
|
app/editor/page.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
| 10 |
AgeNodeView,
|
| 11 |
FaceNodeView
|
| 12 |
} from "./nodes";
|
|
|
|
| 13 |
|
| 14 |
function cx(...args: Array<string | false | null | undefined>) {
|
| 15 |
return args.filter(Boolean).join(" ");
|
|
@@ -22,19 +23,27 @@ const uid = () => Math.random().toString(36).slice(2, 9);
|
|
| 22 |
function generateMergePrompt(characterData: { image: string; label: string }[]): string {
|
| 23 |
const count = characterData.length;
|
| 24 |
|
| 25 |
-
|
| 26 |
-
return `You are provided with 2 images. Each image may contain one or more people. Create a single new photorealistic image that combines ALL people from BOTH images into one scene. If image 1 has multiple people, include all of them. If image 2 has multiple people, include all of them. Place everyone together in the same scene, standing side by side or in a natural group arrangement. Ensure all people are clearly visible with consistent lighting, proper sizing, and natural shadows. The result should look like a genuine group photo.`;
|
| 27 |
-
}
|
| 28 |
|
| 29 |
-
return `You are provided with ${count}
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
}
|
| 39 |
|
| 40 |
// Types
|
|
@@ -56,7 +65,7 @@ type CharacterNode = NodeBase & {
|
|
| 56 |
type MergeNode = NodeBase & {
|
| 57 |
type: "MERGE";
|
| 58 |
inputs: string[]; // node ids
|
| 59 |
-
output?: string; // data URL from merge
|
| 60 |
isRunning?: boolean;
|
| 61 |
error?: string | null;
|
| 62 |
};
|
|
@@ -119,6 +128,7 @@ type CameraNode = NodeBase & {
|
|
| 119 |
lighting?: string;
|
| 120 |
bokeh?: string;
|
| 121 |
composition?: string;
|
|
|
|
| 122 |
isRunning?: boolean;
|
| 123 |
error?: string | null;
|
| 124 |
};
|
|
@@ -333,18 +343,19 @@ function CharacterNodeView({
|
|
| 333 |
onChange={(e) => onChangeLabel(node.id, e.target.value)}
|
| 334 |
/>
|
| 335 |
<div className="flex items-center gap-2">
|
| 336 |
-
<
|
| 337 |
-
|
| 338 |
onClick={(e) => {
|
| 339 |
e.stopPropagation();
|
| 340 |
-
if (confirm(
|
| 341 |
onDelete(node.id);
|
| 342 |
}
|
| 343 |
}}
|
| 344 |
title="Delete node"
|
|
|
|
| 345 |
>
|
| 346 |
×
|
| 347 |
-
</
|
| 348 |
<Port
|
| 349 |
className="out"
|
| 350 |
nodeId={node.id}
|
|
@@ -354,11 +365,11 @@ function CharacterNodeView({
|
|
| 354 |
</div>
|
| 355 |
</div>
|
| 356 |
<div className="p-3 space-y-3">
|
| 357 |
-
<div className="aspect-[4/5] w-full
|
| 358 |
<img
|
| 359 |
src={node.image}
|
| 360 |
alt="character"
|
| 361 |
-
className="h-full w-full object-
|
| 362 |
draggable={false}
|
| 363 |
/>
|
| 364 |
</div>
|
|
@@ -433,7 +444,7 @@ function MergeNodeView({
|
|
| 433 |
|
| 434 |
|
| 435 |
return (
|
| 436 |
-
<div className="nb-node absolute text-white w-[
|
| 437 |
<div
|
| 438 |
className="nb-header cursor-grab active:cursor-grabbing rounded-t-[14px] px-3 py-2 flex items-center justify-between"
|
| 439 |
onPointerDown={onPointerDown}
|
|
@@ -476,8 +487,8 @@ function MergeNodeView({
|
|
| 476 |
if (!c) return null;
|
| 477 |
return (
|
| 478 |
<div key={id} className="flex items-center gap-2 bg-white/10 rounded px-2 py-1">
|
| 479 |
-
<div className="w-6 h-6 rounded overflow-hidden">
|
| 480 |
-
<img src={c.image} className="w-full h-full object-
|
| 481 |
</div>
|
| 482 |
<span className="text-xs">{c.label || `Character ${id.slice(-3)}`}</span>
|
| 483 |
<button
|
|
@@ -495,35 +506,37 @@ function MergeNodeView({
|
|
| 495 |
)}
|
| 496 |
<div className="flex items-center gap-2">
|
| 497 |
{node.inputs.length > 0 && (
|
| 498 |
-
<
|
| 499 |
-
|
|
|
|
| 500 |
onClick={() => onClearConnections(node.id)}
|
| 501 |
title="Clear all connections"
|
| 502 |
>
|
| 503 |
Clear
|
| 504 |
-
</
|
| 505 |
)}
|
| 506 |
-
<
|
| 507 |
-
|
| 508 |
onClick={() => onRun(node.id)}
|
| 509 |
disabled={node.isRunning || node.inputs.length < 2}
|
| 510 |
>
|
| 511 |
{node.isRunning ? "Merging…" : "Merge"}
|
| 512 |
-
</
|
| 513 |
</div>
|
| 514 |
|
| 515 |
<div className="mt-2">
|
| 516 |
<div className="text-xs text-white/70 mb-1">Output</div>
|
| 517 |
-
<div className="
|
| 518 |
{node.output ? (
|
| 519 |
-
<img src={node.output} className="w-full h-
|
| 520 |
) : (
|
| 521 |
-
<span className="text-white/40 text-xs">Run merge to see result</span>
|
| 522 |
)}
|
| 523 |
</div>
|
| 524 |
{node.output && (
|
| 525 |
-
<
|
| 526 |
-
className="w-full
|
|
|
|
| 527 |
onClick={() => {
|
| 528 |
const link = document.createElement('a');
|
| 529 |
link.href = node.output as string;
|
|
@@ -534,7 +547,7 @@ function MergeNodeView({
|
|
| 534 |
}}
|
| 535 |
>
|
| 536 |
📥 Download Merged Image
|
| 537 |
-
</
|
| 538 |
)}
|
| 539 |
{node.error && (
|
| 540 |
<div className="mt-2">
|
|
@@ -758,6 +771,7 @@ export default function EditorPage() {
|
|
| 758 |
if (cam.lighting && cam.lighting !== "None") config.lighting = cam.lighting;
|
| 759 |
if (cam.bokeh && cam.bokeh !== "None") config.bokeh = cam.bokeh;
|
| 760 |
if (cam.composition && cam.composition !== "None") config.composition = cam.composition;
|
|
|
|
| 761 |
break;
|
| 762 |
case "AGE":
|
| 763 |
if ((node as AgeNode).targetAge) {
|
|
@@ -823,7 +837,7 @@ export default function EditorPage() {
|
|
| 823 |
|
| 824 |
// If this is a MERGE node with output, return its output
|
| 825 |
if (currentNode.type === "MERGE" && (currentNode as MergeNode).output) {
|
| 826 |
-
return (currentNode as MergeNode).output;
|
| 827 |
}
|
| 828 |
|
| 829 |
// If any node has been processed, return its output
|
|
@@ -1125,17 +1139,34 @@ export default function EditorPage() {
|
|
| 1125 |
// Get character nodes with their labels
|
| 1126 |
const characterData = merge.inputs
|
| 1127 |
.map((id, index) => {
|
| 1128 |
-
const char = nodes.find((c) => c.id === id)
|
| 1129 |
if (!char) return null;
|
| 1130 |
-
|
| 1131 |
-
|
| 1132 |
-
|
| 1133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1134 |
})
|
| 1135 |
.filter(Boolean) as { image: string; label: string }[];
|
| 1136 |
|
| 1137 |
if (characterData.length < 2) throw new Error("Connect at least two CHARACTER nodes.");
|
| 1138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1139 |
// Generate dynamic prompt based on number of inputs
|
| 1140 |
const prompt = generateMergePrompt(characterData);
|
| 1141 |
const imgs = characterData.map(d => d.image);
|
|
@@ -1162,13 +1193,30 @@ export default function EditorPage() {
|
|
| 1162 |
}
|
| 1163 |
};
|
| 1164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1165 |
// Connection paths with bezier curves
|
| 1166 |
const connectionPaths = useMemo(() => {
|
| 1167 |
const getNodeOutputPort = (n: AnyNode) => {
|
| 1168 |
// Different nodes have different widths
|
| 1169 |
const widths: Record<string, number> = {
|
| 1170 |
CHARACTER: 340,
|
| 1171 |
-
MERGE:
|
| 1172 |
BACKGROUND: 320,
|
| 1173 |
CLOTHES: 320,
|
| 1174 |
BLEND: 320,
|
|
@@ -1326,13 +1374,9 @@ export default function EditorPage() {
|
|
| 1326 |
};
|
| 1327 |
|
| 1328 |
return (
|
| 1329 |
-
<div className="min-h-[100svh] bg-
|
| 1330 |
-
<header className="flex items-center justify-between px-6 py-4 border-b border-
|
| 1331 |
-
|
| 1332 |
-
<div className="flex items-center gap-2">
|
| 1333 |
-
<button className="text-xs bg-white/10 hover:bg-white/20 rounded px-3 py-1" onClick={() => addCharacter()}>+ CHARACTER</button>
|
| 1334 |
-
<button className="text-xs bg-white/10 hover:bg-white/20 rounded px-3 py-1" onClick={() => addMerge()}>+ MERGE</button>
|
| 1335 |
-
</div>
|
| 1336 |
</header>
|
| 1337 |
|
| 1338 |
<div
|
|
@@ -1368,7 +1412,16 @@ export default function EditorPage() {
|
|
| 1368 |
backfaceVisibility: "hidden"
|
| 1369 |
}}
|
| 1370 |
>
|
| 1371 |
-
<svg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1372 |
<defs>
|
| 1373 |
<filter id="glow">
|
| 1374 |
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
|
|
@@ -1383,11 +1436,10 @@ export default function EditorPage() {
|
|
| 1383 |
key={idx}
|
| 1384 |
d={p.path}
|
| 1385 |
fill="none"
|
| 1386 |
-
stroke={p.active ? "
|
| 1387 |
strokeWidth="2.5"
|
| 1388 |
strokeDasharray={p.active ? "5,5" : undefined}
|
| 1389 |
-
|
| 1390 |
-
opacity={p.active ? 0.8 : 1}
|
| 1391 |
/>
|
| 1392 |
))}
|
| 1393 |
</svg>
|
|
|
|
| 10 |
AgeNodeView,
|
| 11 |
FaceNodeView
|
| 12 |
} from "./nodes";
|
| 13 |
+
import { Button } from "../../components/ui/button";
|
| 14 |
|
| 15 |
function cx(...args: Array<string | false | null | undefined>) {
|
| 16 |
return args.filter(Boolean).join(" ");
|
|
|
|
| 23 |
function generateMergePrompt(characterData: { image: string; label: string }[]): string {
|
| 24 |
const count = characterData.length;
|
| 25 |
|
| 26 |
+
const labels = characterData.map((d, i) => `Image ${i + 1} (${d.label})`).join(", ");
|
|
|
|
|
|
|
| 27 |
|
| 28 |
+
return `MERGE TASK: You are provided with exactly ${count} source images.
|
| 29 |
+
|
| 30 |
+
Images provided:
|
| 31 |
+
${characterData.map((d, i) => `- Image ${i + 1}: ${d.label}`).join("\n")}
|
| 32 |
+
|
| 33 |
+
INSTRUCTIONS:
|
| 34 |
+
1. EXTRACT the exact people/subjects from EACH provided image
|
| 35 |
+
2. DO NOT generate new people - use ONLY the people visible in the provided images
|
| 36 |
+
3. COMBINE all extracted people into ONE single group photo
|
| 37 |
+
4. The output must contain ALL people from ALL ${count} input images together
|
| 38 |
+
|
| 39 |
+
Requirements:
|
| 40 |
+
- Use the ACTUAL people from the provided images (do not create new ones)
|
| 41 |
+
- If an image has multiple people, include ALL of them
|
| 42 |
+
- Arrange everyone naturally in the same scene
|
| 43 |
+
- Match lighting and proportions realistically
|
| 44 |
+
- Output exactly ONE image with everyone combined
|
| 45 |
+
|
| 46 |
+
DO NOT create artistic interpretations or new people. EXTRACT and COMBINE the actual subjects from the provided photographs.`;
|
| 47 |
}
|
| 48 |
|
| 49 |
// Types
|
|
|
|
| 65 |
type MergeNode = NodeBase & {
|
| 66 |
type: "MERGE";
|
| 67 |
inputs: string[]; // node ids
|
| 68 |
+
output?: string | null; // data URL from merge
|
| 69 |
isRunning?: boolean;
|
| 70 |
error?: string | null;
|
| 71 |
};
|
|
|
|
| 128 |
lighting?: string;
|
| 129 |
bokeh?: string;
|
| 130 |
composition?: string;
|
| 131 |
+
aspectRatio?: string;
|
| 132 |
isRunning?: boolean;
|
| 133 |
error?: string | null;
|
| 134 |
};
|
|
|
|
| 343 |
onChange={(e) => onChangeLabel(node.id, e.target.value)}
|
| 344 |
/>
|
| 345 |
<div className="flex items-center gap-2">
|
| 346 |
+
<Button
|
| 347 |
+
variant="ghost" size="icon" className="text-destructive"
|
| 348 |
onClick={(e) => {
|
| 349 |
e.stopPropagation();
|
| 350 |
+
if (confirm('Delete MERGE node?')) {
|
| 351 |
onDelete(node.id);
|
| 352 |
}
|
| 353 |
}}
|
| 354 |
title="Delete node"
|
| 355 |
+
aria-label="Delete node"
|
| 356 |
>
|
| 357 |
×
|
| 358 |
+
</Button>
|
| 359 |
<Port
|
| 360 |
className="out"
|
| 361 |
nodeId={node.id}
|
|
|
|
| 365 |
</div>
|
| 366 |
</div>
|
| 367 |
<div className="p-3 space-y-3">
|
| 368 |
+
<div className="aspect-[4/5] w-full rounded-xl bg-black/40 grid place-items-center overflow-hidden">
|
| 369 |
<img
|
| 370 |
src={node.image}
|
| 371 |
alt="character"
|
| 372 |
+
className="h-full w-full object-contain"
|
| 373 |
draggable={false}
|
| 374 |
/>
|
| 375 |
</div>
|
|
|
|
| 444 |
|
| 445 |
|
| 446 |
return (
|
| 447 |
+
<div className="nb-node absolute text-white w-[420px]" style={{ left: pos.x, top: pos.y }}>
|
| 448 |
<div
|
| 449 |
className="nb-header cursor-grab active:cursor-grabbing rounded-t-[14px] px-3 py-2 flex items-center justify-between"
|
| 450 |
onPointerDown={onPointerDown}
|
|
|
|
| 487 |
if (!c) return null;
|
| 488 |
return (
|
| 489 |
<div key={id} className="flex items-center gap-2 bg-white/10 rounded px-2 py-1">
|
| 490 |
+
<div className="w-6 h-6 rounded overflow-hidden bg-black/20">
|
| 491 |
+
<img src={c.image} className="w-full h-full object-contain" alt="inp" />
|
| 492 |
</div>
|
| 493 |
<span className="text-xs">{c.label || `Character ${id.slice(-3)}`}</span>
|
| 494 |
<button
|
|
|
|
| 506 |
)}
|
| 507 |
<div className="flex items-center gap-2">
|
| 508 |
{node.inputs.length > 0 && (
|
| 509 |
+
<Button
|
| 510 |
+
variant="destructive"
|
| 511 |
+
size="sm"
|
| 512 |
onClick={() => onClearConnections(node.id)}
|
| 513 |
title="Clear all connections"
|
| 514 |
>
|
| 515 |
Clear
|
| 516 |
+
</Button>
|
| 517 |
)}
|
| 518 |
+
<Button
|
| 519 |
+
size="sm"
|
| 520 |
onClick={() => onRun(node.id)}
|
| 521 |
disabled={node.isRunning || node.inputs.length < 2}
|
| 522 |
>
|
| 523 |
{node.isRunning ? "Merging…" : "Merge"}
|
| 524 |
+
</Button>
|
| 525 |
</div>
|
| 526 |
|
| 527 |
<div className="mt-2">
|
| 528 |
<div className="text-xs text-white/70 mb-1">Output</div>
|
| 529 |
+
<div className="w-full min-h-[200px] max-h-[400px] rounded-xl bg-black/40 grid place-items-center">
|
| 530 |
{node.output ? (
|
| 531 |
+
<img src={node.output} className="w-full h-auto max-h-[400px] object-contain rounded-xl" alt="output" />
|
| 532 |
) : (
|
| 533 |
+
<span className="text-white/40 text-xs py-16">Run merge to see result</span>
|
| 534 |
)}
|
| 535 |
</div>
|
| 536 |
{node.output && (
|
| 537 |
+
<Button
|
| 538 |
+
className="w-full mt-2"
|
| 539 |
+
variant="secondary"
|
| 540 |
onClick={() => {
|
| 541 |
const link = document.createElement('a');
|
| 542 |
link.href = node.output as string;
|
|
|
|
| 547 |
}}
|
| 548 |
>
|
| 549 |
📥 Download Merged Image
|
| 550 |
+
</Button>
|
| 551 |
)}
|
| 552 |
{node.error && (
|
| 553 |
<div className="mt-2">
|
|
|
|
| 771 |
if (cam.lighting && cam.lighting !== "None") config.lighting = cam.lighting;
|
| 772 |
if (cam.bokeh && cam.bokeh !== "None") config.bokeh = cam.bokeh;
|
| 773 |
if (cam.composition && cam.composition !== "None") config.composition = cam.composition;
|
| 774 |
+
if (cam.aspectRatio && cam.aspectRatio !== "None") config.aspectRatio = cam.aspectRatio;
|
| 775 |
break;
|
| 776 |
case "AGE":
|
| 777 |
if ((node as AgeNode).targetAge) {
|
|
|
|
| 837 |
|
| 838 |
// If this is a MERGE node with output, return its output
|
| 839 |
if (currentNode.type === "MERGE" && (currentNode as MergeNode).output) {
|
| 840 |
+
return (currentNode as MergeNode).output || null;
|
| 841 |
}
|
| 842 |
|
| 843 |
// If any node has been processed, return its output
|
|
|
|
| 1139 |
// Get character nodes with their labels
|
| 1140 |
const characterData = merge.inputs
|
| 1141 |
.map((id, index) => {
|
| 1142 |
+
const char = nodes.find((c) => c.id === id);
|
| 1143 |
if (!char) return null;
|
| 1144 |
+
|
| 1145 |
+
// Support both CHARACTER nodes and any node with output
|
| 1146 |
+
let image: string | null = null;
|
| 1147 |
+
let label = "";
|
| 1148 |
+
|
| 1149 |
+
if (char.type === "CHARACTER") {
|
| 1150 |
+
image = (char as CharacterNode).image;
|
| 1151 |
+
label = (char as CharacterNode).label || `CHARACTER${index + 1}`;
|
| 1152 |
+
} else if ((char as any).output) {
|
| 1153 |
+
// If it's a processed node, use its output
|
| 1154 |
+
image = (char as any).output;
|
| 1155 |
+
label = `Input ${index + 1}`;
|
| 1156 |
+
}
|
| 1157 |
+
|
| 1158 |
+
if (!image) return null;
|
| 1159 |
+
|
| 1160 |
+
return { image, label };
|
| 1161 |
})
|
| 1162 |
.filter(Boolean) as { image: string; label: string }[];
|
| 1163 |
|
| 1164 |
if (characterData.length < 2) throw new Error("Connect at least two CHARACTER nodes.");
|
| 1165 |
|
| 1166 |
+
// Debug: Log what we're sending
|
| 1167 |
+
console.log("🔄 Merging nodes:", characterData.map(d => d.label).join(", "));
|
| 1168 |
+
console.log("📷 Image URLs being sent:", characterData.map(d => d.image.substring(0, 100) + "..."));
|
| 1169 |
+
|
| 1170 |
// Generate dynamic prompt based on number of inputs
|
| 1171 |
const prompt = generateMergePrompt(characterData);
|
| 1172 |
const imgs = characterData.map(d => d.image);
|
|
|
|
| 1193 |
}
|
| 1194 |
};
|
| 1195 |
|
| 1196 |
+
// Calculate SVG bounds for connection lines
|
| 1197 |
+
const svgBounds = useMemo(() => {
|
| 1198 |
+
let minX = 0, minY = 0, maxX = 1000, maxY = 1000;
|
| 1199 |
+
nodes.forEach(node => {
|
| 1200 |
+
minX = Math.min(minX, node.x - 100);
|
| 1201 |
+
minY = Math.min(minY, node.y - 100);
|
| 1202 |
+
maxX = Math.max(maxX, node.x + 500);
|
| 1203 |
+
maxY = Math.max(maxY, node.y + 500);
|
| 1204 |
+
});
|
| 1205 |
+
return {
|
| 1206 |
+
x: minX,
|
| 1207 |
+
y: minY,
|
| 1208 |
+
width: maxX - minX,
|
| 1209 |
+
height: maxY - minY
|
| 1210 |
+
};
|
| 1211 |
+
}, [nodes]);
|
| 1212 |
+
|
| 1213 |
// Connection paths with bezier curves
|
| 1214 |
const connectionPaths = useMemo(() => {
|
| 1215 |
const getNodeOutputPort = (n: AnyNode) => {
|
| 1216 |
// Different nodes have different widths
|
| 1217 |
const widths: Record<string, number> = {
|
| 1218 |
CHARACTER: 340,
|
| 1219 |
+
MERGE: 420,
|
| 1220 |
BACKGROUND: 320,
|
| 1221 |
CLOTHES: 320,
|
| 1222 |
BLEND: 320,
|
|
|
|
| 1374 |
};
|
| 1375 |
|
| 1376 |
return (
|
| 1377 |
+
<div className="min-h-[100svh] bg-background text-foreground">
|
| 1378 |
+
<header className="flex items-center justify-between px-6 py-4 border-b border-border/60 bg-card/70 backdrop-blur">
|
| 1379 |
+
<h1 className="text-lg font-semibold tracking-wide"><span className="mr-2" aria-hidden>🍌</span>Nano Banana Editor</h1>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1380 |
</header>
|
| 1381 |
|
| 1382 |
<div
|
|
|
|
| 1412 |
backfaceVisibility: "hidden"
|
| 1413 |
}}
|
| 1414 |
>
|
| 1415 |
+
<svg
|
| 1416 |
+
className="absolute pointer-events-none z-0"
|
| 1417 |
+
style={{
|
| 1418 |
+
left: `${svgBounds.x}px`,
|
| 1419 |
+
top: `${svgBounds.y}px`,
|
| 1420 |
+
width: `${svgBounds.width}px`,
|
| 1421 |
+
height: `${svgBounds.height}px`
|
| 1422 |
+
}}
|
| 1423 |
+
viewBox={`${svgBounds.x} ${svgBounds.y} ${svgBounds.width} ${svgBounds.height}`}
|
| 1424 |
+
>
|
| 1425 |
<defs>
|
| 1426 |
<filter id="glow">
|
| 1427 |
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
|
|
|
|
| 1436 |
key={idx}
|
| 1437 |
d={p.path}
|
| 1438 |
fill="none"
|
| 1439 |
+
stroke={p.active ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))"}
|
| 1440 |
strokeWidth="2.5"
|
| 1441 |
strokeDasharray={p.active ? "5,5" : undefined}
|
| 1442 |
+
style={p.active ? undefined : { opacity: 0.9 }}
|
|
|
|
| 1443 |
/>
|
| 1444 |
))}
|
| 1445 |
</svg>
|
app/globals.css
CHANGED
|
@@ -1,36 +1,140 @@
|
|
| 1 |
@import "tailwindcss";
|
| 2 |
|
|
|
|
| 3 |
:root {
|
| 4 |
-
--background:
|
| 5 |
-
--foreground:
|
| 6 |
-
}
|
| 7 |
|
| 8 |
-
|
| 9 |
-
--
|
| 10 |
-
|
| 11 |
-
--
|
| 12 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
@media (prefers-color-scheme: dark) {
|
| 16 |
:root {
|
| 17 |
-
--background:
|
| 18 |
-
--foreground:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
}
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
body {
|
| 23 |
-
background: var(--background);
|
| 24 |
-
color: var(--foreground);
|
| 25 |
-
font-family:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
}
|
| 27 |
|
| 28 |
/* Nano Banana Editor - node visuals */
|
| 29 |
.nb-node {
|
| 30 |
-
background:
|
| 31 |
-
border: 1px solid
|
| 32 |
box-shadow: 0 10px 30px rgba(0,0,0,0.35);
|
| 33 |
-
border-radius:
|
| 34 |
backdrop-filter: blur(6px);
|
| 35 |
/* Prevent blurring on zoom */
|
| 36 |
image-rendering: -webkit-optimize-contrast;
|
|
@@ -43,29 +147,29 @@ body {
|
|
| 43 |
perspective: 1000px;
|
| 44 |
}
|
| 45 |
.nb-node .nb-header {
|
| 46 |
-
background: linear-gradient(to bottom,
|
| 47 |
}
|
| 48 |
.nb-port {
|
| 49 |
width: 20px;
|
| 50 |
height: 20px;
|
| 51 |
border-radius: 9999px;
|
| 52 |
border: 3px solid rgba(255,255,255,0.6);
|
| 53 |
-
background:
|
| 54 |
cursor: crosshair;
|
| 55 |
transition: transform 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
| 56 |
position: relative;
|
| 57 |
}
|
| 58 |
.nb-port:hover {
|
| 59 |
-
transform: scale(1.
|
| 60 |
-
background:
|
| 61 |
-
box-shadow: 0 0 12px
|
| 62 |
}
|
| 63 |
.nb-port.out {
|
| 64 |
-
border-color:
|
| 65 |
}
|
| 66 |
.nb-port.out:hover {
|
| 67 |
-
background:
|
| 68 |
-
box-shadow: 0 0 16px
|
| 69 |
}
|
| 70 |
.nb-port.in {
|
| 71 |
border-color: #34d399;
|
|
@@ -83,10 +187,10 @@ body {
|
|
| 83 |
|
| 84 |
/* Canvas grid */
|
| 85 |
.nb-canvas {
|
| 86 |
-
background-color:
|
| 87 |
background-image:
|
| 88 |
-
radial-gradient(circle at 1px 1px,
|
| 89 |
-
radial-gradient(circle at 1px 1px,
|
| 90 |
background-size: 20px 20px, 100px 100px;
|
| 91 |
}
|
| 92 |
|
|
|
|
| 1 |
@import "tailwindcss";
|
| 2 |
|
| 3 |
+
/* shadcn theme tokens */
|
| 4 |
:root {
|
| 5 |
+
--background: 0 0% 100%;
|
| 6 |
+
--foreground: 222.2 84% 4.9%;
|
|
|
|
| 7 |
|
| 8 |
+
--card: 0 0% 100%;
|
| 9 |
+
--card-foreground: 222.2 84% 4.9%;
|
| 10 |
+
|
| 11 |
+
--popover: 0 0% 100%;
|
| 12 |
+
--popover-foreground: 222.2 84% 4.9%;
|
| 13 |
+
|
| 14 |
+
/* Brand: Orangish Red */
|
| 15 |
+
--primary: 14 90% 50%;
|
| 16 |
+
--primary-foreground: 0 0% 98%;
|
| 17 |
+
|
| 18 |
+
--secondary: 210 40% 96.1%;
|
| 19 |
+
--secondary-foreground: 222.2 47.4% 11.2%;
|
| 20 |
+
|
| 21 |
+
--muted: 210 40% 96.1%;
|
| 22 |
+
--muted-foreground: 215.4 16.3% 46.9%;
|
| 23 |
+
|
| 24 |
+
--accent: 14 95% 90%;
|
| 25 |
+
--accent-foreground: 14 90% 20%;
|
| 26 |
+
|
| 27 |
+
--destructive: 0 84.2% 60.2%;
|
| 28 |
+
--destructive-foreground: 210 40% 98%;
|
| 29 |
+
|
| 30 |
+
--border: 214.3 31.8% 91.4%;
|
| 31 |
+
--input: 214.3 31.8% 91.4%;
|
| 32 |
+
--ring: 14 90% 45%;
|
| 33 |
+
|
| 34 |
+
--radius: 0.75rem;
|
| 35 |
+
|
| 36 |
+
--chart-1: 12 76% 61%;
|
| 37 |
+
--chart-2: 173 58% 39%;
|
| 38 |
+
--chart-3: 197 37% 24%;
|
| 39 |
+
--chart-4: 43 74% 66%;
|
| 40 |
+
--chart-5: 27 87% 67%;
|
| 41 |
}
|
| 42 |
|
| 43 |
@media (prefers-color-scheme: dark) {
|
| 44 |
:root {
|
| 45 |
+
--background: 240 10% 3.9%;
|
| 46 |
+
--foreground: 0 0% 98%;
|
| 47 |
+
|
| 48 |
+
--card: 240 10% 3.9%;
|
| 49 |
+
--card-foreground: 0 0% 98%;
|
| 50 |
+
|
| 51 |
+
--popover: 240 10% 3.9%;
|
| 52 |
+
--popover-foreground: 0 0% 98%;
|
| 53 |
+
|
| 54 |
+
/* Brand in dark mode */
|
| 55 |
+
--primary: 14 87% 60%;
|
| 56 |
+
--primary-foreground: 240 10% 3.9%;
|
| 57 |
+
|
| 58 |
+
--secondary: 240 3.7% 15.9%;
|
| 59 |
+
--secondary-foreground: 0 0% 98%;
|
| 60 |
+
|
| 61 |
+
--muted: 240 3.7% 15.9%;
|
| 62 |
+
--muted-foreground: 240 5% 64.9%;
|
| 63 |
+
|
| 64 |
+
--accent: 14 40% 20%;
|
| 65 |
+
--accent-foreground: 0 0% 98%;
|
| 66 |
+
|
| 67 |
+
--destructive: 0 62.8% 30.6%;
|
| 68 |
+
--destructive-foreground: 0 85.7% 97.3%;
|
| 69 |
+
|
| 70 |
+
--border: 240 3.7% 15.9%;
|
| 71 |
+
--input: 240 3.7% 15.9%;
|
| 72 |
+
--ring: 14 87% 55%;
|
| 73 |
}
|
| 74 |
}
|
| 75 |
|
| 76 |
+
@theme inline {
|
| 77 |
+
--color-background: hsl(var(--background));
|
| 78 |
+
--color-foreground: hsl(var(--foreground));
|
| 79 |
+
--color-card: hsl(var(--card));
|
| 80 |
+
--color-card-foreground: hsl(var(--card-foreground));
|
| 81 |
+
--color-popover: hsl(var(--popover));
|
| 82 |
+
--color-popover-foreground: hsl(var(--popover-foreground));
|
| 83 |
+
--color-primary: hsl(var(--primary));
|
| 84 |
+
--color-primary-foreground: hsl(var(--primary-foreground));
|
| 85 |
+
--color-secondary: hsl(var(--secondary));
|
| 86 |
+
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
| 87 |
+
--color-muted: hsl(var(--muted));
|
| 88 |
+
--color-muted-foreground: hsl(var(--muted-foreground));
|
| 89 |
+
--color-accent: hsl(var(--accent));
|
| 90 |
+
--color-accent-foreground: hsl(var(--accent-foreground));
|
| 91 |
+
--color-destructive: hsl(var(--destructive));
|
| 92 |
+
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
| 93 |
+
--color-border: hsl(var(--border));
|
| 94 |
+
--color-input: hsl(var(--input));
|
| 95 |
+
--color-ring: hsl(var(--ring));
|
| 96 |
+
--font-sans: var(--font-geist-sans);
|
| 97 |
+
--font-mono: var(--font-geist-mono);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
body {
|
| 101 |
+
background: hsl(var(--background));
|
| 102 |
+
color: hsl(var(--foreground));
|
| 103 |
+
font-family: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/* Custom scrollbar styling */
|
| 107 |
+
.scrollbar-thin::-webkit-scrollbar {
|
| 108 |
+
width: 6px;
|
| 109 |
+
height: 6px;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.scrollbar-thin::-webkit-scrollbar-track {
|
| 113 |
+
background: hsl(var(--background) / 0.1);
|
| 114 |
+
border-radius: 3px;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.scrollbar-thin::-webkit-scrollbar-thumb {
|
| 118 |
+
background: hsl(var(--muted-foreground) / 0.3);
|
| 119 |
+
border-radius: 3px;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
| 123 |
+
background: hsl(var(--muted-foreground) / 0.5);
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/* Firefox */
|
| 127 |
+
.scrollbar-thin {
|
| 128 |
+
scrollbar-width: thin;
|
| 129 |
+
scrollbar-color: hsl(var(--muted-foreground) / 0.3) hsl(var(--background) / 0.1);
|
| 130 |
}
|
| 131 |
|
| 132 |
/* Nano Banana Editor - node visuals */
|
| 133 |
.nb-node {
|
| 134 |
+
background: hsl(var(--card) / 0.9);
|
| 135 |
+
border: 1px solid hsl(var(--border) / 0.6);
|
| 136 |
box-shadow: 0 10px 30px rgba(0,0,0,0.35);
|
| 137 |
+
border-radius: var(--radius);
|
| 138 |
backdrop-filter: blur(6px);
|
| 139 |
/* Prevent blurring on zoom */
|
| 140 |
image-rendering: -webkit-optimize-contrast;
|
|
|
|
| 147 |
perspective: 1000px;
|
| 148 |
}
|
| 149 |
.nb-node .nb-header {
|
| 150 |
+
background: linear-gradient(to bottom, hsl(var(--muted) / 0.35), hsl(var(--muted) / 0.08));
|
| 151 |
}
|
| 152 |
.nb-port {
|
| 153 |
width: 20px;
|
| 154 |
height: 20px;
|
| 155 |
border-radius: 9999px;
|
| 156 |
border: 3px solid rgba(255,255,255,0.6);
|
| 157 |
+
background: hsl(var(--popover));
|
| 158 |
cursor: crosshair;
|
| 159 |
transition: transform 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
|
| 160 |
position: relative;
|
| 161 |
}
|
| 162 |
.nb-port:hover {
|
| 163 |
+
transform: scale(1.25);
|
| 164 |
+
background: hsl(var(--accent));
|
| 165 |
+
box-shadow: 0 0 12px hsl(var(--ring) / 0.4);
|
| 166 |
}
|
| 167 |
.nb-port.out {
|
| 168 |
+
border-color: hsl(var(--primary));
|
| 169 |
}
|
| 170 |
.nb-port.out:hover {
|
| 171 |
+
background: hsl(var(--primary));
|
| 172 |
+
box-shadow: 0 0 16px hsl(var(--primary) / 0.6);
|
| 173 |
}
|
| 174 |
.nb-port.in {
|
| 175 |
border-color: #34d399;
|
|
|
|
| 187 |
|
| 188 |
/* Canvas grid */
|
| 189 |
.nb-canvas {
|
| 190 |
+
background-color: hsl(var(--background));
|
| 191 |
background-image:
|
| 192 |
+
radial-gradient(circle at 1px 1px, hsl(var(--muted-foreground) / 0.18) 1px, transparent 0),
|
| 193 |
+
radial-gradient(circle at 1px 1px, hsl(var(--muted-foreground) / 0.09) 1px, transparent 0);
|
| 194 |
background-size: 20px 20px, 100px 100px;
|
| 195 |
}
|
| 196 |
|
app/layout.tsx
CHANGED
|
@@ -25,7 +25,7 @@ export default function RootLayout({
|
|
| 25 |
return (
|
| 26 |
<html lang="en">
|
| 27 |
<body
|
| 28 |
-
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
| 29 |
>
|
| 30 |
{children}
|
| 31 |
</body>
|
|
|
|
| 25 |
return (
|
| 26 |
<html lang="en">
|
| 27 |
<body
|
| 28 |
+
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-background text-foreground font-sans`}
|
| 29 |
>
|
| 30 |
{children}
|
| 31 |
</body>
|
app/try-on/page.tsx
CHANGED
|
@@ -206,7 +206,7 @@ export default function TryOnPage() {
|
|
| 206 |
};
|
| 207 |
|
| 208 |
return (
|
| 209 |
-
<div className="min-h-[100svh] bg-
|
| 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">
|
|
|
|
| 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">
|
package-lock.json
CHANGED
|
@@ -9,9 +9,12 @@
|
|
| 9 |
"version": "0.1.0",
|
| 10 |
"dependencies": {
|
| 11 |
"@google/genai": "^1.17.0",
|
|
|
|
|
|
|
| 12 |
"next": "15.5.2",
|
| 13 |
"react": "19.1.0",
|
| 14 |
-
"react-dom": "19.1.0"
|
|
|
|
| 15 |
},
|
| 16 |
"devDependencies": {
|
| 17 |
"@eslint/eslintrc": "^3",
|
|
@@ -2357,12 +2360,33 @@
|
|
| 2357 |
"node": ">=18"
|
| 2358 |
}
|
| 2359 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2360 |
"node_modules/client-only": {
|
| 2361 |
"version": "0.0.1",
|
| 2362 |
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
| 2363 |
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
| 2364 |
"license": "MIT"
|
| 2365 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2366 |
"node_modules/color": {
|
| 2367 |
"version": "4.2.3",
|
| 2368 |
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
|
@@ -5932,6 +5956,16 @@
|
|
| 5932 |
"url": "https://github.com/sponsors/ljharb"
|
| 5933 |
}
|
| 5934 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5935 |
"node_modules/tailwindcss": {
|
| 5936 |
"version": "4.1.13",
|
| 5937 |
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz",
|
|
|
|
| 9 |
"version": "0.1.0",
|
| 10 |
"dependencies": {
|
| 11 |
"@google/genai": "^1.17.0",
|
| 12 |
+
"class-variance-authority": "^0.7.0",
|
| 13 |
+
"clsx": "^2.1.1",
|
| 14 |
"next": "15.5.2",
|
| 15 |
"react": "19.1.0",
|
| 16 |
+
"react-dom": "19.1.0",
|
| 17 |
+
"tailwind-merge": "^2.5.3"
|
| 18 |
},
|
| 19 |
"devDependencies": {
|
| 20 |
"@eslint/eslintrc": "^3",
|
|
|
|
| 2360 |
"node": ">=18"
|
| 2361 |
}
|
| 2362 |
},
|
| 2363 |
+
"node_modules/class-variance-authority": {
|
| 2364 |
+
"version": "0.7.1",
|
| 2365 |
+
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
|
| 2366 |
+
"integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
|
| 2367 |
+
"license": "Apache-2.0",
|
| 2368 |
+
"dependencies": {
|
| 2369 |
+
"clsx": "^2.1.1"
|
| 2370 |
+
},
|
| 2371 |
+
"funding": {
|
| 2372 |
+
"url": "https://polar.sh/cva"
|
| 2373 |
+
}
|
| 2374 |
+
},
|
| 2375 |
"node_modules/client-only": {
|
| 2376 |
"version": "0.0.1",
|
| 2377 |
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
| 2378 |
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
| 2379 |
"license": "MIT"
|
| 2380 |
},
|
| 2381 |
+
"node_modules/clsx": {
|
| 2382 |
+
"version": "2.1.1",
|
| 2383 |
+
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
| 2384 |
+
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
| 2385 |
+
"license": "MIT",
|
| 2386 |
+
"engines": {
|
| 2387 |
+
"node": ">=6"
|
| 2388 |
+
}
|
| 2389 |
+
},
|
| 2390 |
"node_modules/color": {
|
| 2391 |
"version": "4.2.3",
|
| 2392 |
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
|
|
|
| 5956 |
"url": "https://github.com/sponsors/ljharb"
|
| 5957 |
}
|
| 5958 |
},
|
| 5959 |
+
"node_modules/tailwind-merge": {
|
| 5960 |
+
"version": "2.6.0",
|
| 5961 |
+
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
|
| 5962 |
+
"integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==",
|
| 5963 |
+
"license": "MIT",
|
| 5964 |
+
"funding": {
|
| 5965 |
+
"type": "github",
|
| 5966 |
+
"url": "https://github.com/sponsors/dcastil"
|
| 5967 |
+
}
|
| 5968 |
+
},
|
| 5969 |
"node_modules/tailwindcss": {
|
| 5970 |
"version": "4.1.13",
|
| 5971 |
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.13.tgz",
|
package.json
CHANGED
|
@@ -10,9 +10,12 @@
|
|
| 10 |
},
|
| 11 |
"dependencies": {
|
| 12 |
"@google/genai": "^1.17.0",
|
|
|
|
|
|
|
| 13 |
"next": "15.5.2",
|
| 14 |
"react": "19.1.0",
|
| 15 |
-
"react-dom": "19.1.0"
|
|
|
|
| 16 |
},
|
| 17 |
"devDependencies": {
|
| 18 |
"@eslint/eslintrc": "^3",
|
|
|
|
| 10 |
},
|
| 11 |
"dependencies": {
|
| 12 |
"@google/genai": "^1.17.0",
|
| 13 |
+
"class-variance-authority": "^0.7.0",
|
| 14 |
+
"clsx": "^2.1.1",
|
| 15 |
"next": "15.5.2",
|
| 16 |
"react": "19.1.0",
|
| 17 |
+
"react-dom": "19.1.0",
|
| 18 |
+
"tailwind-merge": "^2.5.3"
|
| 19 |
},
|
| 20 |
"devDependencies": {
|
| 21 |
"@eslint/eslintrc": "^3",
|