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.
225 lines
7.8 KiB
TypeScript
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;
|