The Gemini service has been updated to generate a character sheet rather than a single avatar image. This sheet includes the main character and separate assets for closed eyes and an open mouth. The `AvatarConfig` type and `RiggingEditor` component have been extended to handle these new expression assets (`textureClosedEye`, `textureOpenMouth`). A new `Sprite` component has been added to `Studio.tsx` to correctly render these specific regions from the generated character sheet. The UI has been updated to reflect the new generation process.
242 lines
9.9 KiB
TypeScript
242 lines
9.9 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;
|
|
}) => void;
|
|
}
|
|
|
|
type ActiveFeature = 'leftEye' | 'rightEye' | 'mouth' | 'textureClosedEye' | 'textureOpenMouth' | 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 });
|
|
|
|
// 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');
|
|
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. Match the <b>Target</b> boxes (Red/Blue/Green) to the main character.<br/>
|
|
2. Match the <b>Source</b> boxes (Purple/Orange) to the extra 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">
|
|
{/* 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">
|
|
<label className="block text-xs font-bold text-slate-400 mb-2 uppercase">Skin Color Fallback</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">{skinColor}</span>
|
|
</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">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 })}
|
|
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;
|