Spaces:
Running
Running
/** | |
* NANO BANANA EDITOR - MAIN APPLICATION COMPONENT | |
* | |
* This is a visual node-based editor for AI image processing. | |
* Users can create nodes for different operations like merging images, | |
* changing backgrounds, adding clothes, applying styles, etc. | |
* | |
* Key Features: | |
* - Drag & drop interface for connecting nodes | |
* - Real-time image processing using Google's Gemini API | |
* - Support for multiple image operations (merge, style, edit, etc.) | |
* - Visual connection lines with animations | |
* - Viewport controls (pan, zoom) | |
*/ | |
"use client"; | |
// React imports for hooks and core functionality | |
import React, { useEffect, useMemo, useRef, useState } from "react"; | |
// Custom CSS for animations and styling | |
import "./editor.css"; | |
// Import all the different node view components | |
import { | |
BackgroundNodeView, // Changes/generates backgrounds | |
ClothesNodeView, // Adds/changes clothing | |
StyleNodeView, // Applies artistic styles | |
EditNodeView, // General text-based editing | |
CameraNodeView, // Camera effects and settings | |
AgeNodeView, // Age transformation | |
FaceNodeView, // Face modifications | |
LightningNodeView, // Lighting effects | |
PosesNodeView // Pose modifications | |
} from "./nodes"; | |
// UI components from shadcn/ui library | |
import { Button } from "../components/ui/button"; | |
import { Input } from "../components/ui/input"; | |
// Hugging Face OAuth functionality | |
import { oauthLoginUrl, oauthHandleRedirectIfPresent } from '@huggingface/hub'; | |
/** | |
* Utility function to combine CSS class names conditionally | |
* Filters out falsy values and joins the remaining strings with spaces | |
* Example: cx("class1", condition && "class2", null) => "class1 class2" or "class1" | |
*/ | |
function cx(...args: Array<string | false | null | undefined>) { | |
return args.filter(Boolean).join(" "); | |
} | |
/** | |
* Generate a unique ID for new nodes | |
* Uses Math.random() to create a random string identifier | |
* Format: random base-36 string (letters + numbers), 7 characters long | |
*/ | |
const uid = () => Math.random().toString(36).slice(2, 9); | |
/** | |
* Generate AI prompt for merging multiple character images into a single cohesive group photo | |
* | |
* This function creates a detailed prompt that instructs the AI model to: | |
* 1. Extract people from separate images | |
* 2. Combine them naturally as if photographed together | |
* 3. Ensure consistent lighting, shadows, and perspective | |
* 4. Create a believable group composition | |
* | |
* @param characterData Array of objects containing image data and labels | |
* @returns Detailed prompt string for the AI merge operation | |
*/ | |
function generateMergePrompt(characterData: { image: string; label: string }[]): string { | |
const count = characterData.length; | |
// Create a summary of all images being processed | |
const labels = characterData.map((d, i) => `Image ${i + 1} (${d.label})`).join(", "); | |
// Return comprehensive prompt with specific instructions for natural-looking merge | |
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.`; | |
} | |
/** | |
* Copy image to clipboard with PNG conversion | |
* The clipboard API only supports PNG format for images, so we convert other formats | |
*/ | |
async function copyImageToClipboard(dataUrl: string) { | |
try { | |
const response = await fetch(dataUrl); | |
const blob = await response.blob(); | |
// Convert to PNG if not already PNG | |
if (blob.type !== 'image/png') { | |
const canvas = document.createElement('canvas'); | |
const ctx = canvas.getContext('2d'); | |
const img = new Image(); | |
await new Promise((resolve) => { | |
img.onload = () => { | |
canvas.width = img.width; | |
canvas.height = img.height; | |
ctx?.drawImage(img, 0, 0); | |
resolve(void 0); | |
}; | |
img.src = dataUrl; | |
}); | |
const pngBlob = await new Promise<Blob>((resolve) => { | |
canvas.toBlob((blob) => resolve(blob!), 'image/png'); | |
}); | |
await navigator.clipboard.write([ | |
new ClipboardItem({ 'image/png': pngBlob }) | |
]); | |
} else { | |
await navigator.clipboard.write([ | |
new ClipboardItem({ 'image/png': blob }) | |
]); | |
} | |
} catch (error) { | |
console.error('Failed to copy image to clipboard:', error); | |
} | |
} | |
/* ======================================== | |
TYPE DEFINITIONS | |
======================================== */ | |
/** | |
* All possible node types in the editor | |
* Each type represents a different kind of image processing operation | |
*/ | |
type NodeType = "CHARACTER" | "MERGE" | "BACKGROUND" | "CLOTHES" | "STYLE" | "EDIT" | "CAMERA" | "AGE" | "FACE" | "BLEND" | "LIGHTNING" | "POSES"; | |
/** | |
* Base properties that all nodes share | |
* Every node has an ID, type, and position in the editor world space | |
*/ | |
type NodeBase = { | |
id: string; // Unique identifier for the node | |
type: NodeType; // What kind of operation this node performs | |
x: number; // X position in world coordinates (not screen pixels) | |
y: number; // Y position in world coordinates (not screen pixels) | |
}; | |
/** | |
* CHARACTER node - Contains source images (people/subjects) | |
* These are the starting points for most image processing workflows | |
* Users can upload images or paste URLs/data URLs | |
*/ | |
type CharacterNode = NodeBase & { | |
type: "CHARACTER"; | |
image: string; // Image data (data URL, http URL, or file path) | |
label?: string; // Optional human-readable name for the character | |
}; | |
/** | |
* MERGE node - Combines multiple inputs into a single group photo | |
* Takes multiple CHARACTER or processed nodes and creates a cohesive image | |
* Uses AI to naturally blend subjects together with consistent lighting | |
*/ | |
type MergeNode = NodeBase & { | |
type: "MERGE"; | |
inputs: string[]; // Array of node IDs to merge together | |
output?: string | null; // Resulting merged image (data URL) | |
isRunning?: boolean; // Whether merge operation is currently processing | |
error?: string | null; // Error message if merge failed | |
}; | |
/** | |
* BACKGROUND node - Changes or generates backgrounds | |
* Can use solid colors, preset images, uploaded custom images, or AI-generated backgrounds | |
*/ | |
type BackgroundNode = NodeBase & { | |
type: "BACKGROUND"; | |
input?: string; // ID of the source node (usually CHARACTER) | |
output?: string; // Processed image with new background | |
backgroundType: "color" | "gradient" | "image" | "city" | "photostudio" | "upload" | "custom"; // Type of background to apply | |
backgroundColor?: string; // Hex color code for solid color backgrounds | |
// Gradient background properties | |
gradientDirection?: string; // Direction of gradient (to right, to bottom, radial, etc.) | |
gradientStartColor?: string; // Starting color of gradient | |
gradientEndColor?: string; // Ending color of gradient | |
backgroundImage?: string; // URL/path for preset background images | |
// City scene properties | |
citySceneType?: string; // Type of city scene (busy_street, times_square, etc.) | |
cityTimeOfDay?: string; // Time of day for city scene | |
// Photo studio properties | |
studioSetup?: string; // Studio background setup type | |
studioBackgroundColor?: string; // Color for colored seamless background | |
studioLighting?: string; // Studio lighting setup | |
faceCamera?: boolean; // Whether to position character facing camera | |
customBackgroundImage?: string; // User-uploaded background image data | |
customPrompt?: string; // AI prompt for generating custom backgrounds | |
isRunning?: boolean; // Processing state indicator | |
error?: string | null; // Error message if processing failed | |
}; | |
/** | |
* CLOTHES node - Adds or changes clothing on subjects | |
* Can use preset clothing styles or custom uploaded clothing images | |
*/ | |
type ClothesNode = NodeBase & { | |
type: "CLOTHES"; | |
input?: string; // ID of the source node | |
output?: string; // Image with modified clothing | |
clothesImage?: string; // Custom clothing image to apply | |
selectedPreset?: string; // Preset clothing style identifier | |
clothesPrompt?: string; // Text description for clothing changes | |
isRunning?: boolean; // Processing state | |
error?: string | null; // Error message | |
}; | |
/** | |
* STYLE node - Applies artistic styles and filters | |
* Uses AI to transform images with different artistic styles (oil painting, watercolor, etc.) | |
*/ | |
type StyleNode = NodeBase & { | |
type: "STYLE"; | |
input?: string; // Source node ID | |
output?: string; // Styled output image | |
stylePreset?: string; // Selected artistic style | |
styleStrength?: number; // How strongly to apply the style (0-100) | |
isRunning?: boolean; // Processing indicator | |
error?: string | null; // Error message | |
}; | |
/** | |
* EDIT node - General purpose text-based image editing | |
* Uses natural language prompts to make specific changes to images | |
*/ | |
type EditNode = NodeBase & { | |
type: "EDIT"; | |
input?: string; // Input node ID | |
output?: string; // Edited output image | |
editPrompt?: string; // Natural language description of desired changes | |
isRunning?: boolean; // Whether edit is being processed | |
error?: string | null; // Error if edit failed | |
}; | |
/** | |
* CAMERA node - Applies camera effects and photographic settings | |
* Simulates different camera settings, lenses, and photographic techniques | |
*/ | |
type CameraNode = NodeBase & { | |
type: "CAMERA"; | |
input?: string; // Source image node ID | |
output?: string; // Image with camera effects applied | |
focalLength?: string; // Lens focal length (e.g., "50mm", "85mm") | |
aperture?: string; // Aperture setting (e.g., "f/1.4", "f/2.8") | |
shutterSpeed?: string; // Shutter speed (e.g., "1/60", "1/125") | |
whiteBalance?: string; // Color temperature setting | |
angle?: string; // Camera angle/perspective | |
iso?: string; // ISO sensitivity setting | |
filmStyle?: string; // Film simulation (e.g., "Kodak", "Fuji") | |
lighting?: string; // Lighting setup description | |
bokeh?: string; // Background blur style | |
composition?: string; // Composition technique | |
aspectRatio?: string; // Image aspect ratio | |
motionBlur?: string; // Motion blur effect | |
isRunning?: boolean; // Processing status | |
error?: string | null; // Error message | |
}; | |
/** | |
* AGE node - Transforms subject age | |
* Uses AI to make people appear older or younger while maintaining their identity | |
*/ | |
type AgeNode = NodeBase & { | |
type: "AGE"; | |
input?: string; // Input node ID | |
output?: string; // Age-transformed image | |
targetAge?: number; // Target age to transform to (in years) | |
isRunning?: boolean; // Processing indicator | |
error?: string | null; // Error if transformation failed | |
}; | |
/** | |
* FACE node - Modifies facial features and accessories | |
* Can add/remove facial hair, accessories, change expressions, etc. | |
*/ | |
type FaceNode = NodeBase & { | |
type: "FACE"; | |
input?: string; // Source node ID | |
output?: string; // Modified face image | |
faceOptions?: { // Collection of face modification options | |
removePimples?: boolean; // Clean up skin blemishes | |
addSunglasses?: boolean; // Add sunglasses accessory | |
addHat?: boolean; // Add hat accessory | |
changeHairstyle?: string; // New hairstyle description | |
facialExpression?: string; // Change facial expression | |
beardStyle?: string; // Add/modify facial hair | |
selectedMakeup?: string; // Selected makeup style | |
makeupImage?: string; // Path to makeup reference image | |
}; | |
isRunning?: boolean; // Processing state | |
error?: string | null; // Error message | |
}; | |
/** | |
* BLEND node - Blends/composites images with adjustable opacity | |
* Used for subtle image combinations and overlay effects | |
*/ | |
type BlendNode = NodeBase & { | |
type: "BLEND"; | |
input?: string; // Primary input node ID | |
output?: string; // Blended output image | |
blendStrength?: number; // Blend intensity (0-100 percent) | |
isRunning?: boolean; // Processing indicator | |
error?: string | null; // Error message | |
}; | |
/** | |
* LIGHTNING node - Applies lighting effects to images | |
* Uses preset lighting styles and images for realistic lighting effects | |
*/ | |
type LightningNode = NodeBase & { | |
type: "LIGHTNING"; | |
input?: string; // Source node ID | |
output?: string; // Image with lighting applied | |
selectedLighting?: string; // Selected lighting preset name | |
lightingPrompt?: string; // Text prompt for lighting effect | |
lightingStrength?: number; // Intensity of lighting effect (0-100) | |
isRunning?: boolean; // Processing state | |
error?: string | null; // Error message | |
}; | |
/** | |
* POSES node - Applies pose modifications to subjects | |
* Uses preset pose images to modify subject poses | |
*/ | |
type PosesNode = NodeBase & { | |
type: "POSES"; | |
input?: string; // Source node ID | |
output?: string; // Image with pose applied | |
selectedPose?: string; // Selected pose preset name | |
posePrompt?: string; // Text prompt for pose effect | |
poseStrength?: number; // How strongly to apply the pose (0-100) | |
isRunning?: boolean; // Processing state | |
error?: string | null; // Error message | |
}; | |
/** | |
* Union type of all possible node types | |
* Used for type-safe handling of nodes throughout the application | |
*/ | |
type AnyNode = CharacterNode | MergeNode | BackgroundNode | ClothesNode | StyleNode | EditNode | CameraNode | AgeNode | FaceNode | BlendNode | LightningNode | PosesNode; | |
/* ======================================== | |
CONSTANTS AND UTILITY FUNCTIONS | |
======================================== */ | |
/** | |
* Default placeholder image for new CHARACTER nodes | |
* Uses Unsplash image as a starting point before users upload their own images | |
*/ | |
const DEFAULT_PERSON = "/reo.png"; | |
/** | |
* Convert File objects to data URLs for image processing | |
* | |
* Takes a FileList or array of File objects (from drag/drop or file input) | |
* and converts each file to a base64 data URL that can be used in img tags | |
* or sent to APIs for processing. | |
* | |
* @param files FileList or File array from input events | |
* @returns Promise that resolves to array of data URL strings | |
*/ | |
function toDataUrls(files: FileList | File[]): Promise<string[]> { | |
const arr = Array.from(files as File[]); // Convert FileList to regular array | |
return Promise.all( | |
arr.map( | |
(file) => | |
new Promise<string>((resolve, reject) => { | |
const r = new FileReader(); // Browser API for reading files | |
r.onload = () => resolve(r.result as string); // Success: return data URL | |
r.onerror = reject; // Error: reject promise | |
r.readAsDataURL(file); // Start reading as base64 data URL | |
}) | |
) | |
); | |
} | |
/** | |
* Convert screen pixel coordinates to world coordinates | |
* | |
* The editor uses a coordinate system where: | |
* - Screen coordinates: actual pixel positions on the browser window | |
* - World coordinates: virtual positions that account for pan/zoom transformations | |
* | |
* This function converts mouse/touch positions to world space for accurate node positioning. | |
* | |
* @param clientX Mouse X position in screen pixels | |
* @param clientY Mouse Y position in screen pixels | |
* @param container Bounding rect of the editor container | |
* @param tx Current pan transform X offset | |
* @param ty Current pan transform Y offset | |
* @param scale Current zoom scale factor | |
* @returns Object with world coordinates {x, y} | |
*/ | |
function screenToWorld( | |
clientX: number, | |
clientY: number, | |
container: DOMRect, | |
tx: number, | |
ty: number, | |
scale: number | |
) { | |
const x = (clientX - container.left - tx) / scale; // Account for container offset, pan, and zoom | |
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 hover:bg-destructive/20 h-6 w-6" | |
onClick={(e) => { | |
e.stopPropagation(); | |
e.preventDefault(); | |
if (confirm('Delete this character node?')) { | |
onDelete(node.id); | |
} | |
}} | |
onPointerDown={(e) => e.stopPropagation()} | |
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 cursor-pointer hover:opacity-80 transition-opacity" | |
draggable={false} | |
onClick={async () => { | |
try { | |
const response = await fetch(node.image); | |
const blob = await response.blob(); | |
await navigator.clipboard.write([ | |
new ClipboardItem({ [blob.type]: blob }) | |
]); | |
} catch (error) { | |
console.error('Failed to copy image:', error); | |
} | |
}} | |
onContextMenu={async (e) => { | |
e.preventDefault(); | |
try { | |
const response = await fetch(node.image); | |
const blob = await response.blob(); | |
await navigator.clipboard.write([ | |
new ClipboardItem({ [blob.type]: blob }) | |
]); | |
// Show visual feedback | |
const img = e.currentTarget; | |
const originalFilter = img.style.filter; | |
img.style.filter = "brightness(1.2)"; | |
setTimeout(() => { | |
img.style.filter = originalFilter; | |
}, 500); | |
} catch (error) { | |
console.error('Failed to copy image:', error); | |
} | |
}} | |
title="Click or right-click to copy image to clipboard" | |
/> | |
</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 | |
variant="ghost" | |
size="icon" | |
className="text-destructive hover:bg-destructive/20 h-6 w-6" | |
onClick={(e) => { | |
e.stopPropagation(); | |
e.preventDefault(); | |
if (confirm('Delete this merge node?')) { | |
onDelete(node.id); | |
} | |
}} | |
onPointerDown={(e) => e.stopPropagation()} | |
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="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 cursor-pointer hover:opacity-80" | |
alt="inp" | |
onClick={async () => { | |
try { | |
const response = await fetch(image); | |
const blob = await response.blob(); | |
await navigator.clipboard.write([ | |
new ClipboardItem({ [blob.type]: blob }) | |
]); | |
} catch (error) { | |
console.error('Failed to copy image:', error); | |
} | |
}} | |
onContextMenu={async (e) => { | |
e.preventDefault(); | |
try { | |
const response = await fetch(image); | |
const blob = await response.blob(); | |
await navigator.clipboard.write([ | |
new ClipboardItem({ [blob.type]: blob }) | |
]); | |
// Show visual feedback | |
const img = e.currentTarget; | |
const originalFilter = img.style.filter; | |
img.style.filter = "brightness(1.2)"; | |
setTimeout(() => { | |
img.style.filter = originalFilter; | |
}, 300); | |
} catch (error) { | |
console.error('Failed to copy image:', error); | |
} | |
}} | |
title="Click or right-click to copy" | |
/> | |
</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="flex items-center justify-between mb-1"> | |
<div className="text-xs text-white/70">Output</div> | |
</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 cursor-pointer hover:opacity-80 transition-opacity" | |
alt="output" | |
onClick={async () => { | |
if (node.output) { | |
try { | |
const response = await fetch(node.output); | |
const blob = await response.blob(); | |
await navigator.clipboard.write([ | |
new ClipboardItem({ [blob.type]: blob }) | |
]); | |
} catch (error) { | |
console.error('Failed to copy image:', error); | |
} | |
} | |
}} | |
onContextMenu={async (e) => { | |
e.preventDefault(); | |
if (node.output) { | |
try { | |
const response = await fetch(node.output); | |
const blob = await response.blob(); | |
await navigator.clipboard.write([ | |
new ClipboardItem({ [blob.type]: blob }) | |
]); | |
// Show visual feedback | |
const img = e.currentTarget; | |
const originalFilter = img.style.filter; | |
img.style.filter = "brightness(1.2)"; | |
setTimeout(() => { | |
img.style.filter = originalFilter; | |
}, 500); | |
} catch (error) { | |
console.error('Failed to copy image:', error); | |
} | |
} | |
}} | |
title="Click or right-click to copy image to clipboard" | |
/> | |
) : ( | |
<span className="text-white/40 text-xs py-16">Run merge to see result</span> | |
)} | |
</div> | |
{node.output && ( | |
<div className="mt-2"> | |
<Button | |
className="w-full" | |
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> | |
</div> | |
)} | |
{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]); | |
// HF OAUTH CHECK | |
useEffect(() => { | |
(async () => { | |
setIsCheckingAuth(true); | |
try { | |
// Handle OAuth redirect if present | |
const oauth = await oauthHandleRedirectIfPresent(); | |
if (oauth) { | |
// Store the token server-side | |
await fetch('/api/auth/callback', { | |
method: 'POST', | |
body: JSON.stringify({ hf_token: oauth.accessToken }), | |
headers: { 'Content-Type': 'application/json' } | |
}); | |
setIsHfProLoggedIn(true); | |
} else { | |
// Check if already logged in | |
const response = await fetch('/api/auth/callback', { method: 'GET' }); | |
if (response.ok) { | |
const data = await response.json(); | |
setIsHfProLoggedIn(data.isLoggedIn); | |
} | |
} | |
} catch (error) { | |
console.error('OAuth error:', error); | |
} finally { | |
setIsCheckingAuth(false); | |
} | |
})(); | |
}, []); | |
// HF PRO LOGIN HANDLER | |
const handleHfProLogin = async () => { | |
if (isHfProLoggedIn) { | |
// Logout: clear the token | |
try { | |
await fetch('/api/auth/callback', { method: 'DELETE' }); | |
setIsHfProLoggedIn(false); | |
} catch (error) { | |
console.error('Logout error:', error); | |
} | |
} else { | |
// Login with HF OAuth | |
const clientId = process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID; | |
if (!clientId) { | |
console.error('OAuth client ID not configured'); | |
alert('OAuth client ID not configured. Please check environment variables.'); | |
return; | |
} | |
window.location.href = await oauthLoginUrl({ | |
clientId, | |
redirectUrl: `${window.location.origin}/api/auth/callback` | |
}); | |
} | |
}; | |
// Connection dragging state | |
const [draggingFrom, setDraggingFrom] = useState<string | null>(null); | |
const [dragPos, setDragPos] = useState<{x: number, y: number} | null>(null); | |
// API Token state (restored for manual review) | |
const [apiToken, setApiToken] = useState(""); | |
const [showHelpSidebar, setShowHelpSidebar] = useState(false); | |
// HF PRO AUTHENTICATION | |
const [isHfProLoggedIn, setIsHfProLoggedIn] = useState(false); | |
const [isCheckingAuth, setIsCheckingAuth] = useState(true); | |
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): Record<string, unknown> => { | |
const config: Record<string, unknown> = {}; | |
switch (node.type) { | |
case "BACKGROUND": | |
if ((node as BackgroundNode).backgroundType) { | |
const bgNode = node as BackgroundNode; | |
config.backgroundType = bgNode.backgroundType; | |
config.backgroundColor = bgNode.backgroundColor; | |
config.backgroundImage = bgNode.backgroundImage; | |
config.customBackgroundImage = bgNode.customBackgroundImage; | |
config.customPrompt = bgNode.customPrompt; | |
// Gradient properties | |
if (bgNode.backgroundType === "gradient") { | |
config.gradientDirection = bgNode.gradientDirection; | |
config.gradientStartColor = bgNode.gradientStartColor; | |
config.gradientEndColor = bgNode.gradientEndColor; | |
} | |
// City scene properties | |
if (bgNode.backgroundType === "city") { | |
config.citySceneType = bgNode.citySceneType; | |
config.cityTimeOfDay = bgNode.cityTimeOfDay; | |
} | |
// Photo studio properties | |
if (bgNode.backgroundType === "photostudio") { | |
config.studioSetup = bgNode.studioSetup; | |
config.studioBackgroundColor = bgNode.studioBackgroundColor; | |
config.studioLighting = bgNode.studioLighting; | |
config.faceCamera = bgNode.faceCamera; | |
} | |
} | |
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; | |
if (cam.motionBlur && cam.motionBlur !== "None") config.motionBlur = cam.motionBlur; | |
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: Record<string, unknown> = {}; | |
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; | |
case "LIGHTNING": | |
if ((node as LightningNode).lightingPrompt && (node as LightningNode).selectedLighting) { | |
config.lightingPrompt = (node as LightningNode).lightingPrompt; | |
config.selectedLighting = (node as LightningNode).selectedLighting; | |
} | |
break; | |
case "POSES": | |
if ((node as PosesNode).posePrompt && (node as PosesNode).selectedPose) { | |
config.posePrompt = (node as PosesNode).posePrompt; | |
config.selectedPose = (node as PosesNode).selectedPose; | |
} | |
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) { | |
// 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) { | |
} else { | |
} | |
// 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) { | |
// 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) { | |
// 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 | |
// ORIGINAL PROCESSING LOGIC RESTORED (HF processing commented out) | |
/* | |
// Only use HF + fal.ai processing | |
if (!isHfProLoggedIn) { | |
setNodes(prev => prev.map(n => | |
n.id === nodeId ? { ...n, error: "Please login with HF Pro to use fal.ai processing", isRunning: false } : n | |
)); | |
return; | |
} | |
// Make a SINGLE API call with fal.ai processing | |
const res = await fetch("/api/hf-process", { | |
method: "POST", | |
headers: { "Content-Type": "application/json" }, | |
body: JSON.stringify({ | |
type: "COMBINED", | |
image: inputImage, | |
params | |
}), | |
}); | |
*/ | |
// 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) { | |
// Handle both string and object error formats | |
const errorMessage = typeof data.error === 'string' | |
? data.error | |
: data.error?.message || JSON.stringify(data.error) || "Processing failed"; | |
throw new Error(errorMessage); | |
} | |
// 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; | |
})); | |
// Add to node's history | |
const description = unprocessedNodeCount > 1 | |
? `Combined ${unprocessedNodeCount} transformations` | |
: `${node.type} transformation`; | |
if (unprocessedNodeCount > 1) { | |
} | |
} 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 | |
const prompt = generateMergePrompt(inputData); | |
// ORIGINAL MERGE LOGIC RESTORED (HF processing commented out) | |
/* | |
const res = await fetch("/api/hf-process", { | |
method: "POST", | |
headers: { "Content-Type": "application/json" }, | |
body: JSON.stringify({ | |
type: "MERGE", | |
images: mergeImages, | |
prompt | |
}), | |
}); | |
*/ | |
// 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 | |
// Generate dynamic prompt based on number of inputs | |
const prompt = generateMergePrompt(inputData); | |
const imgs = inputData.map(d => d.image); | |
// ORIGINAL RUNMERGE LOGIC RESTORED (HF processing commented out) | |
/* | |
if (!isHfProLoggedIn) { | |
throw new Error("Please login with HF Pro to use fal.ai processing"); | |
} | |
const res = await fetch("/api/hf-process", { | |
method: "POST", | |
headers: { "Content-Type": "application/json" }, | |
body: JSON.stringify({ | |
type: "MERGE", | |
images: imgs, | |
prompt | |
}), | |
}); | |
*/ | |
// 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))); | |
// Add merge result to node's history | |
if (out) { | |
const inputLabels = merge.inputs.map((id, index) => { | |
const inputNode = nodes.find(n => n.id === id); | |
if (inputNode?.type === "CHARACTER") { | |
return (inputNode as CharacterNode).label || `Character ${index + 1}`; | |
} | |
return `${inputNode?.type || 'Node'} ${index + 1}`; | |
}); | |
} | |
} 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; // Only animate to the currently processing merge node | |
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; // Only animate to the currently processing node | |
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); | |
// Menu dimensions | |
const menuWidth = 224; // w-56 = 224px | |
const menuHeight = 320; // Approximate height with max-h-[300px] + padding | |
// Calculate position relative to container | |
let x = e.clientX - rect.left; | |
let y = e.clientY - rect.top; | |
// Adjust if menu would go off right edge | |
if (x + menuWidth > rect.width) { | |
x = rect.width - menuWidth - 10; | |
} | |
// Adjust if menu would go off bottom edge | |
if (y + menuHeight > rect.height) { | |
y = rect.height - menuHeight - 10; | |
} | |
// Ensure minimum margins from edges | |
x = Math.max(10, x); | |
y = Math.max(10, y); | |
setMenuPos({ x, y }); | |
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; | |
case "EDIT": | |
setNodes(prev => [...prev, { ...commonProps, type: "EDIT" } as EditNode]); | |
break; | |
case "LIGHTNING": | |
setNodes(prev => [...prev, { ...commonProps, type: "LIGHTNING", lightingStrength: 75 } as LightningNode]); | |
break; | |
case "POSES": | |
setNodes(prev => [...prev, { ...commonProps, type: "POSES", poseStrength: 60 } as PosesNode]); | |
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-3"> | |
{/* ORIGINAL API TOKEN INPUT RESTORED */} | |
<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="outline" | |
size="sm" | |
className="h-8 px-3" | |
type="button" | |
onClick={() => setShowHelpSidebar(true)} | |
> | |
Help | |
</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"> | |
{/* ORIGINAL HELP CONTENT RESTORED (HF help commented out) */} | |
{/* | |
<div> | |
<h3 className="font-semibold mb-3 text-foreground">🤗 HF Pro Login</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: Login with Hugging Face</p> | |
<p>Click "Login HF PRO" to authenticate with your Hugging Face account.</p> | |
</div> | |
<div className="p-3 bg-secondary border border-border rounded-lg"> | |
<p className="font-medium text-secondary-foreground mb-2">Step 2: Access fal.ai Models</p> | |
<p>Once logged in, you'll have access to fal.ai's Gemini 2.5 Flash Image models.</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>Use the powerful fal.ai models for image generation, merging, editing, and style transfers.</p> | |
</div> | |
</div> | |
</div> | |
*/} | |
<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"> | |
{/* ORIGINAL PRIVACY INFO RESTORED (HF privacy info commented out) */} | |
{/* | |
<p>• Your HF token is stored securely in HTTP-only cookies</p> | |
<p>• Authentication happens through Hugging Face OAuth</p> | |
<p>• You can logout anytime to revoke access</p> | |
<p>• Processing happens via fal.ai's secure infrastructure</p> | |
*/} | |
<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} | |
apiToken={apiToken} | |
/> | |
); | |
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} | |
apiToken={apiToken} | |
/> | |
); | |
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} | |
/> | |
); | |
case "LIGHTNING": | |
return ( | |
<LightningNodeView | |
key={node.id} | |
node={node as LightningNode} | |
onDelete={deleteNode} | |
onUpdate={updateNode} | |
onStartConnection={handleStartConnection} | |
onEndConnection={handleEndSingleConnection} | |
onProcess={processNode} | |
onUpdatePosition={updateNodePosition} | |
/> | |
); | |
case "POSES": | |
return ( | |
<PosesNodeView | |
key={node.id} | |
node={node as PosesNode} | |
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-[300px] overflow-y-auto scrollbar-thin pr-1" | |
onWheel={(e) => e.stopPropagation()} | |
> | |
<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> | |
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("LIGHTNING")}>LIGHTNING</button> | |
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("POSES")}>POSES</button> | |
</div> | |
</div> | |
)} | |
</div> | |
</div> | |
); | |
} | |