Spaces:
Running
Running
"use client"; | |
import React, { useState, useRef, useEffect } from "react"; | |
import { Button } from "../components/ui/button"; | |
import { Select } from "../components/ui/select"; | |
import { Textarea } from "../components/ui/textarea"; | |
import { Label } from "../components/ui/label"; | |
import { Slider } from "../components/ui/slider"; | |
import { ColorPicker } from "../components/ui/color-picker"; | |
import { Checkbox } from "../components/ui/checkbox"; | |
// Helper function to download image | |
function downloadImage(dataUrl: string, filename: string) { | |
const link = document.createElement('a'); | |
link.href = dataUrl; | |
link.download = filename; | |
document.body.appendChild(link); | |
link.click(); | |
document.body.removeChild(link); | |
} | |
// Import types (we'll need to export these from page.tsx) | |
type BackgroundNode = any; | |
type ClothesNode = any; | |
type BlendNode = any; | |
type EditNode = any; | |
type CameraNode = any; | |
type AgeNode = any; | |
type FaceNode = any; | |
function cx(...args: Array<string | false | null | undefined>) { | |
return args.filter(Boolean).join(" "); | |
} | |
// Reusable drag hook for all nodes | |
function useNodeDrag(node: any, onUpdatePosition?: (id: string, x: number, y: number) => void) { | |
const [localPos, setLocalPos] = useState({ x: node.x, y: node.y }); | |
const dragging = useRef(false); | |
const start = useRef<{ sx: number; sy: number; ox: number; oy: number } | null>(null); | |
useEffect(() => { | |
setLocalPos({ x: node.x, y: node.y }); | |
}, [node.x, node.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; | |
const dy = e.clientY - start.current.sy; | |
const newX = start.current.ox + dx; | |
const newY = start.current.oy + dy; | |
setLocalPos({ x: newX, y: newY }); | |
if (onUpdatePosition) onUpdatePosition(node.id, newX, newY); | |
}; | |
const onPointerUp = (e: React.PointerEvent) => { | |
dragging.current = false; | |
start.current = null; | |
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); | |
}; | |
return { 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} | |
/> | |
); | |
} | |
export function BackgroundNodeView({ | |
node, | |
onDelete, | |
onUpdate, | |
onStartConnection, | |
onEndConnection, | |
onProcess, | |
onUpdatePosition, | |
}: any) { | |
const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition); | |
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => { | |
if (e.target.files?.length) { | |
const reader = new FileReader(); | |
reader.onload = () => { | |
onUpdate(node.id, { customBackgroundImage: reader.result }); | |
}; | |
reader.readAsDataURL(e.target.files[0]); | |
} | |
}; | |
const handleImagePaste = (e: React.ClipboardEvent) => { | |
const items = e.clipboardData.items; | |
for (let i = 0; i < items.length; i++) { | |
if (items[i].type.startsWith("image/")) { | |
const file = items[i].getAsFile(); | |
if (file) { | |
const reader = new FileReader(); | |
reader.onload = () => { | |
onUpdate(node.id, { customBackgroundImage: reader.result }); | |
}; | |
reader.readAsDataURL(file); | |
return; | |
} | |
} | |
} | |
const text = e.clipboardData.getData("text"); | |
if (text && (text.startsWith("http") || text.startsWith("data:image"))) { | |
onUpdate(node.id, { customBackgroundImage: text }); | |
} | |
}; | |
const handleDrop = (e: React.DragEvent) => { | |
e.preventDefault(); | |
const files = e.dataTransfer.files; | |
if (files && files.length) { | |
const reader = new FileReader(); | |
reader.onload = () => { | |
onUpdate(node.id, { customBackgroundImage: reader.result }); | |
}; | |
reader.readAsDataURL(files[0]); | |
} | |
}; | |
return ( | |
<div | |
className="nb-node absolute text-white w-[320px]" | |
style={{ left: localPos.x, top: localPos.y }} | |
onDrop={handleDrop} | |
onDragOver={(e) => e.preventDefault()} | |
onPaste={handleImagePaste} | |
> | |
<div | |
className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing" | |
onPointerDown={onPointerDown} | |
onPointerMove={onPointerMove} | |
onPointerUp={onPointerUp} | |
> | |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} /> | |
<div className="font-semibold text-sm flex-1 text-center">BACKGROUND</div> | |
<div className="flex items-center gap-1"> | |
<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 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"> | |
<Select | |
className="w-full" | |
value={node.backgroundType || "color"} | |
onChange={(e) => onUpdate(node.id, { backgroundType: (e.target as HTMLSelectElement).value })} | |
> | |
<option value="color">Solid Color</option> | |
<option value="image">Preset Background</option> | |
<option value="upload">Upload Image</option> | |
<option value="custom">Custom Prompt</option> | |
</Select> | |
{node.backgroundType === "color" && ( | |
<ColorPicker | |
className="w-full" | |
value={node.backgroundColor || "#ffffff"} | |
onChange={(e) => onUpdate(node.id, { backgroundColor: (e.target as HTMLInputElement).value })} | |
/> | |
)} | |
{node.backgroundType === "image" && ( | |
<Select | |
className="w-full" | |
value={node.backgroundImage || ""} | |
onChange={(e) => onUpdate(node.id, { backgroundImage: (e.target as HTMLSelectElement).value })} | |
> | |
<option value="">Select Background</option> | |
<option value="beach">Beach</option> | |
<option value="office">Office</option> | |
<option value="studio">Studio</option> | |
<option value="nature">Nature</option> | |
<option value="city">City Skyline</option> | |
</Select> | |
)} | |
{node.backgroundType === "upload" && ( | |
<div className="space-y-2"> | |
{node.customBackgroundImage ? ( | |
<div className="relative"> | |
<img src={node.customBackgroundImage} className="w-full rounded" alt="Custom Background" /> | |
<Button | |
variant="destructive" | |
size="sm" | |
className="absolute top-2 right-2" | |
onClick={() => onUpdate(node.id, { customBackgroundImage: null })} | |
> | |
Remove | |
</Button> | |
</div> | |
) : ( | |
<label className="block"> | |
<input | |
type="file" | |
accept="image/*" | |
className="hidden" | |
onChange={handleImageUpload} | |
/> | |
<div className="border-2 border-dashed border-white/20 rounded-lg p-4 text-center cursor-pointer hover:border-white/40"> | |
<p className="text-xs text-white/60">Drop, upload, or paste background image</p> | |
<p className="text-xs text-white/40 mt-1">JPG, PNG, WEBP</p> | |
</div> | |
</label> | |
)} | |
</div> | |
)} | |
{node.backgroundType === "custom" && ( | |
<Textarea | |
className="w-full" | |
placeholder="Describe the background..." | |
value={node.customPrompt || ""} | |
onChange={(e) => onUpdate(node.id, { customPrompt: (e.target as HTMLTextAreaElement).value })} | |
rows={2} | |
/> | |
)} | |
<Button | |
className="w-full" | |
onClick={() => onProcess(node.id)} | |
disabled={node.isRunning} | |
title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"} | |
> | |
{node.isRunning ? "Processing..." : "Apply Background"} | |
</Button> | |
{node.output && ( | |
<div className="space-y-2"> | |
<img src={node.output} className="w-full rounded" alt="Output" /> | |
<Button | |
className="w-full" | |
variant="secondary" | |
onClick={() => downloadImage(node.output, `background-${Date.now()}.png`)} | |
> | |
π₯ Download Output | |
</Button> | |
</div> | |
)} | |
{node.error && ( | |
<div className="text-xs text-red-400 mt-2">{node.error}</div> | |
)} | |
</div> | |
</div> | |
); | |
} | |
export function ClothesNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) { | |
const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition); | |
const presetClothes = [ | |
{ name: "Sukajan", path: "/sukajan.png" }, | |
{ name: "Blazer", path: "/blazzer.png" }, | |
]; | |
const onDrop = async (e: React.DragEvent) => { | |
e.preventDefault(); | |
const files = e.dataTransfer.files; | |
if (files && files.length) { | |
const reader = new FileReader(); | |
reader.onload = () => onUpdate(node.id, { clothesImage: reader.result, selectedPreset: null }); | |
reader.readAsDataURL(files[0]); | |
} | |
}; | |
const onPaste = async (e: React.ClipboardEvent) => { | |
const items = e.clipboardData.items; | |
for (let i = 0; i < items.length; i++) { | |
if (items[i].type.startsWith("image/")) { | |
const file = items[i].getAsFile(); | |
if (file) { | |
const reader = new FileReader(); | |
reader.onload = () => onUpdate(node.id, { clothesImage: reader.result, selectedPreset: null }); | |
reader.readAsDataURL(file); | |
return; | |
} | |
} | |
} | |
const text = e.clipboardData.getData("text"); | |
if (text && (text.startsWith("http") || text.startsWith("data:image"))) { | |
onUpdate(node.id, { clothesImage: text, selectedPreset: null }); | |
} | |
}; | |
const selectPreset = (presetPath: string, presetName: string) => { | |
onUpdate(node.id, { clothesImage: presetPath, selectedPreset: presetName }); | |
}; | |
return ( | |
<div | |
className="nb-node absolute text-white w-[320px]" | |
style={{ left: localPos.x, top: localPos.y }} | |
onDrop={onDrop} | |
onDragOver={(e) => e.preventDefault()} | |
onPaste={onPaste} | |
> | |
<div | |
className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing" | |
onPointerDown={onPointerDown} | |
onPointerMove={onPointerMove} | |
onPointerUp={onPointerUp} | |
> | |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} /> | |
<div className="font-semibold text-sm flex-1 text-center">CLOTHES</div> | |
<div className="flex items-center gap-1"> | |
<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 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"> | |
{node.input && ( | |
<div className="flex justify-end mb-2"> | |
<Button | |
variant="ghost" | |
size="sm" | |
onClick={() => onUpdate(node.id, { input: undefined })} | |
className="text-xs" | |
> | |
Clear Connection | |
</Button> | |
</div> | |
)} | |
<div className="text-xs text-white/70">Clothes Reference</div> | |
{/* Preset clothes options */} | |
<div className="flex gap-2"> | |
{presetClothes.map((preset) => ( | |
<button | |
key={preset.name} | |
className={`flex-1 p-2 rounded border ${ | |
node.selectedPreset === preset.name | |
? "border-indigo-400 bg-indigo-500/20" | |
: "border-white/20 hover:border-white/40" | |
}`} | |
onClick={() => selectPreset(preset.path, preset.name)} | |
> | |
<img src={preset.path} alt={preset.name} className="w-full h-16 object-cover rounded mb-1" /> | |
<div className="text-xs">{preset.name}</div> | |
</button> | |
))} | |
</div> | |
<div className="text-xs text-white/50 text-center">β or β</div> | |
{/* Custom image upload */} | |
{node.clothesImage && !node.selectedPreset ? ( | |
<div className="relative"> | |
<img src={node.clothesImage} className="w-full rounded" alt="Clothes" /> | |
<Button | |
variant="destructive" | |
size="sm" | |
className="absolute top-2 right-2" | |
onClick={() => onUpdate(node.id, { clothesImage: null, selectedPreset: null })} | |
> | |
Remove | |
</Button> | |
</div> | |
) : !node.selectedPreset ? ( | |
<label className="block"> | |
<input | |
type="file" | |
accept="image/*" | |
className="hidden" | |
onChange={(e) => { | |
if (e.target.files?.length) { | |
const reader = new FileReader(); | |
reader.onload = () => onUpdate(node.id, { clothesImage: reader.result, selectedPreset: null }); | |
reader.readAsDataURL(e.target.files[0]); | |
} | |
}} | |
/> | |
<div className="border-2 border-dashed border-white/20 rounded-lg p-4 text-center cursor-pointer hover:border-white/40"> | |
<p className="text-xs text-white/60">Drop, upload, or paste clothes image</p> | |
</div> | |
</label> | |
) : null} | |
<Button | |
className="w-full" | |
onClick={() => onProcess(node.id)} | |
disabled={node.isRunning || !node.clothesImage} | |
title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"} | |
> | |
{node.isRunning ? "Processing..." : "Apply Clothes"} | |
</Button> | |
{node.output && ( | |
<div className="space-y-2"> | |
<img src={node.output} className="w-full rounded" alt="Output" /> | |
<Button | |
className="w-full" | |
variant="secondary" | |
onClick={() => downloadImage(node.output, `clothes-${Date.now()}.png`)} | |
> | |
π₯ Download Output | |
</Button> | |
</div> | |
)} | |
{node.error && ( | |
<div className="text-xs text-red-400 mt-2">{node.error}</div> | |
)} | |
</div> | |
</div> | |
); | |
} | |
export function AgeNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) { | |
const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition); | |
return ( | |
<div className="nb-node absolute text-white w-[280px]" style={{ left: localPos.x, top: localPos.y }}> | |
<div | |
className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing" | |
onPointerDown={onPointerDown} | |
onPointerMove={onPointerMove} | |
onPointerUp={onPointerUp} | |
> | |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} /> | |
<div className="font-semibold text-sm flex-1 text-center">AGE</div> | |
<div className="flex items-center gap-1"> | |
<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 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"> | |
{node.input && ( | |
<div className="flex justify-end mb-2"> | |
<Button | |
variant="ghost" | |
size="sm" | |
onClick={() => onUpdate(node.id, { input: undefined })} | |
className="text-xs" | |
> | |
Clear Connection | |
</Button> | |
</div> | |
)} | |
<div> | |
<Slider | |
label="Target Age" | |
valueLabel={`${node.targetAge || 30} years`} | |
min={18} | |
max={100} | |
value={node.targetAge || 30} | |
onChange={(e) => onUpdate(node.id, { targetAge: parseInt((e.target as HTMLInputElement).value) })} | |
/> | |
</div> | |
<Button | |
className="w-full" | |
onClick={() => onProcess(node.id)} | |
disabled={node.isRunning} | |
title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"} | |
> | |
{node.isRunning ? "Processing..." : "Apply Age"} | |
</Button> | |
{node.output && ( | |
<div className="space-y-2"> | |
<img src={node.output} className="w-full rounded" alt="Output" /> | |
<Button | |
className="w-full" | |
variant="secondary" | |
onClick={() => downloadImage(node.output, `age-${Date.now()}.png`)} | |
> | |
π₯ Download Output | |
</Button> | |
</div> | |
)} | |
{node.error && ( | |
<div className="text-xs text-red-400 mt-2">{node.error}</div> | |
)} | |
</div> | |
</div> | |
); | |
} | |
export function CameraNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) { | |
const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition); | |
const focalLengths = ["None", "8mm fisheye", "12mm", "24mm", "35mm", "50mm", "85mm", "135mm", "200mm", "300mm", "400mm"]; | |
const apertures = ["None", "f/0.95", "f/1.2", "f/1.4", "f/1.8", "f/2", "f/2.8", "f/4", "f/5.6", "f/8", "f/11", "f/16", "f/22"]; | |
const shutterSpeeds = ["None", "1/8000s", "1/4000s", "1/2000s", "1/1000s", "1/500s", "1/250s", "1/125s", "1/60s", "1/30s", "1/15s", "1/8s", "1/4s", "1/2s", "1s", "2s", "5s", "10s", "30s"]; | |
const whiteBalances = ["None", "2800K candlelight", "3200K tungsten", "4000K fluorescent", "5600K daylight", "6500K cloudy", "7000K shade", "8000K blue sky"]; | |
const angles = ["None", "eye level", "low angle", "high angle", "Dutch tilt", "bird's eye", "worm's eye", "over the shoulder", "POV"]; | |
const isoValues = ["None", "ISO 50", "ISO 100", "ISO 200", "ISO 400", "ISO 800", "ISO 1600", "ISO 3200", "ISO 6400", "ISO 12800"]; | |
const filmStyles = ["None", "Kodak Portra", "Fuji Velvia", "Ilford HP5", "Cinestill 800T", "Lomography", "Cross Process", "Black & White", "Sepia", "Vintage", "Film Noir"]; | |
const lightingTypes = ["None", "Natural Light", "Golden Hour", "Blue Hour", "Studio Lighting", "Rembrandt", "Split Lighting", "Butterfly Lighting", "Loop Lighting", "Rim Lighting", "Silhouette", "High Key", "Low Key"]; | |
const bokehStyles = ["None", "Smooth Bokeh", "Swirly Bokeh", "Hexagonal Bokeh", "Cat Eye Bokeh", "Bubble Bokeh", "Creamy Bokeh"]; | |
const compositions = ["None", "Rule of Thirds", "Golden Ratio", "Symmetrical", "Leading Lines", "Frame in Frame", "Fill the Frame", "Negative Space", "Patterns", "Diagonal"]; | |
const aspectRatios = ["None", "1:1 Square", "3:2 Standard", "4:3 Classic", "16:9 Widescreen", "21:9 Cinematic", "9:16 Portrait", "4:5 Instagram", "2:3 Portrait"]; | |
return ( | |
<div className="nb-node absolute text-white w-[360px]" style={{ left: localPos.x, top: localPos.y }}> | |
<div | |
className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing" | |
onPointerDown={onPointerDown} | |
onPointerMove={onPointerMove} | |
onPointerUp={onPointerUp} | |
> | |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} /> | |
<div className="font-semibold text-sm flex-1 text-center">CAMERA</div> | |
<div className="flex items-center gap-1"> | |
<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 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-2 max-h-[500px] overflow-y-auto scrollbar-thin"> | |
{node.input && ( | |
<div className="flex justify-end mb-2"> | |
<Button | |
variant="ghost" | |
size="sm" | |
onClick={() => onUpdate(node.id, { input: undefined })} | |
className="text-xs" | |
> | |
Clear Connection | |
</Button> | |
</div> | |
)} | |
{/* Basic Camera Settings */} | |
<div className="text-xs text-white/50 font-semibold mb-1">Basic Settings</div> | |
<div className="grid grid-cols-2 gap-2"> | |
<div> | |
<label className="text-xs text-white/70">Focal Length</label> | |
<Select | |
className="w-full" | |
value={node.focalLength || "None"} | |
onChange={(e) => onUpdate(node.id, { focalLength: (e.target as HTMLSelectElement).value })} | |
> | |
{focalLengths.map(f => <option key={f} value={f}>{f}</option>)} | |
</Select> | |
</div> | |
<div> | |
<label className="text-xs text-white/70">Aperture</label> | |
<Select | |
className="w-full" | |
value={node.aperture || "None"} | |
onChange={(e) => onUpdate(node.id, { aperture: (e.target as HTMLSelectElement).value })} | |
> | |
{apertures.map(a => <option key={a} value={a}>{a}</option>)} | |
</Select> | |
</div> | |
<div> | |
<label className="text-xs text-white/70">Shutter Speed</label> | |
<Select | |
className="w-full" | |
value={node.shutterSpeed || "None"} | |
onChange={(e) => onUpdate(node.id, { shutterSpeed: (e.target as HTMLSelectElement).value })} | |
> | |
{shutterSpeeds.map(s => <option key={s} value={s}>{s}</option>)} | |
</Select> | |
</div> | |
<div> | |
<label className="text-xs text-white/70">ISO</label> | |
<Select | |
className="w-full" | |
value={node.iso || "None"} | |
onChange={(e) => onUpdate(node.id, { iso: (e.target as HTMLSelectElement).value })} | |
> | |
{isoValues.map(i => <option key={i} value={i}>{i}</option>)} | |
</Select> | |
</div> | |
</div> | |
{/* Creative Settings */} | |
<div className="text-xs text-white/50 font-semibold mb-1 mt-3">Creative Settings</div> | |
<div className="grid grid-cols-2 gap-2"> | |
<div> | |
<label className="text-xs text-white/70">White Balance</label> | |
<Select | |
className="w-full" | |
value={node.whiteBalance || "None"} | |
onChange={(e) => onUpdate(node.id, { whiteBalance: (e.target as HTMLSelectElement).value })} | |
> | |
{whiteBalances.map(w => <option key={w} value={w}>{w}</option>)} | |
</Select> | |
</div> | |
<div> | |
<label className="text-xs text-white/70">Film Style</label> | |
<Select | |
className="w-full" | |
value={node.filmStyle || "None"} | |
onChange={(e) => onUpdate(node.id, { filmStyle: (e.target as HTMLSelectElement).value })} | |
> | |
{filmStyles.map(f => <option key={f} value={f}>{f}</option>)} | |
</Select> | |
</div> | |
<div> | |
<label className="text-xs text-white/70">Lighting</label> | |
<Select | |
className="w-full" | |
value={node.lighting || "None"} | |
onChange={(e) => onUpdate(node.id, { lighting: (e.target as HTMLSelectElement).value })} | |
> | |
{lightingTypes.map(l => <option key={l} value={l}>{l}</option>)} | |
</Select> | |
</div> | |
<div> | |
<label className="text-xs text-white/70">Bokeh Style</label> | |
<Select | |
className="w-full" | |
value={node.bokeh || "None"} | |
onChange={(e) => onUpdate(node.id, { bokeh: (e.target as HTMLSelectElement).value })} | |
> | |
{bokehStyles.map(b => <option key={b} value={b}>{b}</option>)} | |
</Select> | |
</div> | |
</div> | |
{/* Composition Settings */} | |
<div className="text-xs text-white/50 font-semibold mb-1 mt-3">Composition</div> | |
<div className="grid grid-cols-2 gap-2"> | |
<div> | |
<label className="text-xs text-white/70">Camera Angle</label> | |
<Select | |
className="w-full" | |
value={node.angle || "None"} | |
onChange={(e) => onUpdate(node.id, { angle: (e.target as HTMLSelectElement).value })} | |
> | |
{angles.map(a => <option key={a} value={a}>{a}</option>)} | |
</Select> | |
</div> | |
<div> | |
<label className="text-xs text-white/70">Composition</label> | |
<Select | |
className="w-full" | |
value={node.composition || "None"} | |
onChange={(e) => onUpdate(node.id, { composition: (e.target as HTMLSelectElement).value })} | |
> | |
{compositions.map(c => <option key={c} value={c}>{c}</option>)} | |
</Select> | |
</div> | |
<div> | |
<label className="text-xs text-white/70">Aspect Ratio</label> | |
<Select | |
className="w-full" | |
value={node.aspectRatio || "None"} | |
onChange={(e) => onUpdate(node.id, { aspectRatio: (e.target as HTMLSelectElement).value })} | |
> | |
{aspectRatios.map(a => <option key={a} value={a}>{a}</option>)} | |
</Select> | |
</div> | |
</div> | |
<Button | |
className="w-full" | |
onClick={() => onProcess(node.id)} | |
disabled={node.isRunning} | |
title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"} | |
> | |
{node.isRunning ? "Processing..." : "Apply Camera Settings"} | |
</Button> | |
{node.output && ( | |
<div className="space-y-2 mt-2"> | |
<img src={node.output} className="w-full rounded" alt="Output" /> | |
<Button | |
className="w-full" | |
variant="secondary" | |
onClick={() => downloadImage(node.output, `camera-${Date.now()}.png`)} | |
> | |
π₯ Download Output | |
</Button> | |
</div> | |
)} | |
{node.error && ( | |
<div className="text-xs text-red-400 mt-2">{node.error}</div> | |
)} | |
</div> | |
</div> | |
); | |
} | |
export function FaceNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) { | |
const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition); | |
const hairstyles = ["None", "short", "long", "curly", "straight", "bald", "mohawk", "ponytail"]; | |
const expressions = ["None", "happy", "serious", "smiling", "laughing", "sad", "surprised", "angry"]; | |
const beardStyles = ["None", "stubble", "goatee", "full beard", "mustache", "clean shaven"]; | |
return ( | |
<div className="nb-node absolute text-white w-[340px]" style={{ left: localPos.x, top: localPos.y }}> | |
<div | |
className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing" | |
onPointerDown={onPointerDown} | |
onPointerMove={onPointerMove} | |
onPointerUp={onPointerUp} | |
> | |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} /> | |
<div className="font-semibold text-sm flex-1 text-center">FACE</div> | |
<div className="flex items-center gap-1"> | |
<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 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-2"> | |
{node.input && ( | |
<div className="flex justify-end mb-2"> | |
<Button | |
variant="ghost" | |
size="sm" | |
onClick={() => onUpdate(node.id, { input: undefined })} | |
className="text-xs" | |
> | |
Clear Connection | |
</Button> | |
</div> | |
)} | |
<div className="space-y-2"> | |
<label className="flex items-center gap-2 text-xs"> | |
<Checkbox | |
checked={node.faceOptions?.removePimples || false} | |
onChange={(e) => onUpdate(node.id, { | |
faceOptions: { ...node.faceOptions, removePimples: (e.target as HTMLInputElement).checked } | |
})} | |
/> | |
Remove pimples | |
</label> | |
<label className="flex items-center gap-2 text-xs"> | |
<Checkbox | |
checked={node.faceOptions?.addSunglasses || false} | |
onChange={(e) => onUpdate(node.id, { | |
faceOptions: { ...node.faceOptions, addSunglasses: (e.target as HTMLInputElement).checked } | |
})} | |
/> | |
Add sunglasses | |
</label> | |
<label className="flex items-center gap-2 text-xs"> | |
<Checkbox | |
checked={node.faceOptions?.addHat || false} | |
onChange={(e) => onUpdate(node.id, { | |
faceOptions: { ...node.faceOptions, addHat: (e.target as HTMLInputElement).checked } | |
})} | |
/> | |
Add hat | |
</label> | |
</div> | |
<div> | |
<label className="text-xs text-white/70">Hairstyle</label> | |
<Select | |
className="w-full" | |
value={node.faceOptions?.changeHairstyle || "None"} | |
onChange={(e) => onUpdate(node.id, { | |
faceOptions: { ...node.faceOptions, changeHairstyle: (e.target as HTMLSelectElement).value } | |
})} | |
> | |
{hairstyles.map(h => <option key={h} value={h}>{h}</option>)} | |
</Select> | |
</div> | |
<div> | |
<label className="text-xs text-white/70">Expression</label> | |
<Select | |
className="w-full" | |
value={node.faceOptions?.facialExpression || "None"} | |
onChange={(e) => onUpdate(node.id, { | |
faceOptions: { ...node.faceOptions, facialExpression: (e.target as HTMLSelectElement).value } | |
})} | |
> | |
{expressions.map(e => <option key={e} value={e}>{e}</option>)} | |
</Select> | |
</div> | |
<div> | |
<label className="text-xs text-white/70">Beard</label> | |
<Select | |
className="w-full" | |
value={node.faceOptions?.beardStyle || "None"} | |
onChange={(e) => onUpdate(node.id, { | |
faceOptions: { ...node.faceOptions, beardStyle: (e.target as HTMLSelectElement).value } | |
})} | |
> | |
{beardStyles.map(b => <option key={b} value={b}>{b}</option>)} | |
</Select> | |
</div> | |
<Button | |
className="w-full" | |
onClick={() => onProcess(node.id)} | |
disabled={node.isRunning} | |
title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"} | |
> | |
{node.isRunning ? "Processing..." : "Apply Face Changes"} | |
</Button> | |
{node.output && ( | |
<div className="space-y-2 mt-2"> | |
<img src={node.output} className="w-full rounded" alt="Output" /> | |
<Button | |
className="w-full" | |
variant="secondary" | |
onClick={() => downloadImage(node.output, `face-${Date.now()}.png`)} | |
> | |
π₯ Download Output | |
</Button> | |
</div> | |
)} | |
{node.error && ( | |
<div className="text-xs text-red-400 mt-2">{node.error}</div> | |
)} | |
</div> | |
</div> | |
); | |
} | |
export function StyleNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) { | |
const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition); | |
const styleOptions = [ | |
{ value: "90s-anime", label: "90's Anime Style" }, | |
{ value: "mha", label: "My Hero Academia Style" }, | |
{ value: "dbz", label: "Dragon Ball Z Style" }, | |
{ value: "ukiyo-e", label: "Ukiyo-e Style" }, | |
{ value: "cyberpunk", label: "Cyberpunk Style" }, | |
{ value: "steampunk", label: "Steampunk Style" }, | |
{ value: "cubism", label: "Cubism Style" }, | |
{ value: "van-gogh", label: "Post-Impressionist (Van Gogh) Style" }, | |
{ value: "simpsons", label: "Simpsons Style" }, | |
{ value: "family-guy", label: "Family Guy Style" }, | |
{ value: "arcane", label: "Arcane β Painterly + Neon Rim Light" }, | |
{ value: "wildwest", label: "Wild West Style" }, | |
{ value: "stranger-things", label: "Stranger Things β 80s Kodak Style" }, | |
{ value: "breaking-bad", label: "Breaking Bad β Dusty Orange & Teal" }, | |
]; | |
return ( | |
<div | |
className="nb-node absolute text-white w-[320px]" | |
style={{ left: localPos.x, top: localPos.y }} | |
> | |
<div | |
className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing" | |
onPointerDown={onPointerDown} | |
onPointerMove={onPointerMove} | |
onPointerUp={onPointerUp} | |
> | |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} /> | |
<div className="font-semibold text-sm flex-1 text-center">STYLE</div> | |
<div className="flex items-center gap-1"> | |
<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 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"> | |
{node.input && ( | |
<div className="flex justify-end mb-2"> | |
<Button | |
variant="ghost" | |
size="sm" | |
onClick={() => onUpdate(node.id, { input: undefined })} | |
className="text-xs" | |
> | |
Clear Connection | |
</Button> | |
</div> | |
)} | |
<div className="text-xs text-white/70">Art Style</div> | |
<div className="text-xs text-white/50 mb-2">Select an artistic style to apply to your image</div> | |
<Select | |
className="w-full bg-black border-white/20 text-white focus:border-white/40 [&>option]:bg-black [&>option]:text-white" | |
value={node.stylePreset || ""} | |
onChange={(e) => onUpdate(node.id, { stylePreset: (e.target as HTMLSelectElement).value })} | |
> | |
<option value="" className="bg-black">Select a style...</option> | |
{styleOptions.map(opt => ( | |
<option key={opt.value} value={opt.value} className="bg-black"> | |
{opt.label} | |
</option> | |
))} | |
</Select> | |
<div> | |
<Slider | |
label="Style Strength" | |
valueLabel={`${node.styleStrength || 50}%`} | |
min={0} | |
max={100} | |
value={node.styleStrength || 50} | |
onChange={(e) => onUpdate(node.id, { styleStrength: parseInt((e.target as HTMLInputElement).value) })} | |
/> | |
</div> | |
<Button | |
className="w-full" | |
onClick={() => onProcess(node.id)} | |
disabled={node.isRunning || !node.stylePreset} | |
title={!node.input ? "Connect an input first" : !node.stylePreset ? "Select a style first" : "Apply the style to your input image"} | |
> | |
{node.isRunning ? "Applying Style..." : "Apply Style Transfer"} | |
</Button> | |
{node.output && ( | |
<div className="space-y-2"> | |
<img src={node.output} className="w-full rounded" alt="Output" /> | |
<Button | |
className="w-full" | |
variant="secondary" | |
onClick={() => downloadImage(node.output, `style-${Date.now()}.png`)} | |
> | |
π₯ Download Output | |
</Button> | |
</div> | |
)} | |
{node.error && ( | |
<div className="text-xs text-red-400 mt-2">{node.error}</div> | |
)} | |
</div> | |
</div> | |
); | |
} | |
export function EditNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) { | |
const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition); | |
return ( | |
<div className="nb-node absolute text-white w-[320px]" style={{ left: localPos.x, top: localPos.y }}> | |
<div | |
className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing" | |
onPointerDown={onPointerDown} | |
onPointerMove={onPointerMove} | |
onPointerUp={onPointerUp} | |
> | |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} /> | |
<div className="font-semibold text-sm flex-1 text-center">EDIT</div> | |
<div className="flex items-center gap-1"> | |
<Button | |
variant="ghost" | |
size="icon" | |
className="text-destructive hover:bg-destructive/20 h-6 w-6" | |
onClick={() => 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"> | |
{node.input && ( | |
<div className="flex justify-end mb-2"> | |
<Button | |
variant="ghost" | |
size="sm" | |
onClick={() => onUpdate(node.id, { input: undefined })} | |
className="text-xs" | |
> | |
Clear Connection | |
</Button> | |
</div> | |
)} | |
<Textarea | |
className="w-full" | |
placeholder="Describe what to edit (e.g., 'make it brighter', 'add more contrast', 'make it look vintage')" | |
value={node.editPrompt || ""} | |
onChange={(e) => onUpdate(node.id, { editPrompt: (e.target as HTMLTextAreaElement).value })} | |
rows={3} | |
/> | |
<Button | |
className="w-full" | |
onClick={() => onProcess(node.id)} | |
disabled={node.isRunning} | |
title={!node.input ? "Connect an input first" : "Process all unprocessed nodes in chain"} | |
> | |
{node.isRunning ? "Processing..." : "Apply Edit"} | |
</Button> | |
{node.output && ( | |
<div className="space-y-2"> | |
<img src={node.output} className="w-full rounded" alt="Output" /> | |
<Button | |
className="w-full" | |
variant="secondary" | |
onClick={() => downloadImage(node.output, `edit-${Date.now()}.png`)} | |
> | |
π₯ Download Output | |
</Button> | |
</div> | |
)} | |
</div> | |
</div> | |
); | |
} | |