vtube-studio/src/renderer/components/RiggingEditor.tsx

270 lines
12 KiB
TypeScript

import React, { useState, useRef, useEffect } from 'react';
import { Rect } from '../../shared/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',
}}
>
<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>
<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 }) => {
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 });
const [mainBody, setMainBody] = useState<Rect>({ x: 0.05, y: 0.05, w: 0.65, h: 0.9 });
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');
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]">
<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}
/>
<div className="absolute inset-0 w-full h-full">
<ResizableBox
rect={mainBody} color="#facc15" label="Main Body"
isActive={activeFeature === 'mainBody'}
onUpdate={setMainBody} onActivate={() => setActiveFeature('mainBody')}
/>
<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')}
/>
<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>
<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;