Reubencf's picture
Upload 38 files
f09b9f0 verified
raw
history blame
43.9 kB
"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>
);
}