Spaces:
Running
Running
good night
Browse files- app/api/process/route.ts +24 -6
- app/editor/nodes.tsx +40 -73
- app/editor/page.tsx +17 -18
app/api/process/route.ts
CHANGED
|
@@ -104,12 +104,30 @@ export async function POST(req: NextRequest) {
|
|
| 104 |
if (clothesRef) referenceParts.push({ inlineData: clothesRef });
|
| 105 |
}
|
| 106 |
|
| 107 |
-
// Style
|
| 108 |
-
if (params.
|
| 109 |
-
const strength = params.
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
}
|
| 114 |
|
| 115 |
// Edit prompt
|
|
|
|
| 104 |
if (clothesRef) referenceParts.push({ inlineData: clothesRef });
|
| 105 |
}
|
| 106 |
|
| 107 |
+
// Style application
|
| 108 |
+
if (params.stylePreset) {
|
| 109 |
+
const strength = params.styleStrength || 50;
|
| 110 |
+
const styleMap: { [key: string]: string } = {
|
| 111 |
+
"90s-anime": "Convert the image to 90's anime art style with classic anime features: large expressive eyes, detailed hair, soft shading, nostalgic colors reminiscent of Studio Ghibli and classic anime productions",
|
| 112 |
+
"mha": "Transform the image into My Hero Academia anime style with modern crisp lines, vibrant colors, dynamic character design, and heroic aesthetics typical of the series",
|
| 113 |
+
"dbz": "Apply Dragon Ball Z anime style with sharp angular features, spiky hair, intense expressions, bold outlines, high contrast shading, and dramatic action-oriented aesthetics",
|
| 114 |
+
"ukiyo-e": "Render in traditional Japanese Ukiyo-e woodblock print style with flat colors, bold outlines, stylized waves and clouds, traditional Japanese artistic elements",
|
| 115 |
+
"cyberpunk": "Transform into cyberpunk aesthetic with neon colors (cyan, magenta, yellow), dark backgrounds, futuristic elements, holographic effects, tech-noir atmosphere",
|
| 116 |
+
"steampunk": "Apply steampunk style with Victorian-era brass and copper tones, mechanical gears, steam effects, vintage industrial aesthetic, sepia undertones",
|
| 117 |
+
"cubism": "Render in Cubist art style with geometric fragmentation, multiple perspectives shown simultaneously, abstract angular forms, Picasso-inspired decomposition",
|
| 118 |
+
"van-gogh": "Apply Post-Impressionist Van Gogh style with thick swirling brushstrokes, vibrant yellows and blues, expressive texture, starry night-like patterns",
|
| 119 |
+
"simpsons": "Convert to The Simpsons cartoon style with yellow skin tones, simple rounded features, bulging eyes, overbite, Matt Groening's distinctive character design",
|
| 120 |
+
"family-guy": "Transform into Family Guy animation style with rounded character design, simplified features, Seth MacFarlane's distinctive art style, bold outlines",
|
| 121 |
+
"arcane": "Apply Arcane (League of Legends) style with painterly brush-stroke textures, neon rim lighting, hand-painted feel, stylized realism, vibrant color grading",
|
| 122 |
+
"wildwest": "Render in Wild West style with dusty desert tones, sunset orange lighting, vintage film grain, cowboy aesthetic, sepia and brown color palette",
|
| 123 |
+
"stranger-things": "Apply Stranger Things 80s aesthetic with Kodak film push-process look, neon magenta backlight, grainy vignette, retro sci-fi horror atmosphere",
|
| 124 |
+
"breaking-bad": "Transform with Breaking Bad cinematography style featuring dusty New Mexico orange and teal color grading, 35mm film grain, desert atmosphere, dramatic lighting"
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
const styleDescription = styleMap[params.stylePreset];
|
| 128 |
+
if (styleDescription) {
|
| 129 |
+
prompts.push(`${styleDescription}. Apply this style transformation at ${strength}% intensity while preserving the core subject matter.`);
|
| 130 |
+
}
|
| 131 |
}
|
| 132 |
|
| 133 |
// Edit prompt
|
app/editor/nodes.tsx
CHANGED
|
@@ -926,45 +926,30 @@ export function FaceNodeView({ node, onDelete, onUpdate, onStartConnection, onEn
|
|
| 926 |
);
|
| 927 |
}
|
| 928 |
|
| 929 |
-
export function
|
| 930 |
const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
|
| 931 |
|
| 932 |
-
const
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
}
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
const reader = new FileReader();
|
| 949 |
-
reader.onload = () => onUpdate(node.id, { styleImage: reader.result });
|
| 950 |
-
reader.readAsDataURL(file);
|
| 951 |
-
return;
|
| 952 |
-
}
|
| 953 |
-
}
|
| 954 |
-
}
|
| 955 |
-
const text = e.clipboardData.getData("text");
|
| 956 |
-
if (text && (text.startsWith("http") || text.startsWith("data:image"))) {
|
| 957 |
-
onUpdate(node.id, { styleImage: text });
|
| 958 |
-
}
|
| 959 |
-
};
|
| 960 |
|
| 961 |
return (
|
| 962 |
<div
|
| 963 |
className="nb-node absolute text-white w-[320px]"
|
| 964 |
style={{ left: localPos.x, top: localPos.y }}
|
| 965 |
-
onDrop={onDrop}
|
| 966 |
-
onDragOver={(e) => e.preventDefault()}
|
| 967 |
-
onPaste={onPaste}
|
| 968 |
>
|
| 969 |
<div
|
| 970 |
className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
|
|
@@ -973,7 +958,7 @@ export function BlendNodeView({ node, onDelete, onUpdate, onStartConnection, onE
|
|
| 973 |
onPointerUp={onPointerUp}
|
| 974 |
>
|
| 975 |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
|
| 976 |
-
<div className="font-semibold text-sm flex-1 text-center">
|
| 977 |
<div className="flex items-center gap-2">
|
| 978 |
<Button
|
| 979 |
variant="ghost"
|
|
@@ -1008,55 +993,37 @@ export function BlendNodeView({ node, onDelete, onUpdate, onStartConnection, onE
|
|
| 1008 |
</Button>
|
| 1009 |
</div>
|
| 1010 |
)}
|
| 1011 |
-
<div className="text-xs text-white/70">Style
|
| 1012 |
-
<div className="text-xs text-white/50">
|
| 1013 |
-
|
| 1014 |
-
|
| 1015 |
-
|
| 1016 |
-
|
| 1017 |
-
|
| 1018 |
-
|
| 1019 |
-
|
| 1020 |
-
|
| 1021 |
-
|
| 1022 |
-
|
| 1023 |
-
|
| 1024 |
-
|
| 1025 |
-
<input
|
| 1026 |
-
type="file"
|
| 1027 |
-
accept="image/*"
|
| 1028 |
-
className="hidden"
|
| 1029 |
-
onChange={(e) => {
|
| 1030 |
-
if (e.target.files?.length) {
|
| 1031 |
-
const reader = new FileReader();
|
| 1032 |
-
reader.onload = () => onUpdate(node.id, { styleImage: reader.result });
|
| 1033 |
-
reader.readAsDataURL(e.target.files[0]);
|
| 1034 |
-
}
|
| 1035 |
-
}}
|
| 1036 |
-
/>
|
| 1037 |
-
<div className="border-2 border-dashed border-white/20 rounded-lg p-4 text-center cursor-pointer hover:border-white/40">
|
| 1038 |
-
<p className="text-xs text-white/60">Drop, upload, or paste style image</p>
|
| 1039 |
-
<p className="text-xs text-white/40 mt-1">Art style will be applied to input</p>
|
| 1040 |
-
</div>
|
| 1041 |
-
</label>
|
| 1042 |
-
)}
|
| 1043 |
<div>
|
| 1044 |
<Slider
|
| 1045 |
-
label="
|
| 1046 |
-
valueLabel={`${node.
|
| 1047 |
min={0}
|
| 1048 |
max={100}
|
| 1049 |
-
value={node.
|
| 1050 |
-
onChange={(e) => onUpdate(node.id, {
|
| 1051 |
/>
|
| 1052 |
</div>
|
| 1053 |
<Button
|
| 1054 |
className="w-full"
|
| 1055 |
onClick={() => onProcess(node.id)}
|
| 1056 |
-
disabled={node.isRunning || !node.
|
| 1057 |
-
title={!node.input ? "Connect an input first" : !node.
|
| 1058 |
>
|
| 1059 |
-
{node.isRunning ? "
|
| 1060 |
</Button>
|
| 1061 |
{node.output && (
|
| 1062 |
<div className="space-y-2">
|
|
@@ -1064,7 +1031,7 @@ export function BlendNodeView({ node, onDelete, onUpdate, onStartConnection, onE
|
|
| 1064 |
<Button
|
| 1065 |
className="w-full"
|
| 1066 |
variant="secondary"
|
| 1067 |
-
onClick={() => downloadImage(node.output, `
|
| 1068 |
>
|
| 1069 |
📥 Download Output
|
| 1070 |
</Button>
|
|
|
|
| 926 |
);
|
| 927 |
}
|
| 928 |
|
| 929 |
+
export function StyleNodeView({ node, onDelete, onUpdate, onStartConnection, onEndConnection, onProcess, onUpdatePosition }: any) {
|
| 930 |
const { localPos, onPointerDown, onPointerMove, onPointerUp } = useNodeDrag(node, onUpdatePosition);
|
| 931 |
|
| 932 |
+
const styleOptions = [
|
| 933 |
+
{ value: "90s-anime", label: "90's Anime Style" },
|
| 934 |
+
{ value: "mha", label: "My Hero Academia Style" },
|
| 935 |
+
{ value: "dbz", label: "Dragon Ball Z Style" },
|
| 936 |
+
{ value: "ukiyo-e", label: "Ukiyo-e Style" },
|
| 937 |
+
{ value: "cyberpunk", label: "Cyberpunk Style" },
|
| 938 |
+
{ value: "steampunk", label: "Steampunk Style" },
|
| 939 |
+
{ value: "cubism", label: "Cubism Style" },
|
| 940 |
+
{ value: "van-gogh", label: "Post-Impressionist (Van Gogh) Style" },
|
| 941 |
+
{ value: "simpsons", label: "Simpsons Style" },
|
| 942 |
+
{ value: "family-guy", label: "Family Guy Style" },
|
| 943 |
+
{ value: "arcane", label: "Arcane – Painterly + Neon Rim Light" },
|
| 944 |
+
{ value: "wildwest", label: "Wild West Style" },
|
| 945 |
+
{ value: "stranger-things", label: "Stranger Things – 80s Kodak Style" },
|
| 946 |
+
{ value: "breaking-bad", label: "Breaking Bad – Dusty Orange & Teal" },
|
| 947 |
+
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 948 |
|
| 949 |
return (
|
| 950 |
<div
|
| 951 |
className="nb-node absolute text-white w-[320px]"
|
| 952 |
style={{ left: localPos.x, top: localPos.y }}
|
|
|
|
|
|
|
|
|
|
| 953 |
>
|
| 954 |
<div
|
| 955 |
className="nb-header px-3 py-2 flex items-center justify-between rounded-t-[14px] cursor-grab active:cursor-grabbing"
|
|
|
|
| 958 |
onPointerUp={onPointerUp}
|
| 959 |
>
|
| 960 |
<Port className="in" nodeId={node.id} isOutput={false} onEndConnection={onEndConnection} />
|
| 961 |
+
<div className="font-semibold text-sm flex-1 text-center">STYLE</div>
|
| 962 |
<div className="flex items-center gap-2">
|
| 963 |
<Button
|
| 964 |
variant="ghost"
|
|
|
|
| 993 |
</Button>
|
| 994 |
</div>
|
| 995 |
)}
|
| 996 |
+
<div className="text-xs text-white/70">Art Style</div>
|
| 997 |
+
<div className="text-xs text-white/50 mb-2">Select an artistic style to apply to your image</div>
|
| 998 |
+
<Select
|
| 999 |
+
className="w-full bg-black border-white/20 text-white focus:border-white/40 [&>option]:bg-black [&>option]:text-white"
|
| 1000 |
+
value={node.stylePreset || ""}
|
| 1001 |
+
onChange={(e) => onUpdate(node.id, { stylePreset: (e.target as HTMLSelectElement).value })}
|
| 1002 |
+
>
|
| 1003 |
+
<option value="" className="bg-black">Select a style...</option>
|
| 1004 |
+
{styleOptions.map(opt => (
|
| 1005 |
+
<option key={opt.value} value={opt.value} className="bg-black">
|
| 1006 |
+
{opt.label}
|
| 1007 |
+
</option>
|
| 1008 |
+
))}
|
| 1009 |
+
</Select>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1010 |
<div>
|
| 1011 |
<Slider
|
| 1012 |
+
label="Style Strength"
|
| 1013 |
+
valueLabel={`${node.styleStrength || 50}%`}
|
| 1014 |
min={0}
|
| 1015 |
max={100}
|
| 1016 |
+
value={node.styleStrength || 50}
|
| 1017 |
+
onChange={(e) => onUpdate(node.id, { styleStrength: parseInt((e.target as HTMLInputElement).value) })}
|
| 1018 |
/>
|
| 1019 |
</div>
|
| 1020 |
<Button
|
| 1021 |
className="w-full"
|
| 1022 |
onClick={() => onProcess(node.id)}
|
| 1023 |
+
disabled={node.isRunning || !node.stylePreset}
|
| 1024 |
+
title={!node.input ? "Connect an input first" : !node.stylePreset ? "Select a style first" : "Apply the style to your input image"}
|
| 1025 |
>
|
| 1026 |
+
{node.isRunning ? "Applying Style..." : "Apply Style Transfer"}
|
| 1027 |
</Button>
|
| 1028 |
{node.output && (
|
| 1029 |
<div className="space-y-2">
|
|
|
|
| 1031 |
<Button
|
| 1032 |
className="w-full"
|
| 1033 |
variant="secondary"
|
| 1034 |
+
onClick={() => downloadImage(node.output, `style-${Date.now()}.png`)}
|
| 1035 |
>
|
| 1036 |
📥 Download Output
|
| 1037 |
</Button>
|
app/editor/page.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
| 4 |
import {
|
| 5 |
BackgroundNodeView,
|
| 6 |
ClothesNodeView,
|
| 7 |
-
|
| 8 |
EditNodeView,
|
| 9 |
CameraNodeView,
|
| 10 |
AgeNodeView,
|
|
@@ -56,7 +56,7 @@ The result should look like all subjects were photographed together in the same
|
|
| 56 |
}
|
| 57 |
|
| 58 |
// Types
|
| 59 |
-
type NodeType = "CHARACTER" | "MERGE" | "BACKGROUND" | "CLOTHES" | "
|
| 60 |
|
| 61 |
type NodeBase = {
|
| 62 |
id: string;
|
|
@@ -103,13 +103,12 @@ type ClothesNode = NodeBase & {
|
|
| 103 |
error?: string | null;
|
| 104 |
};
|
| 105 |
|
| 106 |
-
type
|
| 107 |
-
type: "
|
| 108 |
input?: string;
|
| 109 |
output?: string;
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
blendStrength?: number;
|
| 113 |
isRunning?: boolean;
|
| 114 |
error?: string | null;
|
| 115 |
};
|
|
@@ -167,7 +166,7 @@ type FaceNode = NodeBase & {
|
|
| 167 |
error?: string | null;
|
| 168 |
};
|
| 169 |
|
| 170 |
-
type AnyNode = CharacterNode | MergeNode | BackgroundNode | ClothesNode |
|
| 171 |
|
| 172 |
// Default placeholder portrait
|
| 173 |
const DEFAULT_PERSON =
|
|
@@ -772,10 +771,10 @@ export default function EditorPage() {
|
|
| 772 |
config.selectedPreset = (node as ClothesNode).selectedPreset;
|
| 773 |
}
|
| 774 |
break;
|
| 775 |
-
case "
|
| 776 |
-
if ((node as
|
| 777 |
-
config.
|
| 778 |
-
config.
|
| 779 |
}
|
| 780 |
break;
|
| 781 |
case "EDIT":
|
|
@@ -1390,8 +1389,8 @@ export default function EditorPage() {
|
|
| 1390 |
case "BLEND":
|
| 1391 |
setNodes(prev => [...prev, { ...commonProps, type: "BLEND", blendStrength: 50 } as BlendNode]);
|
| 1392 |
break;
|
| 1393 |
-
case "
|
| 1394 |
-
setNodes(prev => [...prev, { ...commonProps, type: "
|
| 1395 |
break;
|
| 1396 |
case "CAMERA":
|
| 1397 |
setNodes(prev => [...prev, { ...commonProps, type: "CAMERA" } as CameraNode]);
|
|
@@ -1562,11 +1561,11 @@ export default function EditorPage() {
|
|
| 1562 |
onUpdatePosition={updateNodePosition}
|
| 1563 |
/>
|
| 1564 |
);
|
| 1565 |
-
case "
|
| 1566 |
return (
|
| 1567 |
-
<
|
| 1568 |
key={node.id}
|
| 1569 |
-
node={node as
|
| 1570 |
onDelete={deleteNode}
|
| 1571 |
onUpdate={updateNode}
|
| 1572 |
onStartConnection={handleStartConnection}
|
|
@@ -1646,7 +1645,7 @@ export default function EditorPage() {
|
|
| 1646 |
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("MERGE")}>MERGE</button>
|
| 1647 |
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("BACKGROUND")}>BACKGROUND</button>
|
| 1648 |
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("CLOTHES")}>CLOTHES</button>
|
| 1649 |
-
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("
|
| 1650 |
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("EDIT")}>EDIT</button>
|
| 1651 |
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("CAMERA")}>CAMERA</button>
|
| 1652 |
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("AGE")}>AGE</button>
|
|
|
|
| 4 |
import {
|
| 5 |
BackgroundNodeView,
|
| 6 |
ClothesNodeView,
|
| 7 |
+
StyleNodeView,
|
| 8 |
EditNodeView,
|
| 9 |
CameraNodeView,
|
| 10 |
AgeNodeView,
|
|
|
|
| 56 |
}
|
| 57 |
|
| 58 |
// Types
|
| 59 |
+
type NodeType = "CHARACTER" | "MERGE" | "BACKGROUND" | "CLOTHES" | "STYLE" | "EDIT" | "CAMERA" | "AGE" | "FACE";
|
| 60 |
|
| 61 |
type NodeBase = {
|
| 62 |
id: string;
|
|
|
|
| 103 |
error?: string | null;
|
| 104 |
};
|
| 105 |
|
| 106 |
+
type StyleNode = NodeBase & {
|
| 107 |
+
type: "STYLE";
|
| 108 |
input?: string;
|
| 109 |
output?: string;
|
| 110 |
+
stylePreset?: string;
|
| 111 |
+
styleStrength?: number;
|
|
|
|
| 112 |
isRunning?: boolean;
|
| 113 |
error?: string | null;
|
| 114 |
};
|
|
|
|
| 166 |
error?: string | null;
|
| 167 |
};
|
| 168 |
|
| 169 |
+
type AnyNode = CharacterNode | MergeNode | BackgroundNode | ClothesNode | StyleNode | EditNode | CameraNode | AgeNode | FaceNode;
|
| 170 |
|
| 171 |
// Default placeholder portrait
|
| 172 |
const DEFAULT_PERSON =
|
|
|
|
| 771 |
config.selectedPreset = (node as ClothesNode).selectedPreset;
|
| 772 |
}
|
| 773 |
break;
|
| 774 |
+
case "STYLE":
|
| 775 |
+
if ((node as StyleNode).stylePreset) {
|
| 776 |
+
config.stylePreset = (node as StyleNode).stylePreset;
|
| 777 |
+
config.styleStrength = (node as StyleNode).styleStrength;
|
| 778 |
}
|
| 779 |
break;
|
| 780 |
case "EDIT":
|
|
|
|
| 1389 |
case "BLEND":
|
| 1390 |
setNodes(prev => [...prev, { ...commonProps, type: "BLEND", blendStrength: 50 } as BlendNode]);
|
| 1391 |
break;
|
| 1392 |
+
case "STYLE":
|
| 1393 |
+
setNodes(prev => [...prev, { ...commonProps, type: "STYLE", styleStrength: 50 } as StyleNode]);
|
| 1394 |
break;
|
| 1395 |
case "CAMERA":
|
| 1396 |
setNodes(prev => [...prev, { ...commonProps, type: "CAMERA" } as CameraNode]);
|
|
|
|
| 1561 |
onUpdatePosition={updateNodePosition}
|
| 1562 |
/>
|
| 1563 |
);
|
| 1564 |
+
case "STYLE":
|
| 1565 |
return (
|
| 1566 |
+
<StyleNodeView
|
| 1567 |
key={node.id}
|
| 1568 |
+
node={node as StyleNode}
|
| 1569 |
onDelete={deleteNode}
|
| 1570 |
onUpdate={updateNode}
|
| 1571 |
onStartConnection={handleStartConnection}
|
|
|
|
| 1645 |
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("MERGE")}>MERGE</button>
|
| 1646 |
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("BACKGROUND")}>BACKGROUND</button>
|
| 1647 |
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("CLOTHES")}>CLOTHES</button>
|
| 1648 |
+
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("STYLE")}>STYLE</button>
|
| 1649 |
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("EDIT")}>EDIT</button>
|
| 1650 |
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("CAMERA")}>CAMERA</button>
|
| 1651 |
<button className="w-full text-left px-3 py-2 text-sm hover:bg-white/10 rounded-lg" onClick={() => addFromMenu("AGE")}>AGE</button>
|