Reubencf's picture
Upload 38 files
f09b9f0 verified
raw
history blame
70.3 kB
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import "./editor.css";
import {
BackgroundNodeView,
ClothesNodeView,
StyleNodeView,
EditNodeView,
CameraNodeView,
AgeNodeView,
FaceNodeView
} from "./nodes";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
function cx(...args: Array<string | false | null | undefined>) {
return args.filter(Boolean).join(" ");
}
// Simple ID helper
const uid = () => Math.random().toString(36).slice(2, 9);
// Generate merge prompt based on number of inputs
function generateMergePrompt(characterData: { image: string; label: string }[]): string {
const count = characterData.length;
const labels = characterData.map((d, i) => `Image ${i + 1} (${d.label})`).join(", ");
return `MERGE TASK: Create a natural, cohesive group photo combining ALL subjects from ${count} provided images.
Images provided:
${characterData.map((d, i) => `- Image ${i + 1}: ${d.label}`).join("\n")}
CRITICAL REQUIREMENTS:
1. Extract ALL people/subjects from EACH image exactly as they appear
2. Place them together in a SINGLE UNIFIED SCENE with:
- Consistent lighting direction and color temperature
- Matching shadows and ambient lighting
- Proper scale relationships (realistic relative sizes)
- Natural spacing as if they were photographed together
- Shared environment/background that looks cohesive
3. Composition guidelines:
- Arrange subjects at similar depth (not one far behind another)
- Use natural group photo positioning (slight overlap is ok)
- Ensure all faces are clearly visible
- Create visual balance in the composition
- Apply consistent color grading across all subjects
4. Environmental unity:
- Use a single, coherent background for all subjects
- Match the perspective as if taken with one camera
- Ensure ground plane continuity (all standing on same level)
- Apply consistent atmospheric effects (if any)
The result should look like all subjects were photographed together in the same place at the same time, NOT like separate images placed side by side.`;
}
// Types
type NodeType = "CHARACTER" | "MERGE" | "BACKGROUND" | "CLOTHES" | "STYLE" | "EDIT" | "CAMERA" | "AGE" | "FACE" | "BLEND";
type NodeBase = {
id: string;
type: NodeType;
x: number; // world coords
y: number; // world coords
};
type CharacterNode = NodeBase & {
type: "CHARACTER";
image: string; // data URL or http URL
label?: string;
};
type MergeNode = NodeBase & {
type: "MERGE";
inputs: string[]; // node ids
output?: string | null; // data URL from merge
isRunning?: boolean;
error?: string | null;
};
type BackgroundNode = NodeBase & {
type: "BACKGROUND";
input?: string; // node id
output?: string;
backgroundType: "color" | "image" | "upload" | "custom";
backgroundColor?: string;
backgroundImage?: string;
customBackgroundImage?: string;
customPrompt?: string;
isRunning?: boolean;
error?: string | null;
};
type ClothesNode = NodeBase & {
type: "CLOTHES";
input?: string;
output?: string;
clothesImage?: string;
selectedPreset?: string;
clothesPrompt?: string;
isRunning?: boolean;
error?: string | null;
};
type StyleNode = NodeBase & {
type: "STYLE";
input?: string;
output?: string;
stylePreset?: string;
styleStrength?: number;
isRunning?: boolean;
error?: string | null;
};
type EditNode = NodeBase & {
type: "EDIT";
input?: string;
output?: string;
editPrompt?: string;
isRunning?: boolean;
error?: string | null;
};
type CameraNode = NodeBase & {
type: "CAMERA";
input?: string;
output?: string;
focalLength?: string;
aperture?: string;
shutterSpeed?: string;
whiteBalance?: string;
angle?: string;
iso?: string;
filmStyle?: string;
lighting?: string;
bokeh?: string;
composition?: string;
aspectRatio?: string;
isRunning?: boolean;
error?: string | null;
};
type AgeNode = NodeBase & {
type: "AGE";
input?: string;
output?: string;
targetAge?: number;
isRunning?: boolean;
error?: string | null;
};
type FaceNode = NodeBase & {
type: "FACE";
input?: string;
output?: string;
faceOptions?: {
removePimples?: boolean;
addSunglasses?: boolean;
addHat?: boolean;
changeHairstyle?: string;
facialExpression?: string;
beardStyle?: string;
};
isRunning?: boolean;
error?: string | null;
};
type BlendNode = NodeBase & {
type: "BLEND";
input?: string;
output?: string;
blendStrength?: number;
isRunning?: boolean;
error?: string | null;
};
type AnyNode = CharacterNode | MergeNode | BackgroundNode | ClothesNode | StyleNode | EditNode | CameraNode | AgeNode | FaceNode | BlendNode;
// Default placeholder portrait
const DEFAULT_PERSON =
"https://images.unsplash.com/photo-1527980965255-d3b416303d12?q=80&w=640&auto=format&fit=crop";
function toDataUrls(files: FileList | File[]): Promise<string[]> {
const arr = Array.from(files as File[]);
return Promise.all(
arr.map(
(file) =>
new Promise<string>((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(r.result as string);
r.onerror = reject;
r.readAsDataURL(file);
})
)
);
}
// Viewport helpers
function screenToWorld(
clientX: number,
clientY: number,
container: DOMRect,
tx: number,
ty: number,
scale: number
) {
const x = (clientX - container.left - tx) / scale;
const y = (clientY - container.top - ty) / scale;
return { x, y };
}
function useNodeDrag(
nodeId: string,
scaleRef: React.MutableRefObject<number>,
initial: { x: number; y: number },
onUpdatePosition: (id: string, x: number, y: number) => void
) {
const [localPos, setLocalPos] = useState(initial);
const dragging = useRef(false);
const start = useRef<{ sx: number; sy: number; ox: number; oy: number } | null>(
null
);
useEffect(() => {
setLocalPos(initial);
}, [initial.x, initial.y]);
const onPointerDown = (e: React.PointerEvent) => {
e.stopPropagation();
dragging.current = true;
start.current = { sx: e.clientX, sy: e.clientY, ox: localPos.x, oy: localPos.y };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};
const onPointerMove = (e: React.PointerEvent) => {
if (!dragging.current || !start.current) return;
const dx = (e.clientX - start.current.sx) / scaleRef.current;
const dy = (e.clientY - start.current.sy) / scaleRef.current;
const newX = start.current.ox + dx;
const newY = start.current.oy + dy;
setLocalPos({ x: newX, y: newY });
onUpdatePosition(nodeId, newX, newY);
};
const onPointerUp = (e: React.PointerEvent) => {
dragging.current = false;
start.current = null;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
};
return { pos: localPos, onPointerDown, onPointerMove, onPointerUp };
}
function Port({
className,
nodeId,
isOutput,
onStartConnection,
onEndConnection
}: {
className?: string;
nodeId?: string;
isOutput?: boolean;
onStartConnection?: (nodeId: string) => void;
onEndConnection?: (nodeId: string) => void;
}) {
const handlePointerDown = (e: React.PointerEvent) => {
e.stopPropagation();
if (isOutput && nodeId && onStartConnection) {
onStartConnection(nodeId);
}
};
const handlePointerUp = (e: React.PointerEvent) => {
e.stopPropagation();
if (!isOutput && nodeId && onEndConnection) {
onEndConnection(nodeId);
}
};
return (
<div
className={cx("nb-port", className)}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerEnter={handlePointerUp}
/>
);
}
function CharacterNodeView({
node,
scaleRef,
onChangeImage,
onChangeLabel,
onStartConnection,
onUpdatePosition,
onDelete,
}: {
node: CharacterNode;
scaleRef: React.MutableRefObject<number>;
onChangeImage: (id: string, url: string) => void;
onChangeLabel: (id: string, label: string) => void;
onStartConnection: (nodeId: string) => void;
onUpdatePosition: (id: string, x: number, y: number) => void;
onDelete: (id: string) => void;
}) {
const { pos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(
node.id,
scaleRef,
{ x: node.x, y: node.y },
onUpdatePosition
);
const onDrop = async (e: React.DragEvent) => {
e.preventDefault();
const f = e.dataTransfer.files;
if (f && f.length) {
const [first] = await toDataUrls(f);
if (first) onChangeImage(node.id, first);
}
};
const onPaste = async (e: React.ClipboardEvent) => {
const items = e.clipboardData.items;
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.type.startsWith("image/")) {
const f = it.getAsFile();
if (f) files.push(f);
}
}
if (files.length) {
const [first] = await toDataUrls(files);
if (first) onChangeImage(node.id, first);
return;
}
const text = e.clipboardData.getData("text");
if (text && (text.startsWith("http") || text.startsWith("data:image"))) {
onChangeImage(node.id, text);
}
};
return (
<div
className="nb-node absolute text-white w-[340px] select-none"
style={{ left: pos.x, top: pos.y }}
onDrop={onDrop}
onDragOver={(e) => e.preventDefault()}
onPaste={onPaste}
>
<div
className="nb-header cursor-grab active:cursor-grabbing rounded-t-[14px] px-3 py-2 flex items-center justify-between"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<input
className="bg-transparent outline-none text-sm font-semibold tracking-wide flex-1"
value={node.label || "CHARACTER"}
onChange={(e) => onChangeLabel(node.id, e.target.value)}
/>
<div className="flex items-center gap-2">
<Button
variant="ghost" size="icon" className="text-destructive"
onClick={(e) => {
e.stopPropagation();
if (confirm('Delete MERGE node?')) {
onDelete(node.id);
}
}}
title="Delete node"
aria-label="Delete node"
>
×
</Button>
<Port
className="out"
nodeId={node.id}
isOutput={true}
onStartConnection={onStartConnection}
/>
</div>
</div>
<div className="p-3 space-y-3">
<div className="aspect-[4/5] w-full rounded-xl bg-black/40 grid place-items-center overflow-hidden">
<img
src={node.image}
alt="character"
className="h-full w-full object-contain"
draggable={false}
/>
</div>
<div className="flex gap-2">
<label className="text-xs bg-white/10 hover:bg-white/20 rounded px-3 py-1 cursor-pointer">
Upload
<input
type="file"
accept="image/*"
className="hidden"
onChange={async (e) => {
const files = e.currentTarget.files;
if (files && files.length > 0) {
const [first] = await toDataUrls(files);
if (first) onChangeImage(node.id, first);
// Reset input safely
try {
e.currentTarget.value = "";
} catch {}
}
}}
/>
</label>
<button
className="text-xs bg-white/10 hover:bg-white/20 rounded px-3 py-1"
onClick={async () => {
try {
const text = await navigator.clipboard.readText();
if (text && (text.startsWith("http") || text.startsWith("data:image"))) {
onChangeImage(node.id, text);
}
} catch {}
}}
>
Paste URL
</button>
</div>
</div>
</div>
);
}
function MergeNodeView({
node,
scaleRef,
allNodes,
onDisconnect,
onRun,
onEndConnection,
onStartConnection,
onUpdatePosition,
onDelete,
onClearConnections,
}: {
node: MergeNode;
scaleRef: React.MutableRefObject<number>;
allNodes: AnyNode[];
onDisconnect: (mergeId: string, nodeId: string) => void;
onRun: (mergeId: string) => void;
onEndConnection: (mergeId: string) => void;
onStartConnection: (nodeId: string) => void;
onUpdatePosition: (id: string, x: number, y: number) => void;
onDelete: (id: string) => void;
onClearConnections: (mergeId: string) => void;
}) {
const { pos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(
node.id,
scaleRef,
{ x: node.x, y: node.y },
onUpdatePosition
);
return (
<div className="nb-node absolute text-white w-[420px]" style={{ left: pos.x, top: pos.y }}>
<div
className="nb-header cursor-grab active:cursor-grabbing rounded-t-[14px] px-3 py-2 flex items-center justify-between"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<Port
className="in"
nodeId={node.id}
isOutput={false}
onEndConnection={onEndConnection}
/>
<div className="font-semibold tracking-wide text-sm flex-1 text-center">MERGE</div>
<div className="flex items-center gap-2">
<button
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"
onClick={(e) => {
e.stopPropagation();
if (confirm('Delete MERGE node?')) {
onDelete(node.id);
}
}}
title="Delete node"
>
×
</button>
<Port
className="out"
nodeId={node.id}
isOutput={true}
onStartConnection={onStartConnection}
/>
</div>
</div>
<div className="p-3 space-y-3">
<div className="text-xs text-white/70">Inputs</div>
<div className="flex flex-wrap gap-2">
{node.inputs.map((id) => {
const inputNode = allNodes.find((n) => n.id === id);
if (!inputNode) return null;
// Get image and label based on node type
let image: string | null = null;
let label = "";
if (inputNode.type === "CHARACTER") {
image = (inputNode as CharacterNode).image;
label = (inputNode as CharacterNode).label || "Character";
} else if ((inputNode as any).output) {
image = (inputNode as any).output;
label = `${inputNode.type}`;
} else if (inputNode.type === "MERGE" && (inputNode as MergeNode).output) {
const mergeOutput = (inputNode as MergeNode).output;
image = mergeOutput !== undefined ? mergeOutput : null;
label = "Merged";
} else {
// Node without output yet
label = `${inputNode.type} (pending)`;
}
return (
<div key={id} className="flex items-center gap-2 bg-white/10 rounded px-2 py-1">
{image && (
<div className="w-6 h-6 rounded overflow-hidden bg-black/20">
<img src={image} className="w-full h-full object-contain" alt="inp" />
</div>
)}
<span className="text-xs">{label}</span>
<button
className="text-[10px] text-red-300 hover:text-red-200"
onClick={() => onDisconnect(node.id, id)}
>
remove
</button>
</div>
);
})}
</div>
{node.inputs.length === 0 && (
<p className="text-xs text-white/40">Drag from any node's output port to connect</p>
)}
<div className="flex items-center gap-2">
{node.inputs.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={() => onClearConnections(node.id)}
title="Clear all connections"
>
Clear
</Button>
)}
<Button
size="sm"
onClick={() => onRun(node.id)}
disabled={node.isRunning || node.inputs.length < 2}
>
{node.isRunning ? "Merging…" : "Merge"}
</Button>
</div>
<div className="mt-2">
<div className="text-xs text-white/70 mb-1">Output</div>
<div className="w-full min-h-[200px] max-h-[400px] rounded-xl bg-black/40 grid place-items-center">
{node.output ? (
<img src={node.output} className="w-full h-auto max-h-[400px] object-contain rounded-xl" alt="output" />
) : (
<span className="text-white/40 text-xs py-16">Run merge to see result</span>
)}
</div>
{node.output && (
<Button
className="w-full mt-2"
variant="secondary"
onClick={() => {
const link = document.createElement('a');
link.href = node.output as string;
link.download = `merge-${Date.now()}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
>
📥 Download Merged Image
</Button>
)}
{node.error && (
<div className="mt-2">
<div className="text-xs text-red-400">{node.error}</div>
{node.error.includes("API key") && (
<div className="text-xs text-white/50 mt-2 space-y-1">
<p>To fix this:</p>
<ol className="list-decimal list-inside space-y-1">
<li>Get key from: <a href="https://aistudio.google.com/app/apikey" target="_blank" className="text-blue-400 hover:underline">Google AI Studio</a></li>
<li>Edit .env.local file in project root</li>
<li>Replace placeholder with your key</li>
<li>Restart server (Ctrl+C, npm run dev)</li>
</ol>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
export default function EditorPage() {
const [nodes, setNodes] = useState<AnyNode[]>(() => [
{
id: uid(),
type: "CHARACTER",
x: 80,
y: 120,
image: DEFAULT_PERSON,
label: "CHARACTER 1",
} as CharacterNode,
]);
// Viewport state
const [scale, setScale] = useState(1);
const [tx, setTx] = useState(0);
const [ty, setTy] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const scaleRef = useRef(scale);
useEffect(() => {
scaleRef.current = scale;
}, [scale]);
// Connection dragging state
const [draggingFrom, setDraggingFrom] = useState<string | null>(null);
const [dragPos, setDragPos] = useState<{x: number, y: number} | null>(null);
// API Token state
const [apiToken, setApiToken] = useState<string>("");
const [showHelpSidebar, setShowHelpSidebar] = useState(false);
const characters = nodes.filter((n) => n.type === "CHARACTER") as CharacterNode[];
const merges = nodes.filter((n) => n.type === "MERGE") as MergeNode[];
// Editor actions
const addCharacter = (at?: { x: number; y: number }) => {
setNodes((prev) => [
...prev,
{
id: uid(),
type: "CHARACTER",
x: at ? at.x : 80 + Math.random() * 60,
y: at ? at.y : 120 + Math.random() * 60,
image: DEFAULT_PERSON,
label: `CHARACTER ${prev.filter((n) => n.type === "CHARACTER").length + 1}`,
} as CharacterNode,
]);
};
const addMerge = (at?: { x: number; y: number }) => {
setNodes((prev) => [
...prev,
{
id: uid(),
type: "MERGE",
x: at ? at.x : 520,
y: at ? at.y : 160,
inputs: [],
} as MergeNode,
]);
};
const setCharacterImage = (id: string, url: string) => {
setNodes((prev) =>
prev.map((n) => (n.id === id && n.type === "CHARACTER" ? { ...n, image: url } : n))
);
};
const setCharacterLabel = (id: string, label: string) => {
setNodes((prev) => prev.map((n) => (n.id === id && n.type === "CHARACTER" ? { ...n, label } : n)));
};
const updateNodePosition = (id: string, x: number, y: number) => {
setNodes((prev) => prev.map((n) => (n.id === id ? { ...n, x, y } : n)));
};
const deleteNode = (id: string) => {
setNodes((prev) => {
// If it's a MERGE node, just remove it
// If it's a CHARACTER node, also remove it from all MERGE inputs
return prev
.filter((n) => n.id !== id)
.map((n) => {
if (n.type === "MERGE") {
const merge = n as MergeNode;
return {
...merge,
inputs: merge.inputs.filter((inputId) => inputId !== id),
};
}
return n;
});
});
};
const clearMergeConnections = (mergeId: string) => {
setNodes((prev) =>
prev.map((n) =>
n.id === mergeId && n.type === "MERGE"
? { ...n, inputs: [] }
: n
)
);
};
// Update any node's properties
const updateNode = (id: string, updates: any) => {
setNodes((prev) => prev.map((n) => (n.id === id ? { ...n, ...updates } : n)));
};
// Handle single input connections for new nodes
const handleEndSingleConnection = (nodeId: string) => {
if (draggingFrom) {
// Find the source node
const sourceNode = nodes.find(n => n.id === draggingFrom);
if (sourceNode) {
// Allow connections from ANY node that has an output port
// This includes:
// - CHARACTER nodes (always have an image)
// - MERGE nodes (can have output after merging)
// - Any processing node (BACKGROUND, CLOTHES, BLEND, etc.)
// - Even unprocessed nodes (for configuration chaining)
// All nodes can be connected for chaining
setNodes(prev => prev.map(n =>
n.id === nodeId ? { ...n, input: draggingFrom } : n
));
}
setDraggingFrom(null);
setDragPos(null);
// Re-enable text selection
document.body.style.userSelect = '';
document.body.style.webkitUserSelect = '';
}
};
// Helper to count pending configurations in chain
const countPendingConfigurations = (startNodeId: string): number => {
let count = 0;
const visited = new Set<string>();
const traverse = (nodeId: string) => {
if (visited.has(nodeId)) return;
visited.add(nodeId);
const node = nodes.find(n => n.id === nodeId);
if (!node) return;
// Check if this node has configuration but no output
if (!(node as any).output && node.type !== "CHARACTER" && node.type !== "MERGE") {
const config = getNodeConfiguration(node);
if (Object.keys(config).length > 0) {
count++;
}
}
// Check upstream
const upstreamId = (node as any).input;
if (upstreamId) {
traverse(upstreamId);
}
};
traverse(startNodeId);
return count;
};
// Helper to extract configuration from a node
const getNodeConfiguration = (node: AnyNode): any => {
const config: any = {};
switch (node.type) {
case "BACKGROUND":
if ((node as BackgroundNode).backgroundType) {
config.backgroundType = (node as BackgroundNode).backgroundType;
config.backgroundColor = (node as BackgroundNode).backgroundColor;
config.backgroundImage = (node as BackgroundNode).backgroundImage;
config.customBackgroundImage = (node as BackgroundNode).customBackgroundImage;
config.customPrompt = (node as BackgroundNode).customPrompt;
}
break;
case "CLOTHES":
if ((node as ClothesNode).clothesImage) {
config.clothesImage = (node as ClothesNode).clothesImage;
config.selectedPreset = (node as ClothesNode).selectedPreset;
}
break;
case "STYLE":
if ((node as StyleNode).stylePreset) {
config.stylePreset = (node as StyleNode).stylePreset;
config.styleStrength = (node as StyleNode).styleStrength;
}
break;
case "EDIT":
if ((node as EditNode).editPrompt) {
config.editPrompt = (node as EditNode).editPrompt;
}
break;
case "CAMERA":
const cam = node as CameraNode;
if (cam.focalLength && cam.focalLength !== "None") config.focalLength = cam.focalLength;
if (cam.aperture && cam.aperture !== "None") config.aperture = cam.aperture;
if (cam.shutterSpeed && cam.shutterSpeed !== "None") config.shutterSpeed = cam.shutterSpeed;
if (cam.whiteBalance && cam.whiteBalance !== "None") config.whiteBalance = cam.whiteBalance;
if (cam.angle && cam.angle !== "None") config.angle = cam.angle;
if (cam.iso && cam.iso !== "None") config.iso = cam.iso;
if (cam.filmStyle && cam.filmStyle !== "None") config.filmStyle = cam.filmStyle;
if (cam.lighting && cam.lighting !== "None") config.lighting = cam.lighting;
if (cam.bokeh && cam.bokeh !== "None") config.bokeh = cam.bokeh;
if (cam.composition && cam.composition !== "None") config.composition = cam.composition;
if (cam.aspectRatio && cam.aspectRatio !== "None") config.aspectRatio = cam.aspectRatio;
break;
case "AGE":
if ((node as AgeNode).targetAge) {
config.targetAge = (node as AgeNode).targetAge;
}
break;
case "FACE":
const face = node as FaceNode;
if (face.faceOptions) {
const opts: any = {};
if (face.faceOptions.removePimples) opts.removePimples = true;
if (face.faceOptions.addSunglasses) opts.addSunglasses = true;
if (face.faceOptions.addHat) opts.addHat = true;
if (face.faceOptions.changeHairstyle && face.faceOptions.changeHairstyle !== "None") {
opts.changeHairstyle = face.faceOptions.changeHairstyle;
}
if (face.faceOptions.facialExpression && face.faceOptions.facialExpression !== "None") {
opts.facialExpression = face.faceOptions.facialExpression;
}
if (face.faceOptions.beardStyle && face.faceOptions.beardStyle !== "None") {
opts.beardStyle = face.faceOptions.beardStyle;
}
if (Object.keys(opts).length > 0) {
config.faceOptions = opts;
}
}
break;
}
return config;
};
// Process node with API
const processNode = async (nodeId: string) => {
const node = nodes.find(n => n.id === nodeId);
if (!node) {
console.error("Node not found:", nodeId);
return;
}
// Get input image and collect all configurations from chain
let inputImage: string | null = null;
let accumulatedParams: any = {};
const processedNodes: string[] = []; // Track which nodes' configs we're applying
const inputId = (node as any).input;
if (inputId) {
// Track unprocessed MERGE nodes that need to be executed
const unprocessedMerges: MergeNode[] = [];
// Find the source image by traversing the chain backwards
const findSourceImage = (currentNodeId: string, visited: Set<string> = new Set()): string | null => {
if (visited.has(currentNodeId)) return null;
visited.add(currentNodeId);
const currentNode = nodes.find(n => n.id === currentNodeId);
if (!currentNode) return null;
// If this is a CHARACTER node, return its image
if (currentNode.type === "CHARACTER") {
return (currentNode as CharacterNode).image;
}
// If this is a MERGE node with output, return its output
if (currentNode.type === "MERGE" && (currentNode as MergeNode).output) {
return (currentNode as MergeNode).output || null;
}
// If any node has been processed, return its output
if ((currentNode as any).output) {
return (currentNode as any).output;
}
// For MERGE nodes without output, we need to process them first
if (currentNode.type === "MERGE") {
const merge = currentNode as MergeNode;
if (!merge.output && merge.inputs.length >= 2) {
// Mark this merge for processing
unprocessedMerges.push(merge);
// For now, return null - we'll process the merge first
return null;
} else if (merge.inputs.length > 0) {
// Try to get image from first input if merge can't be executed
const firstInput = merge.inputs[0];
const inputImage = findSourceImage(firstInput, visited);
if (inputImage) return inputImage;
}
}
// Otherwise, check upstream
const upstreamId = (currentNode as any).input;
if (upstreamId) {
return findSourceImage(upstreamId, visited);
}
return null;
};
// Collect all configurations from unprocessed nodes in the chain
const collectConfigurations = (currentNodeId: string, visited: Set<string> = new Set()): any => {
if (visited.has(currentNodeId)) return {};
visited.add(currentNodeId);
const currentNode = nodes.find(n => n.id === currentNodeId);
if (!currentNode) return {};
let configs: any = {};
// First, collect from upstream nodes
const upstreamId = (currentNode as any).input;
if (upstreamId) {
configs = collectConfigurations(upstreamId, visited);
}
// Add this node's configuration only if:
// 1. It's the current node being processed, OR
// 2. It hasn't been processed yet (no output) AND it's not the current node
const shouldIncludeConfig =
currentNodeId === nodeId || // Always include current node's config
(!(currentNode as any).output && currentNodeId !== nodeId); // Include unprocessed intermediate nodes
if (shouldIncludeConfig) {
const nodeConfig = getNodeConfiguration(currentNode);
if (Object.keys(nodeConfig).length > 0) {
configs = { ...configs, ...nodeConfig };
// Track unprocessed intermediate nodes
if (currentNodeId !== nodeId && !(currentNode as any).output) {
processedNodes.push(currentNodeId);
}
}
}
return configs;
};
// Find the source image
inputImage = findSourceImage(inputId);
// If we found unprocessed merges, we need to execute them first
if (unprocessedMerges.length > 0 && !inputImage) {
console.log(`Found ${unprocessedMerges.length} unprocessed MERGE nodes in chain. Processing them first...`);
// Process each merge node
for (const merge of unprocessedMerges) {
// Set loading state for the merge
setNodes(prev => prev.map(n =>
n.id === merge.id ? { ...n, isRunning: true, error: null } : n
));
try {
const mergeOutput = await executeMerge(merge);
// Update the merge node with output
setNodes(prev => prev.map(n =>
n.id === merge.id ? { ...n, output: mergeOutput || undefined, isRunning: false, error: null } : n
));
// Track that we processed this merge as part of the chain
processedNodes.push(merge.id);
// Now use this as our input image if it's the direct input
if (inputId === merge.id) {
inputImage = mergeOutput;
}
} catch (e: any) {
console.error("Auto-merge error:", e);
setNodes(prev => prev.map(n =>
n.id === merge.id ? { ...n, isRunning: false, error: e?.message || "Merge failed" } : n
));
// Abort the main processing if merge failed
setNodes(prev => prev.map(n =>
n.id === nodeId ? { ...n, error: "Failed to process upstream MERGE node", isRunning: false } : n
));
return;
}
}
// After processing merges, try to find the source image again
if (!inputImage) {
inputImage = findSourceImage(inputId);
}
}
// Collect configurations from the chain
accumulatedParams = collectConfigurations(inputId, new Set());
}
if (!inputImage) {
const errorMsg = inputId
? "No source image found in the chain. Connect to a CHARACTER node or processed node."
: "No input connected. Connect an image source to this node.";
setNodes(prev => prev.map(n =>
n.id === nodeId ? { ...n, error: errorMsg, isRunning: false } : n
));
return;
}
// Add current node's configuration
const currentNodeConfig = getNodeConfiguration(node);
const params = { ...accumulatedParams, ...currentNodeConfig };
// Count how many unprocessed nodes we're combining
const unprocessedNodeCount = Object.keys(params).length > 0 ?
(processedNodes.length + 1) : 1;
// Show info about batch processing
if (unprocessedNodeCount > 1) {
console.log(`🚀 Combining ${unprocessedNodeCount} node transformations into ONE API call`);
console.log("Combined parameters:", params);
} else {
console.log("Processing single node:", node.type);
}
// Set loading state for all nodes being processed
setNodes(prev => prev.map(n => {
if (n.id === nodeId || processedNodes.includes(n.id)) {
return { ...n, isRunning: true, error: null };
}
return n;
}));
try {
// Validate image data before sending
if (inputImage && inputImage.length > 10 * 1024 * 1024) { // 10MB limit warning
console.warn("Large input image detected, size:", (inputImage.length / (1024 * 1024)).toFixed(2) + "MB");
}
// Check if params contains custom images and validate them
if (params.clothesImage) {
console.log("[Process] Clothes image size:", (params.clothesImage.length / 1024).toFixed(2) + "KB");
// Validate it's a proper data URL
if (!params.clothesImage.startsWith('data:') && !params.clothesImage.startsWith('http') && !params.clothesImage.startsWith('/')) {
throw new Error("Invalid clothes image format. Please upload a valid image.");
}
}
if (params.customBackgroundImage) {
console.log("[Process] Custom background size:", (params.customBackgroundImage.length / 1024).toFixed(2) + "KB");
// Validate it's a proper data URL
if (!params.customBackgroundImage.startsWith('data:') && !params.customBackgroundImage.startsWith('http') && !params.customBackgroundImage.startsWith('/')) {
throw new Error("Invalid background image format. Please upload a valid image.");
}
}
// Log request details for debugging
console.log("[Process] Sending request with:", {
hasImage: !!inputImage,
imageSize: inputImage ? (inputImage.length / 1024).toFixed(2) + "KB" : 0,
paramsKeys: Object.keys(params),
nodeType: node.type
});
// Make a SINGLE API call with all accumulated parameters
const res = await fetch("/api/process", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "COMBINED", // Indicate this is a combined processing
image: inputImage,
params,
apiToken: apiToken || undefined
}),
});
// Check if response is actually JSON before parsing
const contentType = res.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
const textResponse = await res.text();
console.error("Non-JSON response received:", textResponse);
throw new Error("Server returned an error page instead of JSON. Check your API key configuration.");
}
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Processing failed");
// Only update the current node with the output
// Don't show output in intermediate nodes - they were just used for configuration
setNodes(prev => prev.map(n => {
if (n.id === nodeId) {
// Only the current node gets the final output displayed
return { ...n, output: data.image, isRunning: false, error: null };
} else if (processedNodes.includes(n.id)) {
// Mark intermediate nodes as no longer running but don't give them output
// This way they remain unprocessed visually but their configs were used
return { ...n, isRunning: false, error: null };
}
return n;
}));
if (unprocessedNodeCount > 1) {
console.log(`✅ Successfully applied ${unprocessedNodeCount} transformations in ONE API call!`);
console.log(`Saved ${unprocessedNodeCount - 1} API calls by combining transformations`);
}
} catch (e: any) {
console.error("Process error:", e);
// Clear loading state for all nodes
setNodes(prev => prev.map(n => {
if (n.id === nodeId || processedNodes.includes(n.id)) {
return { ...n, isRunning: false, error: e?.message || "Error" };
}
return n;
}));
}
};
const connectToMerge = (mergeId: string, nodeId: string) => {
setNodes((prev) =>
prev.map((n) =>
n.id === mergeId && n.type === "MERGE"
? { ...n, inputs: Array.from(new Set([...(n as MergeNode).inputs, nodeId])) }
: n
)
);
};
// Connection drag handlers
const handleStartConnection = (nodeId: string) => {
setDraggingFrom(nodeId);
// Prevent text selection during dragging
document.body.style.userSelect = 'none';
document.body.style.webkitUserSelect = 'none';
};
const handleEndConnection = (mergeId: string) => {
if (draggingFrom) {
// Allow connections from any node type that could have an output
const sourceNode = nodes.find(n => n.id === draggingFrom);
if (sourceNode) {
// Allow connections from:
// - CHARACTER nodes (always have an image)
// - Any node with an output (processed nodes)
// - Any processing node (for future processing)
connectToMerge(mergeId, draggingFrom);
}
setDraggingFrom(null);
setDragPos(null);
// Re-enable text selection
document.body.style.userSelect = '';
document.body.style.webkitUserSelect = '';
}
};
const handlePointerMove = (e: React.PointerEvent) => {
if (draggingFrom) {
const rect = containerRef.current!.getBoundingClientRect();
const world = screenToWorld(e.clientX, e.clientY, rect, tx, ty, scale);
setDragPos(world);
}
};
const handlePointerUp = () => {
if (draggingFrom) {
setDraggingFrom(null);
setDragPos(null);
// Re-enable text selection
document.body.style.userSelect = '';
document.body.style.webkitUserSelect = '';
}
};
const disconnectFromMerge = (mergeId: string, nodeId: string) => {
setNodes((prev) =>
prev.map((n) =>
n.id === mergeId && n.type === "MERGE"
? { ...n, inputs: (n as MergeNode).inputs.filter((i) => i !== nodeId) }
: n
)
);
};
const executeMerge = async (merge: MergeNode): Promise<string | null> => {
// Get images from merge inputs - now accepts any node type
const mergeImages: string[] = [];
const inputData: { image: string; label: string }[] = [];
for (const inputId of merge.inputs) {
const inputNode = nodes.find(n => n.id === inputId);
if (inputNode) {
let image: string | null = null;
let label = "";
if (inputNode.type === "CHARACTER") {
image = (inputNode as CharacterNode).image;
label = (inputNode as CharacterNode).label || "";
} else if ((inputNode as any).output) {
// Any processed node with output
image = (inputNode as any).output;
label = `${inputNode.type} Output`;
} else if (inputNode.type === "MERGE" && (inputNode as MergeNode).output) {
// Another merge node's output
const mergeOutput = (inputNode as MergeNode).output;
image = mergeOutput !== undefined ? mergeOutput : null;
label = "Merged Image";
}
if (image) {
// Validate image format
if (!image.startsWith('data:') && !image.startsWith('http') && !image.startsWith('/')) {
console.error(`Invalid image format for ${label}:`, image.substring(0, 100));
continue; // Skip invalid images
}
mergeImages.push(image);
inputData.push({ image, label: label || `Input ${mergeImages.length}` });
}
}
}
if (mergeImages.length < 2) {
throw new Error("Not enough valid inputs for merge. Need at least 2 images.");
}
// Log merge details for debugging
console.log("[Merge] Processing merge with:", {
imageCount: mergeImages.length,
imageSizes: mergeImages.map(img => (img.length / 1024).toFixed(2) + "KB"),
labels: inputData.map(d => d.label)
});
const prompt = generateMergePrompt(inputData);
// Use the process route instead of merge route
const res = await fetch("/api/process", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "MERGE",
images: mergeImages,
prompt,
apiToken: apiToken || undefined
}),
});
// Check if response is actually JSON before parsing
const contentType = res.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
const textResponse = await res.text();
console.error("Non-JSON response received:", textResponse);
throw new Error("Server returned an error page instead of JSON. Check your API key configuration.");
}
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Merge failed");
}
return data.image || (data.images?.[0] as string) || null;
};
const runMerge = async (mergeId: string) => {
setNodes((prev) => prev.map((n) => (n.id === mergeId && n.type === "MERGE" ? { ...n, isRunning: true, error: null } : n)));
try {
const merge = (nodes.find((n) => n.id === mergeId) as MergeNode) || null;
if (!merge) return;
// Get input nodes with their labels - now accepts any node type
const inputData = merge.inputs
.map((id, index) => {
const inputNode = nodes.find((n) => n.id === id);
if (!inputNode) return null;
// Support CHARACTER nodes, processed nodes, and MERGE outputs
let image: string | null = null;
let label = "";
if (inputNode.type === "CHARACTER") {
image = (inputNode as CharacterNode).image;
label = (inputNode as CharacterNode).label || `CHARACTER ${index + 1}`;
} else if ((inputNode as any).output) {
// Any processed node with output
image = (inputNode as any).output;
label = `${inputNode.type} Output ${index + 1}`;
} else if (inputNode.type === "MERGE" && (inputNode as MergeNode).output) {
// Another merge node's output
const mergeOutput = (inputNode as MergeNode).output;
image = mergeOutput !== undefined ? mergeOutput : null;
label = `Merged Image ${index + 1}`;
}
if (!image) return null;
return { image, label };
})
.filter(Boolean) as { image: string; label: string }[];
if (inputData.length < 2) throw new Error("Connect at least two nodes with images (CHARACTER nodes or processed nodes).");
// Debug: Log what we're sending
console.log("🔄 Merging nodes:", inputData.map(d => d.label).join(", "));
console.log("📷 Image URLs being sent:", inputData.map(d => d.image.substring(0, 100) + "..."));
// Generate dynamic prompt based on number of inputs
const prompt = generateMergePrompt(inputData);
const imgs = inputData.map(d => d.image);
// Use the process route with MERGE type
const res = await fetch("/api/process", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "MERGE",
images: imgs,
prompt,
apiToken: apiToken || undefined
}),
});
// Check if response is actually JSON before parsing
const contentType = res.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
const textResponse = await res.text();
console.error("Non-JSON response received:", textResponse);
throw new Error("Server returned an error page instead of JSON. Check your API key configuration.");
}
const js = await res.json();
if (!res.ok) {
// Show more helpful error messages
const errorMsg = js.error || "Merge failed";
if (errorMsg.includes("API key")) {
throw new Error("API key not configured. Add GOOGLE_API_KEY to .env.local");
}
throw new Error(errorMsg);
}
const out = js.image || (js.images?.[0] as string) || null;
setNodes((prev) => prev.map((n) => (n.id === mergeId && n.type === "MERGE" ? { ...n, output: out, isRunning: false } : n)));
} catch (e: any) {
console.error("Merge error:", e);
setNodes((prev) => prev.map((n) => (n.id === mergeId && n.type === "MERGE" ? { ...n, isRunning: false, error: e?.message || "Error" } : n)));
}
};
// Calculate SVG bounds for connection lines
const svgBounds = useMemo(() => {
let minX = 0, minY = 0, maxX = 1000, maxY = 1000;
nodes.forEach(node => {
minX = Math.min(minX, node.x - 100);
minY = Math.min(minY, node.y - 100);
maxX = Math.max(maxX, node.x + 500);
maxY = Math.max(maxY, node.y + 500);
});
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY
};
}, [nodes]);
// Connection paths with bezier curves
const connectionPaths = useMemo(() => {
const getNodeOutputPort = (n: AnyNode) => {
// Different nodes have different widths
const widths: Record<string, number> = {
CHARACTER: 340,
MERGE: 420,
BACKGROUND: 320,
CLOTHES: 320,
BLEND: 320,
EDIT: 320,
CAMERA: 360,
AGE: 280,
FACE: 340,
};
const width = widths[n.type] || 320;
return { x: n.x + width - 10, y: n.y + 25 };
};
const getNodeInputPort = (n: AnyNode) => ({ x: n.x + 10, y: n.y + 25 });
const createPath = (x1: number, y1: number, x2: number, y2: number) => {
const dx = x2 - x1;
const dy = y2 - y1;
const distance = Math.sqrt(dx * dx + dy * dy);
const controlOffset = Math.min(200, Math.max(50, distance * 0.3));
return `M ${x1} ${y1} C ${x1 + controlOffset} ${y1}, ${x2 - controlOffset} ${y2}, ${x2} ${y2}`;
};
const paths: { path: string; active?: boolean; processing?: boolean }[] = [];
// Handle all connections
for (const node of nodes) {
if (node.type === "MERGE") {
// MERGE node with multiple inputs
const merge = node as MergeNode;
for (const inputId of merge.inputs) {
const inputNode = nodes.find(n => n.id === inputId);
if (inputNode) {
const start = getNodeOutputPort(inputNode);
const end = getNodeInputPort(node);
const isProcessing = merge.isRunning || (inputNode as any).isRunning;
paths.push({
path: createPath(start.x, start.y, end.x, end.y),
processing: isProcessing
});
}
}
} else if ((node as any).input) {
// Single input nodes
const inputId = (node as any).input;
const inputNode = nodes.find(n => n.id === inputId);
if (inputNode) {
const start = getNodeOutputPort(inputNode);
const end = getNodeInputPort(node);
const isProcessing = (node as any).isRunning || (inputNode as any).isRunning;
paths.push({
path: createPath(start.x, start.y, end.x, end.y),
processing: isProcessing
});
}
}
}
// Dragging path
if (draggingFrom && dragPos) {
const sourceNode = nodes.find(n => n.id === draggingFrom);
if (sourceNode) {
const start = getNodeOutputPort(sourceNode);
paths.push({
path: createPath(start.x, start.y, dragPos.x, dragPos.y),
active: true
});
}
}
return paths;
}, [nodes, draggingFrom, dragPos]);
// Panning & zooming
const isPanning = useRef(false);
const panStart = useRef<{ sx: number; sy: number; ox: number; oy: number } | null>(null);
const onBackgroundPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
// Only pan if clicking directly on the background
if (e.target !== e.currentTarget && !((e.target as HTMLElement).tagName === "svg" || (e.target as HTMLElement).tagName === "line")) return;
isPanning.current = true;
panStart.current = { sx: e.clientX, sy: e.clientY, ox: tx, oy: ty };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};
const onBackgroundPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!isPanning.current || !panStart.current) return;
const dx = e.clientX - panStart.current.sx;
const dy = e.clientY - panStart.current.sy;
setTx(panStart.current.ox + dx);
setTy(panStart.current.oy + dy);
};
const onBackgroundPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
isPanning.current = false;
panStart.current = null;
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
};
const onWheel = (e: React.WheelEvent<HTMLDivElement>) => {
e.preventDefault();
const rect = containerRef.current!.getBoundingClientRect();
const oldScale = scaleRef.current;
const factor = Math.exp(-e.deltaY * 0.0015);
const newScale = Math.min(2.5, Math.max(0.25, oldScale * factor));
const { x: wx, y: wy } = screenToWorld(e.clientX, e.clientY, rect, tx, ty, oldScale);
// keep cursor anchored while zooming
const ntx = e.clientX - rect.left - wx * newScale;
const nty = e.clientY - rect.top - wy * newScale;
setTx(ntx);
setTy(nty);
setScale(newScale);
};
// Context menu for adding nodes
const [menuOpen, setMenuOpen] = useState(false);
const [menuPos, setMenuPos] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const [menuWorld, setMenuWorld] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
const onContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault();
const rect = containerRef.current!.getBoundingClientRect();
const world = screenToWorld(e.clientX, e.clientY, rect, tx, ty, scale);
setMenuWorld(world);
setMenuPos({ x: e.clientX - rect.left, y: e.clientY - rect.top });
setMenuOpen(true);
};
const addFromMenu = (kind: NodeType) => {
const commonProps = {
id: uid(),
x: menuWorld.x,
y: menuWorld.y,
};
switch(kind) {
case "CHARACTER":
addCharacter(menuWorld);
break;
case "MERGE":
addMerge(menuWorld);
break;
case "BACKGROUND":
setNodes(prev => [...prev, { ...commonProps, type: "BACKGROUND", backgroundType: "color" } as BackgroundNode]);
break;
case "CLOTHES":
setNodes(prev => [...prev, { ...commonProps, type: "CLOTHES" } as ClothesNode]);
break;
case "BLEND":
setNodes(prev => [...prev, { ...commonProps, type: "BLEND", blendStrength: 50 } as BlendNode]);
break;
case "STYLE":
setNodes(prev => [...prev, { ...commonProps, type: "STYLE", styleStrength: 50 } as StyleNode]);
break;
case "CAMERA":
setNodes(prev => [...prev, { ...commonProps, type: "CAMERA" } as CameraNode]);
break;
case "AGE":
setNodes(prev => [...prev, { ...commonProps, type: "AGE", targetAge: 30 } as AgeNode]);
break;
case "FACE":
setNodes(prev => [...prev, { ...commonProps, type: "FACE", faceOptions: {} } as FaceNode]);
break;
}
setMenuOpen(false);
};
return (
<div className="min-h-[100svh] bg-background text-foreground">
<header className="flex items-center justify-between px-6 py-4 border-b border-border/60 bg-card/70 backdrop-blur">
<h1 className="text-lg font-semibold tracking-wide">
<span className="mr-2" aria-hidden>🍌</span>Nano Banana Editor
</h1>
<div className="flex items-center gap-2">
<label htmlFor="api-token" className="text-sm font-medium text-muted-foreground">
API Token:
</label>
<Input
id="api-token"
type="password"
placeholder="Enter your Google Gemini API token"
value={apiToken}
onChange={(e) => setApiToken(e.target.value)}
className="w-64"
/>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 rounded-full hover:bg-red-50 dark:hover:bg-red-900/20"
type="button"
onClick={() => setShowHelpSidebar(true)}
>
<span className="text-sm font-medium text-red-500 hover:text-red-600">?</span>
</Button>
</div>
</header>
{/* Help Sidebar */}
{showHelpSidebar && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 z-[9998]"
onClick={() => setShowHelpSidebar(false)}
/>
{/* Sidebar */}
<div className="fixed right-0 top-0 h-full w-96 bg-card/95 backdrop-blur border-l border-border/60 shadow-xl z-[9999] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-foreground">Help & Guide</h2>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={() => setShowHelpSidebar(false)}
>
<span className="text-lg">×</span>
</Button>
</div>
<div className="space-y-6">
<div>
<h3 className="font-semibold mb-3 text-foreground">🔑 API Token Setup</h3>
<div className="text-sm text-muted-foreground space-y-3">
<div className="p-3 bg-primary/10 border border-primary/20 rounded-lg">
<p className="font-medium text-primary mb-2">Step 1: Get Your API Key</p>
<p>Visit <a href="https://aistudio.google.com/app/apikey" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline font-medium">Google AI Studio</a> to create your free Gemini API key.</p>
</div>
<div className="p-3 bg-secondary border border-border rounded-lg">
<p className="font-medium text-secondary-foreground mb-2">Step 2: Add Your Token</p>
<p>Paste your API key in the "API Token" field in the top navigation bar.</p>
</div>
<div className="p-3 bg-accent border border-border rounded-lg">
<p className="font-medium text-accent-foreground mb-2">Step 3: Start Creating</p>
<p>Your token enables all AI features: image generation, merging, editing, and style transfers.</p>
</div>
</div>
</div>
<div>
<h3 className="font-semibold mb-3 text-foreground">🎨 How to Use the Editor</h3>
<div className="text-sm text-muted-foreground space-y-2">
<p><strong>Adding Nodes:</strong> Right-click on the editor canvas and choose the node type you want, then drag and drop to position it</p>
<p><strong>Character Nodes:</strong> Upload or drag images to create character nodes</p>
<p><strong>Merge Nodes:</strong> Connect multiple characters to create group photos</p>
<p><strong>Style Nodes:</strong> Apply artistic styles and filters</p>
<p><strong>Background Nodes:</strong> Change or generate new backgrounds</p>
<p><strong>Edit Nodes:</strong> Make specific modifications with text prompts</p>
</div>
</div>
<div className="p-4 bg-muted border border-border rounded-lg">
<h4 className="font-semibold text-foreground mb-2">🔒 Privacy & Security</h4>
<div className="text-sm text-muted-foreground space-y-1">
<p>• Your API token is stored locally in your browser</p>
<p>• Tokens are never sent to our servers</p>
<p>• Keep your API key secure and don't share it</p>
<p>• You can revoke keys anytime in Google AI Studio</p>
</div>
</div>
</div>
</div>
</div>
</>
)}
<div
ref={containerRef}
className="relative w-full h-[calc(100svh-56px)] overflow-hidden nb-canvas"
style={{
imageRendering: "auto",
transform: "translateZ(0)",
willChange: "contents"
}}
onContextMenu={onContextMenu}
onPointerDown={onBackgroundPointerDown}
onPointerMove={(e) => {
onBackgroundPointerMove(e);
handlePointerMove(e);
}}
onPointerUp={(e) => {
onBackgroundPointerUp(e);
handlePointerUp();
}}
onPointerLeave={(e) => {
onBackgroundPointerUp(e);
handlePointerUp();
}}
onWheel={onWheel}
>
<div
className="absolute left-0 top-0 will-change-transform"
style={{
transform: `translate3d(${tx}px, ${ty}px, 0) scale(${scale})`,
transformOrigin: "0 0",
transformStyle: "preserve-3d",
backfaceVisibility: "hidden"
}}
>
<svg
className="absolute pointer-events-none z-0"
style={{
left: `${svgBounds.x}px`,
top: `${svgBounds.y}px`,
width: `${svgBounds.width}px`,
height: `${svgBounds.height}px`
}}
viewBox={`${svgBounds.x} ${svgBounds.y} ${svgBounds.width} ${svgBounds.height}`}
>
<defs>
<filter id="glow">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
{connectionPaths.map((p, idx) => (
<path
key={idx}
className={p.processing ? "connection-processing connection-animated" : ""}
d={p.path}
fill="none"
stroke={p.processing ? undefined : (p.active ? "hsl(var(--primary))" : "hsl(var(--muted-foreground))")}
strokeWidth={p.processing ? undefined : "2.5"}
strokeDasharray={p.active && !p.processing ? "5,5" : undefined}
style={p.active && !p.processing ? undefined : (!p.processing ? { opacity: 0.9 } : {})}
/>
))}
</svg>
<div className="relative z-10">
{nodes.map((node) => {
switch (node.type) {
case "CHARACTER":
return (
<CharacterNodeView
key={node.id}
node={node as CharacterNode}
scaleRef={scaleRef}
onChangeImage={setCharacterImage}
onChangeLabel={setCharacterLabel}
onStartConnection={handleStartConnection}
onUpdatePosition={updateNodePosition}
onDelete={deleteNode}
/>
);
case "MERGE":
return (
<MergeNodeView
key={node.id}
node={node as MergeNode}
scaleRef={scaleRef}
allNodes={nodes}
onDisconnect={disconnectFromMerge}
onRun={runMerge}
onEndConnection={handleEndConnection}
onStartConnection={handleStartConnection}
onUpdatePosition={updateNodePosition}
onDelete={deleteNode}
onClearConnections={clearMergeConnections}
/>
);
case "BACKGROUND":
return (
<BackgroundNodeView
key={node.id}
node={node as BackgroundNode}
onDelete={deleteNode}
onUpdate={updateNode}
onStartConnection={handleStartConnection}
onEndConnection={handleEndSingleConnection}
onProcess={processNode}
onUpdatePosition={updateNodePosition}
/>
);
case "CLOTHES":
return (
<ClothesNodeView
key={node.id}
node={node as ClothesNode}
onDelete={deleteNode}
onUpdate={updateNode}
onStartConnection={handleStartConnection}
onEndConnection={handleEndSingleConnection}
onProcess={processNode}
onUpdatePosition={updateNodePosition}
/>
);
case "STYLE":
return (
<StyleNodeView
key={node.id}
node={node as StyleNode}
onDelete={deleteNode}
onUpdate={updateNode}
onStartConnection={handleStartConnection}
onEndConnection={handleEndSingleConnection}
onProcess={processNode}
onUpdatePosition={updateNodePosition}
/>
);
case "EDIT":
return (
<EditNodeView
key={node.id}
node={node as EditNode}
onDelete={deleteNode}
onUpdate={updateNode}
onStartConnection={handleStartConnection}
onEndConnection={handleEndSingleConnection}
onProcess={processNode}
onUpdatePosition={updateNodePosition}
/>
);
case "CAMERA":
return (
<CameraNodeView
key={node.id}
node={node as CameraNode}
onDelete={deleteNode}
onUpdate={updateNode}
onStartConnection={handleStartConnection}
onEndConnection={handleEndSingleConnection}
onProcess={processNode}
onUpdatePosition={updateNodePosition}
/>
);
case "AGE":
return (
<AgeNodeView
key={node.id}
node={node as AgeNode}
onDelete={deleteNode}
onUpdate={updateNode}
onStartConnection={handleStartConnection}
onEndConnection={handleEndSingleConnection}
onProcess={processNode}
onUpdatePosition={updateNodePosition}
/>
);
case "FACE":
return (
<FaceNodeView
key={node.id}
node={node as FaceNode}
onDelete={deleteNode}
onUpdate={updateNode}
onStartConnection={handleStartConnection}
onEndConnection={handleEndSingleConnection}
onProcess={processNode}
onUpdatePosition={updateNodePosition}
/>
);
default:
return null;
}
})}
</div>
</div>
{menuOpen && (
<div
className="absolute z-50 rounded-xl border border-white/10 bg-[#111]/95 backdrop-blur p-1 w-56 shadow-2xl"
style={{ left: menuPos.x, top: menuPos.y }}
onMouseLeave={() => setMenuOpen(false)}
>
<div className="px-3 py-2 text-xs text-white/60">Add node</div>
<div className="max-h-[400px] overflow-y-auto">
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("CHARACTER")}>CHARACTER</button>
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("MERGE")}>MERGE</button>
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("BACKGROUND")}>BACKGROUND</button>
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("CLOTHES")}>CLOTHES</button>
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("STYLE")}>STYLE</button>
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("EDIT")}>EDIT</button>
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("CAMERA")}>CAMERA</button>
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("AGE")}>AGE</button>
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("FACE")}>FACE</button>
</div>
</div>
)}
</div>
</div>
);
}