import { Canvas, useLoader, useThree } from "@react-three/fiber"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { Suspense, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; import { ScanFace, Sparkles, X } from "lucide-react"; import { Box3, Color, Mesh, MeshStandardMaterial, Vector3, type Object3D } from "three"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js"; import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js"; import { SpotlightSurface } from "@/components/ui/aceternity/spotlight-surface"; import { aceternityMotion } from "@/lib/aceternity-motion"; import { canvasThemes } from "@/lib/canvas-theme"; import { canvasEmotionPresets, emotionBlendshapes, type CanvasEmotionParams, type CanvasEmotionPreset, type CanvasEmotionEditRegion, type CanvasFaceBox, } from "@/lib/canvas/canvas-emotion"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; export type CanvasImageEmotionPayload = CanvasEmotionParams & { label: string; prompt: string; sourceDataUrl: string; maskDataUrl: string; characterDataUrl: string; editRegion: CanvasEmotionEditRegion; imageWidth: number; imageHeight: number; }; export type CanvasEmotionCharacter = { id: string; name: string; faceBox: CanvasFaceBox; }; type CanvasNodeEmotionPanelProps = { dataUrl: string; imageWidth: number; imageHeight: number; characters: CanvasEmotionCharacter[]; activeCharacterId: string; preset: CanvasEmotionPreset; generating: boolean; error?: string; onSelectCharacter: (characterId: string) => void; onManualSelect: () => void; onPresetChange: (preset: CanvasEmotionPreset) => void; onClose: () => void; onConfirm: () => void; }; export function CanvasNodeEmotionPanel({ dataUrl, imageWidth, imageHeight, characters, activeCharacterId, preset, generating, error, onSelectCharacter, onManualSelect, onPresetChange, onClose, onConfirm }: CanvasNodeEmotionPanelProps) { const theme = canvasThemes[useActiveTheme()]; const reducedMotion = useReducedMotion(); return (
{characters.map((character) => ( onSelectCharacter(character.id)} > {activeCharacterId === character.id ? : null} {character.name} ))}
情绪定位 {preset.label} {error ? {error} : } {generating ? "准备生成" : "生成"}
); } function FaceThumbnail({ dataUrl, imageWidth, imageHeight, box }: { dataUrl: string; imageWidth: number; imageHeight: number; box: CanvasFaceBox }) { const scaleX = imageWidth / Math.max(1, box.width); const scaleY = imageHeight / Math.max(1, box.height); return ( ); } function EmotionPad({ preset, onChange }: { preset: CanvasEmotionPreset; onChange: (preset: CanvasEmotionPreset) => void }) { const theme = canvasThemes[useActiveTheme()]; const reducedMotion = useReducedMotion(); const [pointer, setPointer] = useState<{ x: number; y: number } | null>(null); const [dragging, setDragging] = useState(false); const update = (event: ReactPointerEvent) => { const rect = event.currentTarget.getBoundingClientRect(); const x = Math.max(0, Math.min(4, ((event.clientX - rect.left) / Math.max(1, rect.width)) * 5 - 0.5)); const y = Math.max(0, Math.min(4, ((event.clientY - rect.top) / Math.max(1, rect.height)) * 5 - 0.5)); setPointer({ x, y }); const next = canvasEmotionPresets[Math.round(y) * 5 + Math.round(x)]; if (next && next.id !== preset.id) onChange(next); }; const selectedColumn = 2 - preset.intimacy; const selectedRow = 2 - preset.arousal; return (
激动 平静 亲近 疏离
{ event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); setDragging(true); update(event); }} onPointerMove={(event) => { if (dragging) update(event); }} onPointerUp={(event) => { setDragging(false); setPointer(null); event.currentTarget.releasePointerCapture(event.pointerId); }} onPointerCancel={() => { setDragging(false); setPointer(null); }} onKeyDown={(event) => { const column = Math.max(0, Math.min(4, selectedColumn + (event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0))); const row = Math.max(0, Math.min(4, selectedRow + (event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0))); if (column !== selectedColumn || row !== selectedRow) { event.preventDefault(); onChange(canvasEmotionPresets[row * 5 + column]); } }} > {canvasEmotionPresets.map((item, index) => { const column = index % 5; const row = Math.floor(index / 5); const active = item.id === preset.id; const onPath = !dragging && (column === selectedColumn || row === selectedRow); const distance = pointer ? Math.hypot(pointer.x - column, pointer.y - row) : 4; const proximity = dragging ? Math.max(0, 1 - distance / 1.7) : 0; return ( ); })} {dragging && pointer ? : null}
); } function EmotionHeadPreview({ preset }: { preset: CanvasEmotionPreset }) { const theme = canvasThemes[useActiveTheme()]; return (
实时预览 · {preset.label}
); } function EmotionFaceModel({ preset }: { preset: CanvasEmotionPreset }) { const renderer = useThree((state) => state.gl); const gltf = useLoader(GLTFLoader, "/canvas/models/facecap.glb", (loader) => { loader.setKTX2Loader(new KTX2Loader().setTranscoderPath("/three/basis/").detectSupport(renderer)); loader.setMeshoptDecoder(MeshoptDecoder); }); const invalidate = useThree((state) => state.invalidate); const reducedMotion = useReducedMotion(); const model = useMemo(() => createMannequinModel(gltf.scene), [gltf.scene]); const animationRef = useRef(null); useEffect(() => { const targets = emotionBlendshapes(preset); const meshes = morphMeshes(model); const starts = meshes.map((mesh) => [...(mesh.morphTargetInfluences || [])]); const startTime = performance.now(); const duration = reducedMotion ? 0 : 220; const animate = (now: number) => { const progress = duration ? Math.min(1, (now - startTime) / duration) : 1; const eased = 1 - Math.pow(1 - progress, 3); meshes.forEach((mesh, meshIndex) => { const dictionary = mesh.morphTargetDictionary || {}; const influences = mesh.morphTargetInfluences || []; Object.entries(dictionary).forEach(([name, index]) => { const target = targets[name as keyof typeof targets] || 0; influences[index] = (starts[meshIndex][index] || 0) + (target - (starts[meshIndex][index] || 0)) * eased; }); }); invalidate(); if (progress < 1) animationRef.current = requestAnimationFrame(animate); }; if (animationRef.current) cancelAnimationFrame(animationRef.current); animationRef.current = requestAnimationFrame(animate); return () => { if (animationRef.current) cancelAnimationFrame(animationRef.current); }; }, [invalidate, model, preset, reducedMotion]); return ; } function createMannequinModel(source: Object3D) { const model = source.clone(true); model.traverse((object) => { if (!(object instanceof Mesh)) return; const material = new MeshStandardMaterial({ color: new Color("#aaacae"), roughness: 0.9, metalness: 0.01 }); object.material = material; object.castShadow = false; object.receiveShadow = false; }); // 不依赖模型原点,按真实包围盒归一化后再居中,避免不同导出版本出现头像偏移。 const bounds = new Box3().setFromObject(model); const size = bounds.getSize(new Vector3()); model.scale.multiplyScalar(2.35 / Math.max(size.y, 0.001)); const normalizedBounds = new Box3().setFromObject(model); model.position.sub(normalizedBounds.getCenter(new Vector3())); return model; } function morphMeshes(model: Object3D) { const meshes: Mesh[] = []; model.traverse((object) => { if (object instanceof Mesh && object.morphTargetDictionary && object.morphTargetInfluences) meshes.push(object); }); return meshes; }