Enables users to upload custom avatar assets and automatically remove the background from the generated image. New features: - Avatar creation now supports uploading base, blink, and talk textures. - Added ability to define the main body bounding box during rigging. - Vision service now includes image segmentation for background removal. - Studio component dynamically processes the avatar image for background removal if chroma key is enabled.
283 lines
12 KiB
TypeScript
283 lines
12 KiB
TypeScript
|
|
import React, { useState, useRef, useEffect } from 'react';
|
|
import { Rect } from '../types';
|
|
|
|
interface RiggingEditorProps {
|
|
imageUrl: string;
|
|
initialData?: { leftEye: Rect; rightEye: Rect; mouth: Rect; skinColor: string };
|
|
onComplete: (data: {
|
|
leftEye: Rect; rightEye: Rect; mouth: Rect; skinColor: string;
|
|
textureClosedEye: Rect; textureOpenMouth: Rect;
|
|
mainBody: Rect; chromaKeyColor: string;
|
|
}) => void;
|
|
}
|
|
|
|
type ActiveFeature = 'leftEye' | 'rightEye' | 'mouth' | 'textureClosedEye' | 'textureOpenMouth' | 'mainBody' | null;
|
|
|
|
const ResizableBox: React.FC<{
|
|
rect: Rect;
|
|
color: string;
|
|
label: string;
|
|
isActive: boolean;
|
|
onUpdate: (rect: Rect) => void;
|
|
onActivate: () => void;
|
|
}> = ({ rect, color, label, isActive, onUpdate, onActivate }) => {
|
|
const boxRef = useRef<HTMLDivElement>(null);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [isResizing, setIsResizing] = useState(false);
|
|
const startPos = useRef({ x: 0, y: 0 });
|
|
const startRect = useRef<Rect>({ x: 0, y: 0, w: 0, h: 0 });
|
|
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onActivate();
|
|
setIsDragging(true);
|
|
startPos.current = { x: e.clientX, y: e.clientY };
|
|
startRect.current = { ...rect };
|
|
};
|
|
|
|
const handleResizeDown = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onActivate();
|
|
setIsResizing(true);
|
|
startPos.current = { x: e.clientX, y: e.clientY };
|
|
startRect.current = { ...rect };
|
|
};
|
|
|
|
useEffect(() => {
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (!isDragging && !isResizing) return;
|
|
|
|
const parent = boxRef.current?.parentElement;
|
|
if (!parent) return;
|
|
const parentRect = parent.getBoundingClientRect();
|
|
|
|
const deltaX = (e.clientX - startPos.current.x) / parentRect.width;
|
|
const deltaY = (e.clientY - startPos.current.y) / parentRect.height;
|
|
|
|
if (isDragging) {
|
|
onUpdate({
|
|
...rect,
|
|
x: startRect.current.x + deltaX,
|
|
y: startRect.current.y + deltaY,
|
|
});
|
|
} else if (isResizing) {
|
|
onUpdate({
|
|
...rect,
|
|
w: Math.max(0.01, startRect.current.w + deltaX),
|
|
h: Math.max(0.01, startRect.current.h + deltaY),
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsDragging(false);
|
|
setIsResizing(false);
|
|
};
|
|
|
|
if (isDragging || isResizing) {
|
|
window.addEventListener('mousemove', handleMouseMove);
|
|
window.addEventListener('mouseup', handleMouseUp);
|
|
}
|
|
|
|
return () => {
|
|
window.removeEventListener('mousemove', handleMouseMove);
|
|
window.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
}, [isDragging, isResizing, rect, onUpdate]);
|
|
|
|
return (
|
|
<div
|
|
ref={boxRef}
|
|
onMouseDown={handleMouseDown}
|
|
className={`absolute border-2 cursor-move group transition-colors ${isActive ? 'z-30' : 'z-20'}`}
|
|
style={{
|
|
left: `${rect.x * 100}%`,
|
|
top: `${rect.y * 100}%`,
|
|
width: `${rect.w * 100}%`,
|
|
height: `${rect.h * 100}%`,
|
|
borderColor: color,
|
|
backgroundColor: isActive ? `${color}20` : 'transparent',
|
|
}}
|
|
>
|
|
{/* Label */}
|
|
<div
|
|
className="absolute -top-6 left-0 text-xs font-bold px-1 rounded text-white whitespace-nowrap shadow-sm"
|
|
style={{ backgroundColor: color }}
|
|
>
|
|
{label}
|
|
</div>
|
|
|
|
{/* Resize Handle */}
|
|
<div
|
|
onMouseDown={handleResizeDown}
|
|
className="absolute bottom-0 right-0 w-4 h-4 bg-white border-2 cursor-nwse-resize opacity-0 group-hover:opacity-100 transition-opacity"
|
|
style={{ borderColor: color }}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const RiggingEditor: React.FC<RiggingEditorProps> = ({ imageUrl, initialData, onComplete }) => {
|
|
// Targets (Left side of image usually)
|
|
const [leftEye, setLeftEye] = useState<Rect>(initialData?.leftEye || { x: 0.25, y: 0.4, w: 0.1, h: 0.1 });
|
|
const [rightEye, setRightEye] = useState<Rect>(initialData?.rightEye || { x: 0.45, y: 0.4, w: 0.1, h: 0.1 });
|
|
const [mouth, setMouth] = useState<Rect>(initialData?.mouth || { x: 0.35, y: 0.55, w: 0.1, h: 0.05 });
|
|
|
|
// Main Body (Default to left 70%)
|
|
const [mainBody, setMainBody] = useState<Rect>({ x: 0.05, y: 0.05, w: 0.65, h: 0.9 });
|
|
|
|
// Sources (Right side of image usually)
|
|
const [textureClosedEye, setTextureClosedEye] = useState<Rect>({ x: 0.7, y: 0.1, w: 0.2, h: 0.2 });
|
|
const [textureOpenMouth, setTextureOpenMouth] = useState<Rect>({ x: 0.7, y: 0.5, w: 0.2, h: 0.2 });
|
|
|
|
const [skinColor, setSkinColor] = useState<string>(initialData?.skinColor || '#fcd3bf');
|
|
// Use this simply as a boolean flag now, passing 'AI_AUTO' if enabled
|
|
const [useAiBackground, setUseAiBackground] = useState<boolean>(true);
|
|
|
|
const [activeFeature, setActiveFeature] = useState<ActiveFeature>(null);
|
|
|
|
return (
|
|
<div className="flex flex-col items-center h-full max-w-6xl mx-auto p-4">
|
|
<div className="text-center mb-6">
|
|
<h2 className="text-2xl font-bold text-white mb-2">Rig Your Character</h2>
|
|
<p className="text-slate-400 text-sm">
|
|
1. Adjust the <b>Main Body</b> (Yellow) to frame your character.<br/>
|
|
2. Match the <b>Targets</b> (Red/Blue/Green) to the face features.<br/>
|
|
3. Match the <b>Sources</b> (Purple/Orange) to the assets on the right.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex gap-6 w-full items-start h-[70vh]">
|
|
{/* Editor Area */}
|
|
<div className="flex-1 bg-slate-800 p-4 rounded-xl border border-slate-700 flex justify-center h-full overflow-hidden relative">
|
|
<div className="relative inline-block h-full">
|
|
<img
|
|
src={imageUrl}
|
|
alt="Rigging Target"
|
|
className="h-full w-auto object-contain rounded-lg pointer-events-none select-none block"
|
|
draggable={false}
|
|
/>
|
|
|
|
{/* Aspect ratio container to map percentage boxes correctly */}
|
|
<div className="absolute inset-0 w-full h-full">
|
|
{/* Main Body */}
|
|
<ResizableBox
|
|
rect={mainBody} color="#facc15" label="Main Body"
|
|
isActive={activeFeature === 'mainBody'}
|
|
onUpdate={setMainBody} onActivate={() => setActiveFeature('mainBody')}
|
|
/>
|
|
|
|
{/* Targets */}
|
|
<ResizableBox
|
|
rect={leftEye} color="#ef4444" label="Left Eye Target"
|
|
isActive={activeFeature === 'leftEye'}
|
|
onUpdate={setLeftEye} onActivate={() => setActiveFeature('leftEye')}
|
|
/>
|
|
<ResizableBox
|
|
rect={rightEye} color="#3b82f6" label="Right Eye Target"
|
|
isActive={activeFeature === 'rightEye'}
|
|
onUpdate={setRightEye} onActivate={() => setActiveFeature('rightEye')}
|
|
/>
|
|
<ResizableBox
|
|
rect={mouth} color="#22c55e" label="Mouth Target"
|
|
isActive={activeFeature === 'mouth'}
|
|
onUpdate={setMouth} onActivate={() => setActiveFeature('mouth')}
|
|
/>
|
|
|
|
{/* Sources */}
|
|
<ResizableBox
|
|
rect={textureClosedEye} color="#a855f7" label="Source: Closed Eyes"
|
|
isActive={activeFeature === 'textureClosedEye'}
|
|
onUpdate={setTextureClosedEye} onActivate={() => setActiveFeature('textureClosedEye')}
|
|
/>
|
|
<ResizableBox
|
|
rect={textureOpenMouth} color="#f97316" label="Source: Open Mouth"
|
|
isActive={activeFeature === 'textureOpenMouth'}
|
|
onUpdate={setTextureOpenMouth} onActivate={() => setActiveFeature('textureOpenMouth')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Sidebar Controls */}
|
|
<div className="w-72 flex flex-col gap-4 bg-slate-800/50 p-6 rounded-xl border border-slate-700 h-full overflow-y-auto">
|
|
|
|
<div className="bg-slate-900/50 p-4 rounded-lg space-y-3">
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-400 mb-2 uppercase">Background Removal</label>
|
|
<div className="flex items-center justify-between p-2 bg-slate-800 rounded-lg border border-slate-700">
|
|
<span className="text-xs text-slate-300">AI Magic Removal</span>
|
|
<label className="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
className="sr-only peer"
|
|
checked={useAiBackground}
|
|
onChange={(e) => setUseAiBackground(e.target.checked)}
|
|
/>
|
|
<div className="w-9 h-5 bg-slate-600 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-cyan-500"></div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-400 mb-1 uppercase">Eyelid Skin Color</label>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="color"
|
|
value={skinColor}
|
|
onChange={(e) => setSkinColor(e.target.value)}
|
|
className="w-8 h-8 rounded cursor-pointer border-0 p-0"
|
|
/>
|
|
<span className="text-xs text-slate-400 font-mono">Fallback</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3 flex-1">
|
|
<div className="text-xs font-bold text-slate-400 uppercase border-b border-slate-700 pb-1">Composition</div>
|
|
<div className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer hover:text-white" onClick={() => setActiveFeature('mainBody')}>
|
|
<div className="w-3 h-3 bg-yellow-400 rounded-full shadow"></div> Main Body Crop
|
|
</div>
|
|
|
|
<div className="text-xs font-bold text-slate-400 uppercase border-b border-slate-700 pb-1 mt-4">Targets (Main Face)</div>
|
|
<div className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer hover:text-white" onClick={() => setActiveFeature('leftEye')}>
|
|
<div className="w-3 h-3 bg-red-500 rounded-full shadow"></div> Left Eye
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer hover:text-white" onClick={() => setActiveFeature('rightEye')}>
|
|
<div className="w-3 h-3 bg-blue-500 rounded-full shadow"></div> Right Eye
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer hover:text-white" onClick={() => setActiveFeature('mouth')}>
|
|
<div className="w-3 h-3 bg-green-500 rounded-full shadow"></div> Mouth
|
|
</div>
|
|
|
|
<div className="text-xs font-bold text-slate-400 uppercase border-b border-slate-700 pb-1 mt-4">Sources (Right Side)</div>
|
|
<div className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer hover:text-white" onClick={() => setActiveFeature('textureClosedEye')}>
|
|
<div className="w-3 h-3 bg-purple-500 rounded-full shadow"></div> Closed Eye Texture
|
|
</div>
|
|
<div className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer hover:text-white" onClick={() => setActiveFeature('textureOpenMouth')}>
|
|
<div className="w-3 h-3 bg-orange-500 rounded-full shadow"></div> Open Mouth Texture
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<button
|
|
onClick={() => onComplete({
|
|
leftEye, rightEye, mouth, skinColor,
|
|
textureClosedEye, textureOpenMouth, mainBody,
|
|
chromaKeyColor: useAiBackground ? 'AI_AUTO' : ''
|
|
})}
|
|
className="w-full py-4 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-white rounded-xl font-bold shadow-lg shadow-cyan-500/25 transform hover:scale-[1.02] transition-all"
|
|
>
|
|
Finish Rigging
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default RiggingEditor;
|