Spaces:
Running
Running
fixing the bugs
Browse files- app/api/process/route.ts +89 -62
- app/editor/nodes.tsx +269 -29
- app/editor/page.tsx +400 -78
app/api/process/route.ts
CHANGED
|
@@ -52,72 +52,99 @@ export async function POST(req: NextRequest) {
|
|
| 52 |
return NextResponse.json({ error: "Invalid or missing image data. Please ensure an input is connected." }, { status: 400 });
|
| 53 |
}
|
| 54 |
|
| 55 |
-
|
|
|
|
|
|
|
| 56 |
|
| 57 |
-
//
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
} else {
|
| 66 |
-
|
| 67 |
}
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
prompt = `Apply these camera settings to the image:\n` +
|
| 88 |
-
`Focal Length: ${camera.focalLength || "50mm"}\n` +
|
| 89 |
-
`Aperture: ${camera.aperture || "f/2.8"}\n` +
|
| 90 |
-
`Shutter Speed: ${camera.shutterSpeed || "1/250s"}\n` +
|
| 91 |
-
`White Balance: ${camera.whiteBalance || "5600K daylight"}\n` +
|
| 92 |
-
`Camera Angle: ${camera.angle || "eye level"}\n` +
|
| 93 |
-
`Make the image look like it was shot with these exact camera settings.`;
|
| 94 |
-
break;
|
| 95 |
-
|
| 96 |
-
case "AGE":
|
| 97 |
-
const targetAge = body.params?.targetAge || 30;
|
| 98 |
-
prompt = `Transform the person in this image to look exactly ${targetAge} years old. ` +
|
| 99 |
-
`Adjust their facial features, skin texture, hair, and overall appearance to match that age naturally. ` +
|
| 100 |
-
`Keep their identity recognizable but age-appropriate.`;
|
| 101 |
-
break;
|
| 102 |
-
|
| 103 |
-
case "FACE":
|
| 104 |
-
const face = body.params?.faceOptions || {};
|
| 105 |
-
const modifications = [];
|
| 106 |
-
if (face.removePimples) modifications.push("remove all pimples and blemishes");
|
| 107 |
-
if (face.addSunglasses) modifications.push("add stylish sunglasses");
|
| 108 |
-
if (face.addHat) modifications.push("add a fashionable hat");
|
| 109 |
-
if (face.changeHairstyle) modifications.push(`change hairstyle to ${face.changeHairstyle}`);
|
| 110 |
-
if (face.facialExpression) modifications.push(`change facial expression to ${face.facialExpression}`);
|
| 111 |
-
if (face.beardStyle) modifications.push(`add/change beard to ${face.beardStyle}`);
|
| 112 |
-
|
| 113 |
-
prompt = modifications.length > 0
|
| 114 |
-
? `Modify the person's face: ${modifications.join(", ")}. Keep everything else the same.`
|
| 115 |
-
: "Enhance the person's face subtly.";
|
| 116 |
-
break;
|
| 117 |
-
|
| 118 |
-
default:
|
| 119 |
-
return NextResponse.json({ error: "Unknown node type" }, { status: 400 });
|
| 120 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
// Add the custom prompt if provided
|
| 123 |
if (body.prompt) {
|
|
|
|
| 52 |
return NextResponse.json({ error: "Invalid or missing image data. Please ensure an input is connected." }, { status: 400 });
|
| 53 |
}
|
| 54 |
|
| 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) {
|
| 61 |
+
const bgType = params.backgroundType;
|
| 62 |
+
if (bgType === "color") {
|
| 63 |
+
prompts.push(`Change the background to a solid ${params.backgroundColor || "white"} background.`);
|
| 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 with the uploaded custom background image, ensuring proper lighting and perspective matching.`);
|
| 68 |
+
} else if (params.customPrompt) {
|
| 69 |
+
prompts.push(params.customPrompt);
|
| 70 |
+
}
|
| 71 |
+
}
|
| 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("Change the person's clothes to a Japanese sukajan jacket with embroidered designs.");
|
| 79 |
+
} else if (params.selectedPreset === "Blazer") {
|
| 80 |
+
prompts.push("Change the person's clothes to a professional blazer.");
|
| 81 |
+
} else if (params.clothesImage.startsWith('data:') || params.clothesImage.startsWith('http')) {
|
| 82 |
+
prompts.push("Change the person's clothes to match the provided reference image style.");
|
| 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
|
| 93 |
+
if (params.editPrompt) {
|
| 94 |
+
prompts.push(params.editPrompt);
|
| 95 |
+
}
|
| 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") {
|
| 103 |
+
cameraSettings.push("Apply 8mm fisheye lens effect with 180-degree circular distortion");
|
| 104 |
} else {
|
| 105 |
+
cameraSettings.push(`Focal Length: ${params.focalLength}`);
|
| 106 |
}
|
| 107 |
+
}
|
| 108 |
+
if (params.aperture) cameraSettings.push(`Aperture: ${params.aperture}`);
|
| 109 |
+
if (params.shutterSpeed) cameraSettings.push(`Shutter Speed: ${params.shutterSpeed}`);
|
| 110 |
+
if (params.whiteBalance) cameraSettings.push(`White Balance: ${params.whiteBalance}`);
|
| 111 |
+
if (params.angle) cameraSettings.push(`Camera Angle: ${params.angle}`);
|
| 112 |
+
if (params.iso) cameraSettings.push(`${params.iso}`);
|
| 113 |
+
if (params.filmStyle) cameraSettings.push(`Film style: ${params.filmStyle}`);
|
| 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(", ")}`);
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
// Age transformation
|
| 124 |
+
if (params.targetAge) {
|
| 125 |
+
prompts.push(`Transform the person to look exactly ${params.targetAge} years old with age-appropriate features.`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
}
|
| 127 |
+
|
| 128 |
+
// Face modifications
|
| 129 |
+
if (params.faceOptions) {
|
| 130 |
+
const face = params.faceOptions;
|
| 131 |
+
const modifications: string[] = [];
|
| 132 |
+
if (face.removePimples) modifications.push("remove all pimples and blemishes");
|
| 133 |
+
if (face.addSunglasses) modifications.push("add stylish sunglasses");
|
| 134 |
+
if (face.addHat) modifications.push("add a fashionable hat");
|
| 135 |
+
if (face.changeHairstyle) modifications.push(`change hairstyle to ${face.changeHairstyle}`);
|
| 136 |
+
if (face.facialExpression) modifications.push(`change facial expression to ${face.facialExpression}`);
|
| 137 |
+
if (face.beardStyle) modifications.push(`add/change beard to ${face.beardStyle}`);
|
| 138 |
+
|
| 139 |
+
if (modifications.length > 0) {
|
| 140 |
+
prompts.push(`Face modifications: ${modifications.join(", ")}`);
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
// Combine all prompts
|
| 145 |
+
let prompt = prompts.length > 0
|
| 146 |
+
? prompts.join("\n\n") + "\n\nApply all these modifications while maintaining the person's identity and keeping unspecified aspects unchanged."
|
| 147 |
+
: "Process this image with high quality output.";
|
| 148 |
|
| 149 |
// Add the custom prompt if provided
|
| 150 |
if (body.prompt) {
|
app/editor/nodes.tsx
CHANGED
|
@@ -2,6 +2,16 @@
|
|
| 2 |
|
| 3 |
import React, { useState, useRef, useEffect } from "react";
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
// Import types (we'll need to export these from page.tsx)
|
| 6 |
type BackgroundNode = any;
|
| 7 |
type ClothesNode = any;
|
|
@@ -98,9 +108,59 @@ export function BackgroundNodeView({
|
|
| 98 |
onUpdatePosition,
|
| 99 |
}: any) {
|
| 100 |
const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
return (
|
| 103 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
<div
|
| 105 |
className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
|
| 106 |
onPointerDown={onPointerDown}
|
|
@@ -122,6 +182,7 @@ export function BackgroundNodeView({
|
|
| 122 |
>
|
| 123 |
<option value="color">Solid Color</option>
|
| 124 |
<option value="image">Preset Background</option>
|
|
|
|
| 125 |
<option value="custom">Custom Prompt</option>
|
| 126 |
</select>
|
| 127 |
|
|
@@ -149,6 +210,35 @@ export function BackgroundNodeView({
|
|
| 149 |
</select>
|
| 150 |
)}
|
| 151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
{node.backgroundType === "custom" && (
|
| 153 |
<textarea
|
| 154 |
className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
|
|
@@ -160,15 +250,24 @@ export function BackgroundNodeView({
|
|
| 160 |
)}
|
| 161 |
|
| 162 |
<button
|
| 163 |
-
className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1"
|
| 164 |
onClick={() => onProcess(node.id)}
|
| 165 |
disabled={node.isRunning}
|
|
|
|
| 166 |
>
|
| 167 |
{node.isRunning ? "Processing..." : "Apply Background"}
|
| 168 |
</button>
|
| 169 |
|
| 170 |
{node.output && (
|
| 171 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
)}
|
| 173 |
{node.error && (
|
| 174 |
<div className="text-xs text-red-400 mt-2">{node.error}</div>
|
|
@@ -180,6 +279,7 @@ export function BackgroundNodeView({
|
|
| 180 |
|
| 181 |
export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
|
| 182 |
const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
|
|
|
|
| 183 |
|
| 184 |
const presetClothes = [
|
| 185 |
{ name: "Sukajan", path: "/sukajan.png" },
|
|
@@ -221,11 +321,12 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
|
|
| 221 |
|
| 222 |
return (
|
| 223 |
<div
|
| 224 |
-
className=
|
| 225 |
style={{ left: localPos.x, top: localPos.y }}
|
| 226 |
onDrop={onDrop}
|
| 227 |
onDragOver={(e) => e.preventDefault()}
|
| 228 |
onPaste={onPaste}
|
|
|
|
| 229 |
>
|
| 230 |
<div
|
| 231 |
className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
|
|
@@ -241,6 +342,11 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
|
|
| 241 |
</div>
|
| 242 |
</div>
|
| 243 |
<div className="p-3 space-y-3">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
<div className="text-xs text-white/70">Clothes Reference</div>
|
| 245 |
|
| 246 |
{/* Preset clothes options */}
|
|
@@ -295,13 +401,24 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
|
|
| 295 |
) : null}
|
| 296 |
|
| 297 |
<button
|
| 298 |
-
className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1"
|
| 299 |
onClick={() => onProcess(node.id)}
|
| 300 |
disabled={node.isRunning || !node.clothesImage}
|
|
|
|
| 301 |
>
|
| 302 |
{node.isRunning ? "Processing..." : "Apply Clothes"}
|
| 303 |
</button>
|
| 304 |
-
{node.output &&
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
{node.error && (
|
| 306 |
<div className="text-xs text-red-400 mt-2">{node.error}</div>
|
| 307 |
)}
|
|
@@ -312,9 +429,10 @@ export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, o
|
|
| 312 |
|
| 313 |
export function AgeNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
|
| 314 |
const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
|
|
|
|
| 315 |
|
| 316 |
return (
|
| 317 |
-
<div className=
|
| 318 |
<div
|
| 319 |
className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
|
| 320 |
onPointerDown={onPointerDown}
|
|
@@ -344,13 +462,24 @@ export function AgeNodeView({ node, onDelete, onUpdate, onStartConnection, onEnd
|
|
| 344 |
/>
|
| 345 |
</div>
|
| 346 |
<button
|
| 347 |
-
className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1"
|
| 348 |
onClick={() => onProcess(node.id)}
|
| 349 |
disabled={node.isRunning}
|
|
|
|
| 350 |
>
|
| 351 |
{node.isRunning ? "Processing..." : "Apply Age"}
|
| 352 |
</button>
|
| 353 |
-
{node.output &&
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
{node.error && (
|
| 355 |
<div className="text-xs text-red-400 mt-2">{node.error}</div>
|
| 356 |
)}
|
|
@@ -361,11 +490,16 @@ export function AgeNodeView({ node, onDelete, onUpdate, onStartConnection, onEnd
|
|
| 361 |
|
| 362 |
export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
|
| 363 |
const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
|
| 364 |
-
const focalLengths = ["None", "8mm fisheye", "12mm", "24mm", "35mm", "50mm", "85mm", "135mm", "200mm"];
|
| 365 |
-
const apertures = ["None", "f/1.2", "f/1.8", "f/2.8", "f/5.6", "f/8", "f/11", "f/16"];
|
| 366 |
-
const shutterSpeeds = ["None", "1/8000s", "1/250s", "1/30s", "5s"];
|
| 367 |
-
const whiteBalances = ["None", "3200K tungsten", "5600K daylight", "7000K shade"];
|
| 368 |
-
const angles = ["None", "eye level", "low angle", "high angle", "Dutch tilt", "bird's eye"];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
|
| 370 |
return (
|
| 371 |
<div className="nb-node absolute text-white w-[360px]" style={{ left: localPos.x, top: localPos.y }}>
|
|
@@ -382,7 +516,9 @@ export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, on
|
|
| 382 |
<Port className="out" nodeId={node.id} isOutput={true} onStartConnection={onStartConnection} />
|
| 383 |
</div>
|
| 384 |
</div>
|
| 385 |
-
<div className="p-3 space-y-2">
|
|
|
|
|
|
|
| 386 |
<div className="grid grid-cols-2 gap-2">
|
| 387 |
<div>
|
| 388 |
<label className="text-xs text-white/70">Focal Length</label>
|
|
@@ -405,7 +541,7 @@ export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, on
|
|
| 405 |
</select>
|
| 406 |
</div>
|
| 407 |
<div>
|
| 408 |
-
<label className="text-xs text-white/70">Shutter</label>
|
| 409 |
<select
|
| 410 |
className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
|
| 411 |
value={node.shutterSpeed || "None"}
|
|
@@ -414,6 +550,21 @@ export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, on
|
|
| 414 |
{shutterSpeeds.map(s => <option key={s} value={s}>{s}</option>)}
|
| 415 |
</select>
|
| 416 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
<div>
|
| 418 |
<label className="text-xs text-white/70">White Balance</label>
|
| 419 |
<select
|
|
@@ -424,25 +575,81 @@ export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, on
|
|
| 424 |
{whiteBalances.map(w => <option key={w} value={w}>{w}</option>)}
|
| 425 |
</select>
|
| 426 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
</div>
|
| 428 |
-
|
| 429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
<select
|
| 431 |
className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
|
| 432 |
value={node.angle || "None"}
|
| 433 |
onChange={(e) => onUpdate(node.id, { angle: e.target.value })}
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 437 |
</div>
|
| 438 |
<button
|
| 439 |
-
className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1"
|
| 440 |
onClick={() => onProcess(node.id)}
|
| 441 |
disabled={node.isRunning}
|
|
|
|
| 442 |
>
|
| 443 |
{node.isRunning ? "Processing..." : "Apply Camera Settings"}
|
| 444 |
</button>
|
| 445 |
-
{node.output &&
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
{node.error && (
|
| 447 |
<div className="text-xs text-red-400 mt-2">{node.error}</div>
|
| 448 |
)}
|
|
@@ -546,13 +753,24 @@ export function FaceNodeView({ node, onDelete, onUpdate, onStartConnection, onEn
|
|
| 546 |
</div>
|
| 547 |
|
| 548 |
<button
|
| 549 |
-
className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1"
|
| 550 |
onClick={() => onProcess(node.id)}
|
| 551 |
disabled={node.isRunning}
|
|
|
|
| 552 |
>
|
| 553 |
{node.isRunning ? "Processing..." : "Apply Face Changes"}
|
| 554 |
</button>
|
| 555 |
-
{node.output &&
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
{node.error && (
|
| 557 |
<div className="text-xs text-red-400 mt-2">{node.error}</div>
|
| 558 |
)}
|
|
@@ -660,13 +878,24 @@ export function BlendNodeView({ node, onDelete, onUpdate, onStartConnection, onE
|
|
| 660 |
/>
|
| 661 |
</div>
|
| 662 |
<button
|
| 663 |
-
className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1"
|
| 664 |
onClick={() => onProcess(node.id)}
|
| 665 |
disabled={node.isRunning || !node.styleImage}
|
|
|
|
| 666 |
>
|
| 667 |
{node.isRunning ? "Processing..." : "Apply Style"}
|
| 668 |
</button>
|
| 669 |
-
{node.output &&
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
{node.error && (
|
| 671 |
<div className="text-xs text-red-400 mt-2">{node.error}</div>
|
| 672 |
)}
|
|
@@ -702,13 +931,24 @@ export function EditNodeView({ node, onDelete, onUpdate, onStartConnection, onEn
|
|
| 702 |
rows={3}
|
| 703 |
/>
|
| 704 |
<button
|
| 705 |
-
className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1"
|
| 706 |
onClick={() => onProcess(node.id)}
|
| 707 |
disabled={node.isRunning}
|
|
|
|
| 708 |
>
|
| 709 |
{node.isRunning ? "Processing..." : "Apply Edit"}
|
| 710 |
</button>
|
| 711 |
-
{node.output &&
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 712 |
</div>
|
| 713 |
</div>
|
| 714 |
);
|
|
|
|
| 2 |
|
| 3 |
import React, { useState, useRef, useEffect } from "react";
|
| 4 |
|
| 5 |
+
// Helper function to download image
|
| 6 |
+
function downloadImage(dataUrl: string, filename: string) {
|
| 7 |
+
const link = document.createElement('a');
|
| 8 |
+
link.href = dataUrl;
|
| 9 |
+
link.download = filename;
|
| 10 |
+
document.body.appendChild(link);
|
| 11 |
+
link.click();
|
| 12 |
+
document.body.removeChild(link);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
// Import types (we'll need to export these from page.tsx)
|
| 16 |
type BackgroundNode = any;
|
| 17 |
type ClothesNode = any;
|
|
|
|
| 108 |
onUpdatePosition,
|
| 109 |
}: any) {
|
| 110 |
const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
|
| 111 |
+
const hasConfig = node.backgroundType && !node.output;
|
| 112 |
+
|
| 113 |
+
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 114 |
+
if (e.target.files?.length) {
|
| 115 |
+
const reader = new FileReader();
|
| 116 |
+
reader.onload = () => {
|
| 117 |
+
onUpdate(node.id, { customBackgroundImage: reader.result });
|
| 118 |
+
};
|
| 119 |
+
reader.readAsDataURL(e.target.files[0]);
|
| 120 |
+
}
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
const handleImagePaste = (e: React.ClipboardEvent) => {
|
| 124 |
+
const items = e.clipboardData.items;
|
| 125 |
+
for (let i = 0; i < items.length; i++) {
|
| 126 |
+
if (items[i].type.startsWith("image/")) {
|
| 127 |
+
const file = items[i].getAsFile();
|
| 128 |
+
if (file) {
|
| 129 |
+
const reader = new FileReader();
|
| 130 |
+
reader.onload = () => {
|
| 131 |
+
onUpdate(node.id, { customBackgroundImage: reader.result });
|
| 132 |
+
};
|
| 133 |
+
reader.readAsDataURL(file);
|
| 134 |
+
return;
|
| 135 |
+
}
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
const text = e.clipboardData.getData("text");
|
| 139 |
+
if (text && (text.startsWith("http") || text.startsWith("data:image"))) {
|
| 140 |
+
onUpdate(node.id, { customBackgroundImage: text });
|
| 141 |
+
}
|
| 142 |
+
};
|
| 143 |
+
|
| 144 |
+
const handleDrop = (e: React.DragEvent) => {
|
| 145 |
+
e.preventDefault();
|
| 146 |
+
const files = e.dataTransfer.files;
|
| 147 |
+
if (files && files.length) {
|
| 148 |
+
const reader = new FileReader();
|
| 149 |
+
reader.onload = () => {
|
| 150 |
+
onUpdate(node.id, { customBackgroundImage: reader.result });
|
| 151 |
+
};
|
| 152 |
+
reader.readAsDataURL(files[0]);
|
| 153 |
+
}
|
| 154 |
+
};
|
| 155 |
|
| 156 |
return (
|
| 157 |
+
<div
|
| 158 |
+
className={`nb-node absolute text-white w-[320px] ${hasConfig ? 'ring-2 ring-yellow-500/50' : ''}`}
|
| 159 |
+
style={{ left: localPos.x, top: localPos.y }}
|
| 160 |
+
onDrop={handleDrop}
|
| 161 |
+
onDragOver={(e) => e.preventDefault()}
|
| 162 |
+
onPaste={handleImagePaste}
|
| 163 |
+
>
|
| 164 |
<div
|
| 165 |
className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
|
| 166 |
onPointerDown={onPointerDown}
|
|
|
|
| 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 |
</select>
|
| 188 |
|
|
|
|
| 210 |
</select>
|
| 211 |
)}
|
| 212 |
|
| 213 |
+
{node.backgroundType === "upload" && (
|
| 214 |
+
<div className="space-y-2">
|
| 215 |
+
{node.customBackgroundImage ? (
|
| 216 |
+
<div className="relative">
|
| 217 |
+
<img src={node.customBackgroundImage} className="w-full rounded" alt="Custom Background" />
|
| 218 |
+
<button
|
| 219 |
+
className="absolute top-2 right-2 bg-red-500/80 text-white text-xs px-2 py-1 rounded"
|
| 220 |
+
onClick={() => onUpdate(node.id, { customBackgroundImage: null })}
|
| 221 |
+
>
|
| 222 |
+
Remove
|
| 223 |
+
</button>
|
| 224 |
+
</div>
|
| 225 |
+
) : (
|
| 226 |
+
<label className="block">
|
| 227 |
+
<input
|
| 228 |
+
type="file"
|
| 229 |
+
accept="image/*"
|
| 230 |
+
className="hidden"
|
| 231 |
+
onChange={handleImageUpload}
|
| 232 |
+
/>
|
| 233 |
+
<div className="border-2 border-dashed border-white/20 rounded-lg p-4 text-center cursor-pointer hover:border-white/40">
|
| 234 |
+
<p className="text-xs text-white/60">Drop, upload, or paste background image</p>
|
| 235 |
+
<p className="text-xs text-white/40 mt-1">JPG, PNG, WEBP</p>
|
| 236 |
+
</div>
|
| 237 |
+
</label>
|
| 238 |
+
)}
|
| 239 |
+
</div>
|
| 240 |
+
)}
|
| 241 |
+
|
| 242 |
{node.backgroundType === "custom" && (
|
| 243 |
<textarea
|
| 244 |
className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
|
|
|
|
| 250 |
)}
|
| 251 |
|
| 252 |
<button
|
| 253 |
+
className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1 transition-all"
|
| 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 |
</button>
|
| 260 |
|
| 261 |
{node.output && (
|
| 262 |
+
<div className="space-y-2">
|
| 263 |
+
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 264 |
+
<button
|
| 265 |
+
className="w-full text-xs bg-green-600 hover:bg-green-500 rounded px-3 py-1 transition-all"
|
| 266 |
+
onClick={() => downloadImage(node.output, `background-${Date.now()}.png`)}
|
| 267 |
+
>
|
| 268 |
+
π₯ Download Output
|
| 269 |
+
</button>
|
| 270 |
+
</div>
|
| 271 |
)}
|
| 272 |
{node.error && (
|
| 273 |
<div className="text-xs text-red-400 mt-2">{node.error}</div>
|
|
|
|
| 279 |
|
| 280 |
export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
|
| 281 |
const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
|
| 282 |
+
const hasConfig = node.clothesImage && !node.output;
|
| 283 |
|
| 284 |
const presetClothes = [
|
| 285 |
{ name: "Sukajan", path: "/sukajan.png" },
|
|
|
|
| 321 |
|
| 322 |
return (
|
| 323 |
<div
|
| 324 |
+
className={`nb-node absolute text-white w-[320px] ${hasConfig ? 'ring-2 ring-yellow-500/50' : ''}`}
|
| 325 |
style={{ left: localPos.x, top: localPos.y }}
|
| 326 |
onDrop={onDrop}
|
| 327 |
onDragOver={(e) => e.preventDefault()}
|
| 328 |
onPaste={onPaste}
|
| 329 |
+
title={hasConfig ? "Has unsaved configuration - will be applied when processing downstream" : ""}
|
| 330 |
>
|
| 331 |
<div
|
| 332 |
className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
|
|
|
|
| 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
|
| 348 |
+
</div>
|
| 349 |
+
)}
|
| 350 |
<div className="text-xs text-white/70">Clothes Reference</div>
|
| 351 |
|
| 352 |
{/* Preset clothes options */}
|
|
|
|
| 401 |
) : null}
|
| 402 |
|
| 403 |
<button
|
| 404 |
+
className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1 transition-all"
|
| 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 |
</button>
|
| 411 |
+
{node.output && (
|
| 412 |
+
<div className="space-y-2">
|
| 413 |
+
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 414 |
+
<button
|
| 415 |
+
className="w-full text-xs bg-green-600 hover:bg-green-500 rounded px-3 py-1 transition-all"
|
| 416 |
+
onClick={() => downloadImage(node.output, `clothes-${Date.now()}.png`)}
|
| 417 |
+
>
|
| 418 |
+
π₯ Download Output
|
| 419 |
+
</button>
|
| 420 |
+
</div>
|
| 421 |
+
)}
|
| 422 |
{node.error && (
|
| 423 |
<div className="text-xs text-red-400 mt-2">{node.error}</div>
|
| 424 |
)}
|
|
|
|
| 429 |
|
| 430 |
export function AgeNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
|
| 431 |
const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
|
| 432 |
+
const hasConfig = node.targetAge && node.targetAge !== 30 && !node.output;
|
| 433 |
|
| 434 |
return (
|
| 435 |
+
<div className={`nb-node absolute text-white w-[280px] ${hasConfig ? 'ring-2 ring-yellow-500/50' : ''}`} style={{ left: localPos.x, top: localPos.y }}>
|
| 436 |
<div
|
| 437 |
className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
|
| 438 |
onPointerDown={onPointerDown}
|
|
|
|
| 462 |
/>
|
| 463 |
</div>
|
| 464 |
<button
|
| 465 |
+
className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1 transition-all"
|
| 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 |
</button>
|
| 472 |
+
{node.output && (
|
| 473 |
+
<div className="space-y-2">
|
| 474 |
+
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 475 |
+
<button
|
| 476 |
+
className="w-full text-xs bg-green-600 hover:bg-green-500 rounded px-3 py-1 transition-all"
|
| 477 |
+
onClick={() => downloadImage(node.output, `age-${Date.now()}.png`)}
|
| 478 |
+
>
|
| 479 |
+
π₯ Download Output
|
| 480 |
+
</button>
|
| 481 |
+
</div>
|
| 482 |
+
)}
|
| 483 |
{node.error && (
|
| 484 |
<div className="text-xs text-red-400 mt-2">{node.error}</div>
|
| 485 |
)}
|
|
|
|
| 490 |
|
| 491 |
export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
|
| 492 |
const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
|
| 493 |
+
const focalLengths = ["None", "8mm fisheye", "12mm", "24mm", "35mm", "50mm", "85mm", "135mm", "200mm", "300mm", "400mm"];
|
| 494 |
+
const apertures = ["None", "f/0.95", "f/1.2", "f/1.4", "f/1.8", "f/2", "f/2.8", "f/4", "f/5.6", "f/8", "f/11", "f/16", "f/22"];
|
| 495 |
+
const shutterSpeeds = ["None", "1/8000s", "1/4000s", "1/2000s", "1/1000s", "1/500s", "1/250s", "1/125s", "1/60s", "1/30s", "1/15s", "1/8s", "1/4s", "1/2s", "1s", "2s", "5s", "10s", "30s"];
|
| 496 |
+
const whiteBalances = ["None", "2800K candlelight", "3200K tungsten", "4000K fluorescent", "5600K daylight", "6500K cloudy", "7000K shade", "8000K blue sky"];
|
| 497 |
+
const angles = ["None", "eye level", "low angle", "high angle", "Dutch tilt", "bird's eye", "worm's eye", "over the shoulder", "POV"];
|
| 498 |
+
const isoValues = ["None", "ISO 50", "ISO 100", "ISO 200", "ISO 400", "ISO 800", "ISO 1600", "ISO 3200", "ISO 6400", "ISO 12800"];
|
| 499 |
+
const filmStyles = ["None", "Kodak Portra", "Fuji Velvia", "Ilford HP5", "Cinestill 800T", "Lomography", "Cross Process", "Black & White", "Sepia", "Vintage", "Film Noir"];
|
| 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 }}>
|
|
|
|
| 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>
|
|
|
|
| 541 |
</select>
|
| 542 |
</div>
|
| 543 |
<div>
|
| 544 |
+
<label className="text-xs text-white/70">Shutter Speed</label>
|
| 545 |
<select
|
| 546 |
className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
|
| 547 |
value={node.shutterSpeed || "None"}
|
|
|
|
| 550 |
{shutterSpeeds.map(s => <option key={s} value={s}>{s}</option>)}
|
| 551 |
</select>
|
| 552 |
</div>
|
| 553 |
+
<div>
|
| 554 |
+
<label className="text-xs text-white/70">ISO</label>
|
| 555 |
+
<select
|
| 556 |
+
className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
|
| 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 |
+
</select>
|
| 562 |
+
</div>
|
| 563 |
+
</div>
|
| 564 |
+
|
| 565 |
+
{/* Creative Settings */}
|
| 566 |
+
<div className="text-xs text-white/50 font-semibold mb-1 mt-3">Creative Settings</div>
|
| 567 |
+
<div className="grid grid-cols-2 gap-2">
|
| 568 |
<div>
|
| 569 |
<label className="text-xs text-white/70">White Balance</label>
|
| 570 |
<select
|
|
|
|
| 575 |
{whiteBalances.map(w => <option key={w} value={w}>{w}</option>)}
|
| 576 |
</select>
|
| 577 |
</div>
|
| 578 |
+
<div>
|
| 579 |
+
<label className="text-xs text-white/70">Film Style</label>
|
| 580 |
+
<select
|
| 581 |
+
className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
|
| 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 |
+
</select>
|
| 587 |
+
</div>
|
| 588 |
+
<div>
|
| 589 |
+
<label className="text-xs text-white/70">Lighting</label>
|
| 590 |
+
<select
|
| 591 |
+
className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
|
| 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 |
+
</select>
|
| 597 |
+
</div>
|
| 598 |
+
<div>
|
| 599 |
+
<label className="text-xs text-white/70">Bokeh Style</label>
|
| 600 |
+
<select
|
| 601 |
+
className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
|
| 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 |
+
</select>
|
| 607 |
+
</div>
|
| 608 |
</div>
|
| 609 |
+
|
| 610 |
+
{/* Composition Settings */}
|
| 611 |
+
<div className="text-xs text-white/50 font-semibold mb-1 mt-3">Composition</div>
|
| 612 |
+
<div className="grid grid-cols-2 gap-2">
|
| 613 |
+
<div>
|
| 614 |
+
<label className="text-xs text-white/70">Camera Angle</label>
|
| 615 |
<select
|
| 616 |
className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
|
| 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 |
+
</select>
|
| 622 |
+
</div>
|
| 623 |
+
<div>
|
| 624 |
+
<label className="text-xs text-white/70">Composition</label>
|
| 625 |
+
<select
|
| 626 |
+
className="w-full bg-black/40 border border-white/10 rounded px-2 py-1 text-xs"
|
| 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 |
+
</select>
|
| 632 |
+
</div>
|
| 633 |
</div>
|
| 634 |
<button
|
| 635 |
+
className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1 transition-all"
|
| 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 |
</button>
|
| 642 |
+
{node.output && (
|
| 643 |
+
<div className="space-y-2 mt-2">
|
| 644 |
+
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 645 |
+
<button
|
| 646 |
+
className="w-full text-xs bg-green-600 hover:bg-green-500 rounded px-3 py-1 transition-all"
|
| 647 |
+
onClick={() => downloadImage(node.output, `camera-${Date.now()}.png`)}
|
| 648 |
+
>
|
| 649 |
+
π₯ Download Output
|
| 650 |
+
</button>
|
| 651 |
+
</div>
|
| 652 |
+
)}
|
| 653 |
{node.error && (
|
| 654 |
<div className="text-xs text-red-400 mt-2">{node.error}</div>
|
| 655 |
)}
|
|
|
|
| 753 |
</div>
|
| 754 |
|
| 755 |
<button
|
| 756 |
+
className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1 transition-all"
|
| 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 |
</button>
|
| 763 |
+
{node.output && (
|
| 764 |
+
<div className="space-y-2 mt-2">
|
| 765 |
+
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 766 |
+
<button
|
| 767 |
+
className="w-full text-xs bg-green-600 hover:bg-green-500 rounded px-3 py-1 transition-all"
|
| 768 |
+
onClick={() => downloadImage(node.output, `face-${Date.now()}.png`)}
|
| 769 |
+
>
|
| 770 |
+
π₯ Download Output
|
| 771 |
+
</button>
|
| 772 |
+
</div>
|
| 773 |
+
)}
|
| 774 |
{node.error && (
|
| 775 |
<div className="text-xs text-red-400 mt-2">{node.error}</div>
|
| 776 |
)}
|
|
|
|
| 878 |
/>
|
| 879 |
</div>
|
| 880 |
<button
|
| 881 |
+
className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1 transition-all"
|
| 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" : "Process all unprocessed nodes in chain"}
|
| 885 |
>
|
| 886 |
{node.isRunning ? "Processing..." : "Apply Style"}
|
| 887 |
</button>
|
| 888 |
+
{node.output && (
|
| 889 |
+
<div className="space-y-2">
|
| 890 |
+
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 891 |
+
<button
|
| 892 |
+
className="w-full text-xs bg-green-600 hover:bg-green-500 rounded px-3 py-1 transition-all"
|
| 893 |
+
onClick={() => downloadImage(node.output, `blend-${Date.now()}.png`)}
|
| 894 |
+
>
|
| 895 |
+
π₯ Download Output
|
| 896 |
+
</button>
|
| 897 |
+
</div>
|
| 898 |
+
)}
|
| 899 |
{node.error && (
|
| 900 |
<div className="text-xs text-red-400 mt-2">{node.error}</div>
|
| 901 |
)}
|
|
|
|
| 931 |
rows={3}
|
| 932 |
/>
|
| 933 |
<button
|
| 934 |
+
className="w-full text-xs bg-indigo-500 hover:bg-indigo-400 rounded px-3 py-1 transition-all"
|
| 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 |
</button>
|
| 941 |
+
{node.output && (
|
| 942 |
+
<div className="space-y-2">
|
| 943 |
+
<img src={node.output} className="w-full rounded" alt="Output" />
|
| 944 |
+
<button
|
| 945 |
+
className="w-full text-xs bg-green-600 hover:bg-green-500 rounded px-3 py-1 transition-all"
|
| 946 |
+
onClick={() => downloadImage(node.output, `edit-${Date.now()}.png`)}
|
| 947 |
+
>
|
| 948 |
+
π₯ Download Output
|
| 949 |
+
</button>
|
| 950 |
+
</div>
|
| 951 |
+
)}
|
| 952 |
</div>
|
| 953 |
</div>
|
| 954 |
);
|
app/editor/page.tsx
CHANGED
|
@@ -65,9 +65,11 @@ type BackgroundNode = NodeBase & {
|
|
| 65 |
type: "BACKGROUND";
|
| 66 |
input?: string; // node id
|
| 67 |
output?: string;
|
| 68 |
-
backgroundType: "color" | "image" | "custom";
|
| 69 |
backgroundColor?: string;
|
| 70 |
backgroundImage?: string;
|
|
|
|
|
|
|
| 71 |
isRunning?: boolean;
|
| 72 |
error?: string | null;
|
| 73 |
};
|
|
@@ -77,6 +79,7 @@ type ClothesNode = NodeBase & {
|
|
| 77 |
input?: string;
|
| 78 |
output?: string;
|
| 79 |
clothesImage?: string;
|
|
|
|
| 80 |
clothesPrompt?: string;
|
| 81 |
isRunning?: boolean;
|
| 82 |
error?: string | null;
|
|
@@ -86,6 +89,7 @@ type BlendNode = NodeBase & {
|
|
| 86 |
type: "BLEND";
|
| 87 |
input?: string;
|
| 88 |
output?: string;
|
|
|
|
| 89 |
stylePrompt?: string;
|
| 90 |
blendStrength?: number;
|
| 91 |
isRunning?: boolean;
|
|
@@ -110,6 +114,11 @@ type CameraNode = NodeBase & {
|
|
| 110 |
shutterSpeed?: string;
|
| 111 |
whiteBalance?: string;
|
| 112 |
angle?: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
isRunning?: boolean;
|
| 114 |
error?: string | null;
|
| 115 |
};
|
|
@@ -399,6 +408,7 @@ function MergeNodeView({
|
|
| 399 |
onDisconnect,
|
| 400 |
onRun,
|
| 401 |
onEndConnection,
|
|
|
|
| 402 |
onUpdatePosition,
|
| 403 |
onDelete,
|
| 404 |
onClearConnections,
|
|
@@ -409,6 +419,7 @@ function MergeNodeView({
|
|
| 409 |
onDisconnect: (mergeId: string, characterId: string) => void;
|
| 410 |
onRun: (mergeId: string) => void;
|
| 411 |
onEndConnection: (mergeId: string) => void;
|
|
|
|
| 412 |
onUpdatePosition: (id: string, x: number, y: number) => void;
|
| 413 |
onDelete: (id: string) => void;
|
| 414 |
onClearConnections: (mergeId: string) => void;
|
|
@@ -435,19 +446,27 @@ function MergeNodeView({
|
|
| 435 |
isOutput={false}
|
| 436 |
onEndConnection={onEndConnection}
|
| 437 |
/>
|
| 438 |
-
<div className="font-semibold tracking-wide text-sm flex-1">MERGE</div>
|
| 439 |
-
<
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
e
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
</div>
|
| 452 |
<div className="p-3 space-y-3">
|
| 453 |
<div className="text-xs text-white/70">Inputs</div>
|
|
@@ -502,6 +521,21 @@ function MergeNodeView({
|
|
| 502 |
<span className="text-white/40 text-xs">Run merge to see result</span>
|
| 503 |
)}
|
| 504 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 505 |
{node.error && (
|
| 506 |
<div className="mt-2">
|
| 507 |
<div className="text-xs text-red-400">{node.error}</div>
|
|
@@ -630,15 +664,17 @@ export default function EditorPage() {
|
|
| 630 |
// Handle single input connections for new nodes
|
| 631 |
const handleEndSingleConnection = (nodeId: string) => {
|
| 632 |
if (draggingFrom) {
|
| 633 |
-
// Find the source node
|
| 634 |
const sourceNode = nodes.find(n => n.id === draggingFrom);
|
| 635 |
-
if (sourceNode
|
| 636 |
-
//
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
)
|
| 640 |
-
|
| 641 |
-
//
|
|
|
|
|
|
|
| 642 |
setNodes(prev => prev.map(n =>
|
| 643 |
n.id === nodeId ? { ...n, input: draggingFrom } : n
|
| 644 |
));
|
|
@@ -648,6 +684,112 @@ export default function EditorPage() {
|
|
| 648 |
}
|
| 649 |
};
|
| 650 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 651 |
// Process node with API
|
| 652 |
const processNode = async (nodeId: string) => {
|
| 653 |
const node = nodes.find(n => n.id === nodeId);
|
|
@@ -656,24 +798,156 @@ export default function EditorPage() {
|
|
| 656 |
return;
|
| 657 |
}
|
| 658 |
|
| 659 |
-
// Get input image
|
| 660 |
let inputImage: string | null = null;
|
|
|
|
|
|
|
| 661 |
const inputId = (node as any).input;
|
| 662 |
|
| 663 |
if (inputId) {
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
}
|
| 671 |
}
|
|
|
|
|
|
|
|
|
|
| 672 |
}
|
| 673 |
|
| 674 |
if (!inputImage) {
|
| 675 |
const errorMsg = inputId
|
| 676 |
-
? "
|
| 677 |
: "No input connected. Connect an image source to this node.";
|
| 678 |
setNodes(prev => prev.map(n =>
|
| 679 |
n.id === nodeId ? { ...n, error: errorMsg, isRunning: false } : n
|
|
@@ -681,54 +955,37 @@ export default function EditorPage() {
|
|
| 681 |
return;
|
| 682 |
}
|
| 683 |
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 690 |
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
switch (node.type) {
|
| 696 |
-
case "BACKGROUND":
|
| 697 |
-
params.backgroundType = (node as BackgroundNode).backgroundType;
|
| 698 |
-
params.backgroundColor = (node as BackgroundNode).backgroundColor;
|
| 699 |
-
params.backgroundImage = (node as BackgroundNode).backgroundImage;
|
| 700 |
-
params.customPrompt = (node as BackgroundNode).customPrompt;
|
| 701 |
-
break;
|
| 702 |
-
case "CLOTHES":
|
| 703 |
-
params.clothesPrompt = (node as ClothesNode).clothesPrompt;
|
| 704 |
-
break;
|
| 705 |
-
case "BLEND":
|
| 706 |
-
params.stylePrompt = (node as BlendNode).stylePrompt;
|
| 707 |
-
params.blendStrength = (node as BlendNode).blendStrength;
|
| 708 |
-
break;
|
| 709 |
-
case "EDIT":
|
| 710 |
-
params.editPrompt = (node as EditNode).editPrompt;
|
| 711 |
-
break;
|
| 712 |
-
case "CAMERA":
|
| 713 |
-
params.focalLength = (node as CameraNode).focalLength;
|
| 714 |
-
params.aperture = (node as CameraNode).aperture;
|
| 715 |
-
params.shutterSpeed = (node as CameraNode).shutterSpeed;
|
| 716 |
-
params.whiteBalance = (node as CameraNode).whiteBalance;
|
| 717 |
-
params.angle = (node as CameraNode).angle;
|
| 718 |
-
break;
|
| 719 |
-
case "AGE":
|
| 720 |
-
params.targetAge = (node as AgeNode).targetAge;
|
| 721 |
-
break;
|
| 722 |
-
case "FACE":
|
| 723 |
-
params.faceOptions = (node as FaceNode).faceOptions;
|
| 724 |
-
break;
|
| 725 |
}
|
|
|
|
|
|
|
| 726 |
|
|
|
|
|
|
|
| 727 |
const res = await fetch("/api/process", {
|
| 728 |
method: "POST",
|
| 729 |
headers: { "Content-Type": "application/json" },
|
| 730 |
body: JSON.stringify({
|
| 731 |
-
type:
|
| 732 |
image: inputImage,
|
| 733 |
params
|
| 734 |
}),
|
|
@@ -737,14 +994,33 @@ export default function EditorPage() {
|
|
| 737 |
const data = await res.json();
|
| 738 |
if (!res.ok) throw new Error(data.error || "Processing failed");
|
| 739 |
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 743 |
} catch (e: any) {
|
| 744 |
console.error("Process error:", e);
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 748 |
}
|
| 749 |
};
|
| 750 |
|
|
@@ -795,6 +1071,51 @@ export default function EditorPage() {
|
|
| 795 |
);
|
| 796 |
};
|
| 797 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 798 |
const runMerge = async (mergeId: string) => {
|
| 799 |
setNodes((prev) => prev.map((n) => (n.id === mergeId && n.type === "MERGE" ? { ...n, isRunning: true, error: null } : n)));
|
| 800 |
try {
|
|
@@ -850,7 +1171,7 @@ export default function EditorPage() {
|
|
| 850 |
MERGE: 380,
|
| 851 |
BACKGROUND: 320,
|
| 852 |
CLOTHES: 320,
|
| 853 |
-
BLEND:
|
| 854 |
EDIT: 320,
|
| 855 |
CAMERA: 360,
|
| 856 |
AGE: 280,
|
|
@@ -1087,6 +1408,7 @@ export default function EditorPage() {
|
|
| 1087 |
onDisconnect={disconnectFromMerge}
|
| 1088 |
onRun={runMerge}
|
| 1089 |
onEndConnection={handleEndConnection}
|
|
|
|
| 1090 |
onUpdatePosition={updateNodePosition}
|
| 1091 |
onDelete={deleteNode}
|
| 1092 |
onClearConnections={clearMergeConnections}
|
|
|
|
| 65 |
type: "BACKGROUND";
|
| 66 |
input?: string; // node id
|
| 67 |
output?: string;
|
| 68 |
+
backgroundType: "color" | "image" | "upload" | "custom";
|
| 69 |
backgroundColor?: string;
|
| 70 |
backgroundImage?: string;
|
| 71 |
+
customBackgroundImage?: string;
|
| 72 |
+
customPrompt?: string;
|
| 73 |
isRunning?: boolean;
|
| 74 |
error?: string | null;
|
| 75 |
};
|
|
|
|
| 79 |
input?: string;
|
| 80 |
output?: string;
|
| 81 |
clothesImage?: string;
|
| 82 |
+
selectedPreset?: string;
|
| 83 |
clothesPrompt?: string;
|
| 84 |
isRunning?: boolean;
|
| 85 |
error?: string | null;
|
|
|
|
| 89 |
type: "BLEND";
|
| 90 |
input?: string;
|
| 91 |
output?: string;
|
| 92 |
+
styleImage?: string;
|
| 93 |
stylePrompt?: string;
|
| 94 |
blendStrength?: number;
|
| 95 |
isRunning?: boolean;
|
|
|
|
| 114 |
shutterSpeed?: string;
|
| 115 |
whiteBalance?: string;
|
| 116 |
angle?: string;
|
| 117 |
+
iso?: string;
|
| 118 |
+
filmStyle?: string;
|
| 119 |
+
lighting?: string;
|
| 120 |
+
bokeh?: string;
|
| 121 |
+
composition?: string;
|
| 122 |
isRunning?: boolean;
|
| 123 |
error?: string | null;
|
| 124 |
};
|
|
|
|
| 408 |
onDisconnect,
|
| 409 |
onRun,
|
| 410 |
onEndConnection,
|
| 411 |
+
onStartConnection,
|
| 412 |
onUpdatePosition,
|
| 413 |
onDelete,
|
| 414 |
onClearConnections,
|
|
|
|
| 419 |
onDisconnect: (mergeId: string, characterId: string) => void;
|
| 420 |
onRun: (mergeId: string) => void;
|
| 421 |
onEndConnection: (mergeId: string) => void;
|
| 422 |
+
onStartConnection: (nodeId: string) => void;
|
| 423 |
onUpdatePosition: (id: string, x: number, y: number) => void;
|
| 424 |
onDelete: (id: string) => void;
|
| 425 |
onClearConnections: (mergeId: string) => void;
|
|
|
|
| 446 |
isOutput={false}
|
| 447 |
onEndConnection={onEndConnection}
|
| 448 |
/>
|
| 449 |
+
<div className="font-semibold tracking-wide text-sm flex-1 text-center">MERGE</div>
|
| 450 |
+
<div className="flex items-center gap-2">
|
| 451 |
+
<button
|
| 452 |
+
className="text-2xl leading-none font-bold text-red-400 hover:text-red-300 opacity-50 hover:opacity-100 transition-all hover:scale-110 px-1"
|
| 453 |
+
onClick={(e) => {
|
| 454 |
+
e.stopPropagation();
|
| 455 |
+
if (confirm('Delete MERGE node?')) {
|
| 456 |
+
onDelete(node.id);
|
| 457 |
+
}
|
| 458 |
+
}}
|
| 459 |
+
title="Delete node"
|
| 460 |
+
>
|
| 461 |
+
Γ
|
| 462 |
+
</button>
|
| 463 |
+
<Port
|
| 464 |
+
className="out"
|
| 465 |
+
nodeId={node.id}
|
| 466 |
+
isOutput={true}
|
| 467 |
+
onStartConnection={onStartConnection}
|
| 468 |
+
/>
|
| 469 |
+
</div>
|
| 470 |
</div>
|
| 471 |
<div className="p-3 space-y-3">
|
| 472 |
<div className="text-xs text-white/70">Inputs</div>
|
|
|
|
| 521 |
<span className="text-white/40 text-xs">Run merge to see result</span>
|
| 522 |
)}
|
| 523 |
</div>
|
| 524 |
+
{node.output && (
|
| 525 |
+
<button
|
| 526 |
+
className="w-full text-xs bg-green-600 hover:bg-green-500 rounded px-3 py-1 mt-2 transition-all"
|
| 527 |
+
onClick={() => {
|
| 528 |
+
const link = document.createElement('a');
|
| 529 |
+
link.href = node.output as string;
|
| 530 |
+
link.download = `merge-${Date.now()}.png`;
|
| 531 |
+
document.body.appendChild(link);
|
| 532 |
+
link.click();
|
| 533 |
+
document.body.removeChild(link);
|
| 534 |
+
}}
|
| 535 |
+
>
|
| 536 |
+
π₯ Download Merged Image
|
| 537 |
+
</button>
|
| 538 |
+
)}
|
| 539 |
{node.error && (
|
| 540 |
<div className="mt-2">
|
| 541 |
<div className="text-xs text-red-400">{node.error}</div>
|
|
|
|
| 664 |
// Handle single input connections for new nodes
|
| 665 |
const handleEndSingleConnection = (nodeId: string) => {
|
| 666 |
if (draggingFrom) {
|
| 667 |
+
// Find the source node
|
| 668 |
const sourceNode = nodes.find(n => n.id === draggingFrom);
|
| 669 |
+
if (sourceNode) {
|
| 670 |
+
// Allow connections from ANY node that has an output port
|
| 671 |
+
// This includes:
|
| 672 |
+
// - CHARACTER nodes (always have an image)
|
| 673 |
+
// - MERGE nodes (can have output after merging)
|
| 674 |
+
// - Any processing node (BACKGROUND, CLOTHES, BLEND, etc.)
|
| 675 |
+
// - Even unprocessed nodes (for configuration chaining)
|
| 676 |
+
|
| 677 |
+
// All nodes can be connected for chaining
|
| 678 |
setNodes(prev => prev.map(n =>
|
| 679 |
n.id === nodeId ? { ...n, input: draggingFrom } : n
|
| 680 |
));
|
|
|
|
| 684 |
}
|
| 685 |
};
|
| 686 |
|
| 687 |
+
// Helper to count pending configurations in chain
|
| 688 |
+
const countPendingConfigurations = (startNodeId: string): number => {
|
| 689 |
+
let count = 0;
|
| 690 |
+
const visited = new Set<string>();
|
| 691 |
+
|
| 692 |
+
const traverse = (nodeId: string) => {
|
| 693 |
+
if (visited.has(nodeId)) return;
|
| 694 |
+
visited.add(nodeId);
|
| 695 |
+
|
| 696 |
+
const node = nodes.find(n => n.id === nodeId);
|
| 697 |
+
if (!node) return;
|
| 698 |
+
|
| 699 |
+
// Check if this node has configuration but no output
|
| 700 |
+
if (!(node as any).output && node.type !== "CHARACTER" && node.type !== "MERGE") {
|
| 701 |
+
const config = getNodeConfiguration(node);
|
| 702 |
+
if (Object.keys(config).length > 0) {
|
| 703 |
+
count++;
|
| 704 |
+
}
|
| 705 |
+
}
|
| 706 |
+
|
| 707 |
+
// Check upstream
|
| 708 |
+
const upstreamId = (node as any).input;
|
| 709 |
+
if (upstreamId) {
|
| 710 |
+
traverse(upstreamId);
|
| 711 |
+
}
|
| 712 |
+
};
|
| 713 |
+
|
| 714 |
+
traverse(startNodeId);
|
| 715 |
+
return count;
|
| 716 |
+
};
|
| 717 |
+
|
| 718 |
+
// Helper to extract configuration from a node
|
| 719 |
+
const getNodeConfiguration = (node: AnyNode): any => {
|
| 720 |
+
const config: any = {};
|
| 721 |
+
|
| 722 |
+
switch (node.type) {
|
| 723 |
+
case "BACKGROUND":
|
| 724 |
+
if ((node as BackgroundNode).backgroundType) {
|
| 725 |
+
config.backgroundType = (node as BackgroundNode).backgroundType;
|
| 726 |
+
config.backgroundColor = (node as BackgroundNode).backgroundColor;
|
| 727 |
+
config.backgroundImage = (node as BackgroundNode).backgroundImage;
|
| 728 |
+
config.customBackgroundImage = (node as BackgroundNode).customBackgroundImage;
|
| 729 |
+
config.customPrompt = (node as BackgroundNode).customPrompt;
|
| 730 |
+
}
|
| 731 |
+
break;
|
| 732 |
+
case "CLOTHES":
|
| 733 |
+
if ((node as ClothesNode).clothesImage) {
|
| 734 |
+
config.clothesImage = (node as ClothesNode).clothesImage;
|
| 735 |
+
config.selectedPreset = (node as ClothesNode).selectedPreset;
|
| 736 |
+
}
|
| 737 |
+
break;
|
| 738 |
+
case "BLEND":
|
| 739 |
+
if ((node as BlendNode).styleImage) {
|
| 740 |
+
config.styleImage = (node as BlendNode).styleImage;
|
| 741 |
+
config.blendStrength = (node as BlendNode).blendStrength;
|
| 742 |
+
}
|
| 743 |
+
break;
|
| 744 |
+
case "EDIT":
|
| 745 |
+
if ((node as EditNode).editPrompt) {
|
| 746 |
+
config.editPrompt = (node as EditNode).editPrompt;
|
| 747 |
+
}
|
| 748 |
+
break;
|
| 749 |
+
case "CAMERA":
|
| 750 |
+
const cam = node as CameraNode;
|
| 751 |
+
if (cam.focalLength && cam.focalLength !== "None") config.focalLength = cam.focalLength;
|
| 752 |
+
if (cam.aperture && cam.aperture !== "None") config.aperture = cam.aperture;
|
| 753 |
+
if (cam.shutterSpeed && cam.shutterSpeed !== "None") config.shutterSpeed = cam.shutterSpeed;
|
| 754 |
+
if (cam.whiteBalance && cam.whiteBalance !== "None") config.whiteBalance = cam.whiteBalance;
|
| 755 |
+
if (cam.angle && cam.angle !== "None") config.angle = cam.angle;
|
| 756 |
+
if (cam.iso && cam.iso !== "None") config.iso = cam.iso;
|
| 757 |
+
if (cam.filmStyle && cam.filmStyle !== "None") config.filmStyle = cam.filmStyle;
|
| 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) {
|
| 764 |
+
config.targetAge = (node as AgeNode).targetAge;
|
| 765 |
+
}
|
| 766 |
+
break;
|
| 767 |
+
case "FACE":
|
| 768 |
+
const face = node as FaceNode;
|
| 769 |
+
if (face.faceOptions) {
|
| 770 |
+
const opts: any = {};
|
| 771 |
+
if (face.faceOptions.removePimples) opts.removePimples = true;
|
| 772 |
+
if (face.faceOptions.addSunglasses) opts.addSunglasses = true;
|
| 773 |
+
if (face.faceOptions.addHat) opts.addHat = true;
|
| 774 |
+
if (face.faceOptions.changeHairstyle && face.faceOptions.changeHairstyle !== "None") {
|
| 775 |
+
opts.changeHairstyle = face.faceOptions.changeHairstyle;
|
| 776 |
+
}
|
| 777 |
+
if (face.faceOptions.facialExpression && face.faceOptions.facialExpression !== "None") {
|
| 778 |
+
opts.facialExpression = face.faceOptions.facialExpression;
|
| 779 |
+
}
|
| 780 |
+
if (face.faceOptions.beardStyle && face.faceOptions.beardStyle !== "None") {
|
| 781 |
+
opts.beardStyle = face.faceOptions.beardStyle;
|
| 782 |
+
}
|
| 783 |
+
if (Object.keys(opts).length > 0) {
|
| 784 |
+
config.faceOptions = opts;
|
| 785 |
+
}
|
| 786 |
+
}
|
| 787 |
+
break;
|
| 788 |
+
}
|
| 789 |
+
|
| 790 |
+
return config;
|
| 791 |
+
};
|
| 792 |
+
|
| 793 |
// Process node with API
|
| 794 |
const processNode = async (nodeId: string) => {
|
| 795 |
const node = nodes.find(n => n.id === nodeId);
|
|
|
|
| 798 |
return;
|
| 799 |
}
|
| 800 |
|
| 801 |
+
// Get input image and collect all configurations from chain
|
| 802 |
let inputImage: string | null = null;
|
| 803 |
+
let accumulatedParams: any = {};
|
| 804 |
+
const processedNodes: string[] = []; // Track which nodes' configs we're applying
|
| 805 |
const inputId = (node as any).input;
|
| 806 |
|
| 807 |
if (inputId) {
|
| 808 |
+
// Track unprocessed MERGE nodes that need to be executed
|
| 809 |
+
const unprocessedMerges: MergeNode[] = [];
|
| 810 |
+
|
| 811 |
+
// Find the source image by traversing the chain backwards
|
| 812 |
+
const findSourceImage = (currentNodeId: string, visited: Set<string> = new Set()): string | null => {
|
| 813 |
+
if (visited.has(currentNodeId)) return null;
|
| 814 |
+
visited.add(currentNodeId);
|
| 815 |
+
|
| 816 |
+
const currentNode = nodes.find(n => n.id === currentNodeId);
|
| 817 |
+
if (!currentNode) return null;
|
| 818 |
+
|
| 819 |
+
// If this is a CHARACTER node, return its image
|
| 820 |
+
if (currentNode.type === "CHARACTER") {
|
| 821 |
+
return (currentNode as CharacterNode).image;
|
| 822 |
+
}
|
| 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
|
| 830 |
+
if ((currentNode as any).output) {
|
| 831 |
+
return (currentNode as any).output;
|
| 832 |
+
}
|
| 833 |
+
|
| 834 |
+
// For MERGE nodes without output, we need to process them first
|
| 835 |
+
if (currentNode.type === "MERGE") {
|
| 836 |
+
const merge = currentNode as MergeNode;
|
| 837 |
+
if (!merge.output && merge.inputs.length >= 2) {
|
| 838 |
+
// Mark this merge for processing
|
| 839 |
+
unprocessedMerges.push(merge);
|
| 840 |
+
// For now, return null - we'll process the merge first
|
| 841 |
+
return null;
|
| 842 |
+
} else if (merge.inputs.length > 0) {
|
| 843 |
+
// Try to get image from first input if merge can't be executed
|
| 844 |
+
const firstInput = merge.inputs[0];
|
| 845 |
+
const inputImage = findSourceImage(firstInput, visited);
|
| 846 |
+
if (inputImage) return inputImage;
|
| 847 |
+
}
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
// Otherwise, check upstream
|
| 851 |
+
const upstreamId = (currentNode as any).input;
|
| 852 |
+
if (upstreamId) {
|
| 853 |
+
return findSourceImage(upstreamId, visited);
|
| 854 |
+
}
|
| 855 |
+
|
| 856 |
+
return null;
|
| 857 |
+
};
|
| 858 |
+
|
| 859 |
+
// Collect all configurations from unprocessed nodes in the chain
|
| 860 |
+
const collectConfigurations = (currentNodeId: string, visited: Set<string> = new Set()): any => {
|
| 861 |
+
if (visited.has(currentNodeId)) return {};
|
| 862 |
+
visited.add(currentNodeId);
|
| 863 |
+
|
| 864 |
+
const currentNode = nodes.find(n => n.id === currentNodeId);
|
| 865 |
+
if (!currentNode) return {};
|
| 866 |
+
|
| 867 |
+
let configs: any = {};
|
| 868 |
+
|
| 869 |
+
// First, collect from upstream nodes
|
| 870 |
+
const upstreamId = (currentNode as any).input;
|
| 871 |
+
if (upstreamId) {
|
| 872 |
+
configs = collectConfigurations(upstreamId, visited);
|
| 873 |
+
}
|
| 874 |
+
|
| 875 |
+
// Add this node's configuration only if:
|
| 876 |
+
// 1. It's the current node being processed, OR
|
| 877 |
+
// 2. It hasn't been processed yet (no output) AND it's not the current node
|
| 878 |
+
const shouldIncludeConfig =
|
| 879 |
+
currentNodeId === nodeId || // Always include current node's config
|
| 880 |
+
(!(currentNode as any).output && currentNodeId !== nodeId); // Include unprocessed intermediate nodes
|
| 881 |
+
|
| 882 |
+
if (shouldIncludeConfig) {
|
| 883 |
+
const nodeConfig = getNodeConfiguration(currentNode);
|
| 884 |
+
if (Object.keys(nodeConfig).length > 0) {
|
| 885 |
+
configs = { ...configs, ...nodeConfig };
|
| 886 |
+
// Track unprocessed intermediate nodes
|
| 887 |
+
if (currentNodeId !== nodeId && !(currentNode as any).output) {
|
| 888 |
+
processedNodes.push(currentNodeId);
|
| 889 |
+
}
|
| 890 |
+
}
|
| 891 |
+
}
|
| 892 |
+
|
| 893 |
+
return configs;
|
| 894 |
+
};
|
| 895 |
+
|
| 896 |
+
// Find the source image
|
| 897 |
+
inputImage = findSourceImage(inputId);
|
| 898 |
+
|
| 899 |
+
// If we found unprocessed merges, we need to execute them first
|
| 900 |
+
if (unprocessedMerges.length > 0 && !inputImage) {
|
| 901 |
+
console.log(`Found ${unprocessedMerges.length} unprocessed MERGE nodes in chain. Processing them first...`);
|
| 902 |
+
|
| 903 |
+
// Process each merge node
|
| 904 |
+
for (const merge of unprocessedMerges) {
|
| 905 |
+
// Set loading state for the merge
|
| 906 |
+
setNodes(prev => prev.map(n =>
|
| 907 |
+
n.id === merge.id ? { ...n, isRunning: true, error: null } : n
|
| 908 |
+
));
|
| 909 |
+
|
| 910 |
+
try {
|
| 911 |
+
const mergeOutput = await executeMerge(merge);
|
| 912 |
+
|
| 913 |
+
// Update the merge node with output
|
| 914 |
+
setNodes(prev => prev.map(n =>
|
| 915 |
+
n.id === merge.id ? { ...n, output: mergeOutput, isRunning: false, error: null } : n
|
| 916 |
+
));
|
| 917 |
+
|
| 918 |
+
// Track that we processed this merge as part of the chain
|
| 919 |
+
processedNodes.push(merge.id);
|
| 920 |
+
|
| 921 |
+
// Now use this as our input image if it's the direct input
|
| 922 |
+
if (inputId === merge.id) {
|
| 923 |
+
inputImage = mergeOutput;
|
| 924 |
+
}
|
| 925 |
+
} catch (e: any) {
|
| 926 |
+
console.error("Auto-merge error:", e);
|
| 927 |
+
setNodes(prev => prev.map(n =>
|
| 928 |
+
n.id === merge.id ? { ...n, isRunning: false, error: e?.message || "Merge failed" } : n
|
| 929 |
+
));
|
| 930 |
+
// Abort the main processing if merge failed
|
| 931 |
+
setNodes(prev => prev.map(n =>
|
| 932 |
+
n.id === nodeId ? { ...n, error: "Failed to process upstream MERGE node", isRunning: false } : n
|
| 933 |
+
));
|
| 934 |
+
return;
|
| 935 |
+
}
|
| 936 |
+
}
|
| 937 |
+
|
| 938 |
+
// After processing merges, try to find the source image again
|
| 939 |
+
if (!inputImage) {
|
| 940 |
+
inputImage = findSourceImage(inputId);
|
| 941 |
}
|
| 942 |
}
|
| 943 |
+
|
| 944 |
+
// Collect configurations from the chain
|
| 945 |
+
accumulatedParams = collectConfigurations(inputId, new Set());
|
| 946 |
}
|
| 947 |
|
| 948 |
if (!inputImage) {
|
| 949 |
const errorMsg = inputId
|
| 950 |
+
? "No source image found in the chain. Connect to a CHARACTER node or processed node."
|
| 951 |
: "No input connected. Connect an image source to this node.";
|
| 952 |
setNodes(prev => prev.map(n =>
|
| 953 |
n.id === nodeId ? { ...n, error: errorMsg, isRunning: false } : n
|
|
|
|
| 955 |
return;
|
| 956 |
}
|
| 957 |
|
| 958 |
+
// Add current node's configuration
|
| 959 |
+
const currentNodeConfig = getNodeConfiguration(node);
|
| 960 |
+
const params = { ...accumulatedParams, ...currentNodeConfig };
|
| 961 |
+
|
| 962 |
+
// Count how many unprocessed nodes we're combining
|
| 963 |
+
const unprocessedNodeCount = Object.keys(params).length > 0 ?
|
| 964 |
+
(processedNodes.length + 1) : 1;
|
| 965 |
+
|
| 966 |
+
// Show info about batch processing
|
| 967 |
+
if (unprocessedNodeCount > 1) {
|
| 968 |
+
console.log(`π Combining ${unprocessedNodeCount} node transformations into ONE API call`);
|
| 969 |
+
console.log("Combined parameters:", params);
|
| 970 |
+
} else {
|
| 971 |
+
console.log("Processing single node:", node.type);
|
| 972 |
+
}
|
| 973 |
|
| 974 |
+
// Set loading state for all nodes being processed
|
| 975 |
+
setNodes(prev => prev.map(n => {
|
| 976 |
+
if (n.id === nodeId || processedNodes.includes(n.id)) {
|
| 977 |
+
return { ...n, isRunning: true, error: null };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 978 |
}
|
| 979 |
+
return n;
|
| 980 |
+
}));
|
| 981 |
|
| 982 |
+
try {
|
| 983 |
+
// Make a SINGLE API call with all accumulated parameters
|
| 984 |
const res = await fetch("/api/process", {
|
| 985 |
method: "POST",
|
| 986 |
headers: { "Content-Type": "application/json" },
|
| 987 |
body: JSON.stringify({
|
| 988 |
+
type: "COMBINED", // Indicate this is a combined processing
|
| 989 |
image: inputImage,
|
| 990 |
params
|
| 991 |
}),
|
|
|
|
| 994 |
const data = await res.json();
|
| 995 |
if (!res.ok) throw new Error(data.error || "Processing failed");
|
| 996 |
|
| 997 |
+
// Only update the current node with the output
|
| 998 |
+
// Don't show output in intermediate nodes - they were just used for configuration
|
| 999 |
+
setNodes(prev => prev.map(n => {
|
| 1000 |
+
if (n.id === nodeId) {
|
| 1001 |
+
// Only the current node gets the final output displayed
|
| 1002 |
+
return { ...n, output: data.image, isRunning: false, error: null };
|
| 1003 |
+
} else if (processedNodes.includes(n.id)) {
|
| 1004 |
+
// Mark intermediate nodes as no longer running but don't give them output
|
| 1005 |
+
// This way they remain unprocessed visually but their configs were used
|
| 1006 |
+
return { ...n, isRunning: false, error: null };
|
| 1007 |
+
}
|
| 1008 |
+
return n;
|
| 1009 |
+
}));
|
| 1010 |
+
|
| 1011 |
+
if (unprocessedNodeCount > 1) {
|
| 1012 |
+
console.log(`β
Successfully applied ${unprocessedNodeCount} transformations in ONE API call!`);
|
| 1013 |
+
console.log(`Saved ${unprocessedNodeCount - 1} API calls by combining transformations`);
|
| 1014 |
+
}
|
| 1015 |
} catch (e: any) {
|
| 1016 |
console.error("Process error:", e);
|
| 1017 |
+
// Clear loading state for all nodes
|
| 1018 |
+
setNodes(prev => prev.map(n => {
|
| 1019 |
+
if (n.id === nodeId || processedNodes.includes(n.id)) {
|
| 1020 |
+
return { ...n, isRunning: false, error: e?.message || "Error" };
|
| 1021 |
+
}
|
| 1022 |
+
return n;
|
| 1023 |
+
}));
|
| 1024 |
}
|
| 1025 |
};
|
| 1026 |
|
|
|
|
| 1071 |
);
|
| 1072 |
};
|
| 1073 |
|
| 1074 |
+
const executeMerge = async (merge: MergeNode): Promise<string | null> => {
|
| 1075 |
+
// Get images from merge inputs
|
| 1076 |
+
const mergeImages: string[] = [];
|
| 1077 |
+
const characterData: { image: string; label: string }[] = [];
|
| 1078 |
+
|
| 1079 |
+
for (const inputId of merge.inputs) {
|
| 1080 |
+
const inputNode = nodes.find(n => n.id === inputId);
|
| 1081 |
+
if (inputNode) {
|
| 1082 |
+
let image: string | null = null;
|
| 1083 |
+
let label = "";
|
| 1084 |
+
|
| 1085 |
+
if (inputNode.type === "CHARACTER") {
|
| 1086 |
+
image = (inputNode as CharacterNode).image;
|
| 1087 |
+
label = (inputNode as CharacterNode).label || "";
|
| 1088 |
+
} else if ((inputNode as any).output) {
|
| 1089 |
+
image = (inputNode as any).output;
|
| 1090 |
+
}
|
| 1091 |
+
|
| 1092 |
+
if (image) {
|
| 1093 |
+
mergeImages.push(image);
|
| 1094 |
+
characterData.push({ image, label: label || `Input ${mergeImages.length}` });
|
| 1095 |
+
}
|
| 1096 |
+
}
|
| 1097 |
+
}
|
| 1098 |
+
|
| 1099 |
+
if (mergeImages.length < 2) {
|
| 1100 |
+
throw new Error("Not enough valid inputs for merge");
|
| 1101 |
+
}
|
| 1102 |
+
|
| 1103 |
+
const prompt = generateMergePrompt(characterData);
|
| 1104 |
+
|
| 1105 |
+
const res = await fetch("/api/merge", {
|
| 1106 |
+
method: "POST",
|
| 1107 |
+
headers: { "Content-Type": "application/json" },
|
| 1108 |
+
body: JSON.stringify({ images: mergeImages, prompt }),
|
| 1109 |
+
});
|
| 1110 |
+
|
| 1111 |
+
const data = await res.json();
|
| 1112 |
+
if (!res.ok) {
|
| 1113 |
+
throw new Error(data.error || "Merge failed");
|
| 1114 |
+
}
|
| 1115 |
+
|
| 1116 |
+
return (data.images?.[0] as string) || null;
|
| 1117 |
+
};
|
| 1118 |
+
|
| 1119 |
const runMerge = async (mergeId: string) => {
|
| 1120 |
setNodes((prev) => prev.map((n) => (n.id === mergeId && n.type === "MERGE" ? { ...n, isRunning: true, error: null } : n)));
|
| 1121 |
try {
|
|
|
|
| 1171 |
MERGE: 380,
|
| 1172 |
BACKGROUND: 320,
|
| 1173 |
CLOTHES: 320,
|
| 1174 |
+
BLEND: 320,
|
| 1175 |
EDIT: 320,
|
| 1176 |
CAMERA: 360,
|
| 1177 |
AGE: 280,
|
|
|
|
| 1408 |
onDisconnect={disconnectFromMerge}
|
| 1409 |
onRun={runMerge}
|
| 1410 |
onEndConnection={handleEndConnection}
|
| 1411 |
+
onStartConnection={handleStartConnection}
|
| 1412 |
onUpdatePosition={updateNodePosition}
|
| 1413 |
onDelete={deleteNode}
|
| 1414 |
onClearConnections={clearMergeConnections}
|