vtube-studio/components/RiggingEditor.tsx
James Twose b6017794a5 feat: Initialize Gemini V-Studio project setup
Sets up the foundational project structure, including:
- Vite for build tooling.
- React for the UI.
- Tailwind CSS for styling.
- MediaPipe for face tracking capabilities.
- Gemini API integration for avatar generation.
- Basic configuration files (package.json, vite.config.ts, tsconfig.json).
- Initial README with local run instructions.
- Core types and a basic Gemini service for image generation.
2025-11-20 20:45:25 +01:00

225 lines
7.8 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 }) => void;
}
type ActiveFeature = 'leftEye' | 'rightEye' | 'mouth' | 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-20' : 'z-10'}`}
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"
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 }) => {
const [leftEye, setLeftEye] = useState<Rect>(initialData?.leftEye || { x: 0.35, y: 0.4, w: 0.12, h: 0.08 });
const [rightEye, setRightEye] = useState<Rect>(initialData?.rightEye || { x: 0.53, y: 0.4, w: 0.12, h: 0.08 });
const [mouth, setMouth] = useState<Rect>(initialData?.mouth || { x: 0.45, y: 0.6, w: 0.1, h: 0.05 });
const [skinColor, setSkinColor] = useState<string>(initialData?.skinColor || '#fcd3bf');
const [activeFeature, setActiveFeature] = useState<ActiveFeature>(null);
return (
<div className="flex flex-col items-center h-full max-w-4xl mx-auto p-4">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-white mb-2">Rig Your Avatar</h2>
<p className="text-slate-400">
Drag and resize the boxes to match your avatar's features.
This ensures the eyes blink correctly.
</p>
</div>
<div className="flex gap-8 w-full items-start">
{/* Editor Area */}
<div className="flex-1 bg-slate-800 p-4 rounded-xl border border-slate-700 flex justify-center">
<div className="relative inline-block select-none" style={{ width: '500px', maxWidth: '100%' }}>
<img
src={imageUrl}
alt="Rigging Target"
className="w-full h-auto rounded-lg pointer-events-none select-none block"
draggable={false}
/>
<ResizableBox
rect={leftEye}
color="#ef4444" // Red
label="Left Eye"
isActive={activeFeature === 'leftEye'}
onUpdate={setLeftEye}
onActivate={() => setActiveFeature('leftEye')}
/>
<ResizableBox
rect={rightEye}
color="#3b82f6" // Blue
label="Right Eye"
isActive={activeFeature === 'rightEye'}
onUpdate={setRightEye}
onActivate={() => setActiveFeature('rightEye')}
/>
<ResizableBox
rect={mouth}
color="#22c55e" // Green
label="Mouth"
isActive={activeFeature === 'mouth'}
onUpdate={setMouth}
onActivate={() => setActiveFeature('mouth')}
/>
</div>
</div>
{/* Sidebar Controls */}
<div className="w-64 flex flex-col gap-6 bg-slate-800/50 p-6 rounded-xl border border-slate-700 h-full">
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">Eyelid Color</label>
<div className="flex items-center gap-3">
<input
type="color"
value={skinColor}
onChange={(e) => setSkinColor(e.target.value)}
className="w-10 h-10 rounded cursor-pointer border-0 p-0"
/>
<span className="text-xs text-slate-400 font-mono">{skinColor}</span>
</div>
<p className="text-xs text-slate-500 mt-2">
Pick the color of the skin above the eyes for realistic blinking.
</p>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-slate-300">
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
<span>Left Eye Box</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-300">
<div className="w-3 h-3 bg-blue-500 rounded-full"></div>
<span>Right Eye Box</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-300">
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
<span>Mouth Box</span>
</div>
</div>
<div className="mt-auto pt-6">
<button
onClick={() => onComplete({ leftEye, rightEye, mouth, skinColor })}
className="w-full py-3 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;