import { useMemo, type CSSProperties } from "react"; import type { AvatarExpression, AvatarState } from "./persona"; export type RasterVariant = "neutral" | "sad" | "tired" | "anxious" | "warm" | "startled"; const VARIANT_FOR_EXPRESSION: Record = { neutral: "neutral", calm: "neutral", bored: "neutral", determined: "neutral", warm: "warm", joy: "warm", delight: "warm", relief: "warm", hopeful: "warm", sad: "sad", grief: "sad", lonely: "sad", ashamed: "sad", guilty: "sad", overwhelmed: "sad", anxious: "anxious", confused: "anxious", conflicted: "anxious", embarrassed: "anxious", resistant: "anxious", guarded: "anxious", skeptical: "anxious", irritated: "anxious", angry: "anxious", rage: "anxious", startled: "startled", panic: "startled", tired: "tired", }; const LIVE2D_PARTS_ART_SETS = new Set(["seoyeon-live2d-v3"]); const PSB_GROUP_ART_SETS = new Set(["seoyeon-live2d-psb"]); export function rasterVariantFor(expression: AvatarExpression): RasterVariant { return VARIANT_FOR_EXPRESSION[expression]; } export interface RasterBustProps { artSet: string; affect: AvatarExpression; state: AvatarState; breath: number; blink: number; mouth: number; gazeX: number; gazeY: number; headTilt: number; reduced: boolean; } function layerTransform(extra = ""): string { return `translateX(-50%)${extra ? ` ${extra}` : ""}`; } function eyeClipStyle(maskUrl: string): CSSProperties { return { WebkitMaskImage: `url("${maskUrl}")`, maskImage: `url("${maskUrl}")`, WebkitMaskRepeat: "no-repeat", maskRepeat: "no-repeat", WebkitMaskSize: "100% 100%", maskSize: "100% 100%", }; } function eyeApertureClipStyle( side: "left" | "right", upperLidY: number, lowerLidY: number, closedEyes: number, ): CSSProperties { const baseTop = side === "left" ? 30.25 : 29.0; const baseBottom = side === "left" ? 65.85 : 67.05; const top = clamp(baseTop + upperLidY * 0.18 + closedEyes * 2.4, 0, 48); const bottom = clamp(baseBottom - lowerLidY * 0.12 + closedEyes * 2.1, 45, 82); const clipPath = `inset(${top.toFixed(2)}% 0 ${bottom.toFixed(2)}% 0)`; return { WebkitClipPath: clipPath, clipPath, }; } function clamp01(value: number): number { return Math.max(0, Math.min(1, value)); } function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } function mouthForVariant(variant: RasterVariant): string { if (variant === "sad" || variant === "tired" || variant === "anxious") return "mouth-sad"; if (variant === "warm") return "mouth-warm"; return "mouth-neutral"; } function browPoseForVariant(variant: RasterVariant): { y: number; leftRotate: number; rightRotate: number } { switch (variant) { case "sad": case "tired": return { y: 1.4, leftRotate: 1.1, rightRotate: -1.1 }; case "anxious": return { y: -1.2, leftRotate: -1.4, rightRotate: 1.4 }; case "startled": return { y: -2.4, leftRotate: 0, rightRotate: 0 }; case "warm": return { y: -0.6, leftRotate: -0.4, rightRotate: 0.4 }; default: return { y: 0, leftRotate: 0, rightRotate: 0 }; } } function psbMouthForVariant(variant: RasterVariant): string { switch (variant) { case "warm": return "mouth-warm"; case "sad": return "mouth-sad"; case "tired": return "mouth-tired"; case "anxious": return "mouth-anxious"; case "startled": return "mouth-startled"; default: return "mouth-neutral"; } } function psbBrowPoseForVariant(variant: RasterVariant): { y: number; leftRotate: number; rightRotate: number } { switch (variant) { case "sad": return { y: 2.6, leftRotate: 2.2, rightRotate: -2.2 }; case "tired": return { y: 2.0, leftRotate: 0.8, rightRotate: -0.8 }; case "anxious": return { y: -2.2, leftRotate: -3.0, rightRotate: 3.0 }; case "startled": return { y: -4.2, leftRotate: 0, rightRotate: 0 }; case "warm": return { y: -1.0, leftRotate: -0.8, rightRotate: 0.8 }; default: return { y: 0, leftRotate: 0, rightRotate: 0 }; } } function psbEyePoseForVariant(variant: RasterVariant): { scaleY: number; upperY: number; lowerY: number; lashY: number; leftLashRotate: number; rightLashRotate: number; detailY: number; } { switch (variant) { case "warm": return { scaleY: 0.72, upperY: 5.4, lowerY: -1.4, lashY: 3.0, leftLashRotate: -1.4, rightLashRotate: 1.4, detailY: 0.9, }; case "sad": return { scaleY: 0.92, upperY: 1.8, lowerY: -0.5, lashY: 1.0, leftLashRotate: 1.2, rightLashRotate: -1.2, detailY: 0.4, }; case "tired": return { scaleY: 0.56, upperY: 7.2, lowerY: -1.9, lashY: 4.0, leftLashRotate: 0.4, rightLashRotate: -0.4, detailY: 1.0, }; case "anxious": return { scaleY: 1.04, upperY: -1.2, lowerY: 0.4, lashY: -0.8, leftLashRotate: -1.5, rightLashRotate: 1.5, detailY: -0.2, }; case "startled": return { scaleY: 1.18, upperY: -5.8, lowerY: 1.6, lashY: -3.2, leftLashRotate: 0, rightLashRotate: 0, detailY: -0.5, }; default: return { scaleY: 1, upperY: 0, lowerY: 0, lashY: 0, leftLashRotate: 0, rightLashRotate: 0, detailY: 0, }; } } function psbGeneratedEyeVariantFor(variant: RasterVariant): "joy" | "sad" | "startled" | null { // Imagegen v1 eye parts drifted too far from the source PSB style. void variant; return null; } export function RasterBust({ artSet, affect, state, breath, blink, mouth, gazeX, gazeY, headTilt, reduced, }: RasterBustProps) { const variant = rasterVariantFor(affect); const speaking = state === "speaking" && !reduced; const stackTransform = `translate(${gazeX.toFixed(2)}px, ${(gazeY - breath).toFixed( 2, )}px) rotate(${headTilt.toFixed(2)}deg)`; const raster = useMemo( () => (name: string) => `${import.meta.env.BASE_URL}avatar/${artSet}/${name}.png`, [artSet], ); if (PSB_GROUP_ART_SETS.has(artSet)) { const part = (name: string) => `${import.meta.env.BASE_URL}avatar/${artSet}/parts/${name}.png`; const speakingOpen = speaking ? clamp01(Math.max(mouth, 0.1)) : 0; const wideOpen = clamp01((speakingOpen - 0.34) / 0.5); const smallOpen = speakingOpen > 0 ? 1 - wideOpen : 0; const closedEyes = reduced ? 0 : clamp01((0.55 - blink) / 0.47); const openEyes = 1 - closedEyes; const irisX = reduced ? 0 : gazeX * 0.75; const irisY = reduced ? 0 : gazeY * 0.48; const hairSway = reduced ? 0 : clamp(breath * 0.48 + headTilt * 0.22 + gazeX * 0.08, -2.2, 2.2); const hairLag = reduced ? 0 : clamp(breath * 0.34 + headTilt * 0.18, -1.8, 1.8); const talkBounce = speakingOpen * 1.25; const browFloat = reduced ? 0 : breath * 0.22; const brow = psbBrowPoseForVariant(variant); const eyePose = psbEyePoseForVariant(variant); const generatedEyeVariant = psbGeneratedEyeVariantFor(variant); const blinkScale = 1 - closedEyes * 0.78; const eyeScaleY = Math.max(0.06, eyePose.scaleY * blinkScale); const upperLidY = eyePose.upperY + closedEyes * 10.8; const lowerLidY = eyePose.lowerY - closedEyes * 2.4; const lashY = eyePose.lashY + closedEyes * 2.1; const expressionMouth = psbMouthForVariant(variant); const eyeDetailLayers: Array<{ name: string; mask: string; zIndex: number }> = [ { name: "iris-left", mask: "eye-white-left", zIndex: 9 }, { name: "iris-right", mask: "eye-white-right", zIndex: 9 }, { name: "pupil-left", mask: "eye-white-left", zIndex: 10 }, { name: "pupil-right", mask: "eye-white-right", zIndex: 10 }, { name: "highlight-left", mask: "eye-white-left", zIndex: 11 }, { name: "highlight-right", mask: "eye-white-right", zIndex: 11 }, ]; const renderLayer = ( name: string, className: string, zIndex: number, style: CSSProperties = {}, ) => ( ); return ( ); } if (LIVE2D_PARTS_ART_SETS.has(artSet)) { const part = (name: string) => `${import.meta.env.BASE_URL}avatar/${artSet}/parts/${name}.png`; const speakingOpen = speaking ? clamp01(Math.max(mouth, 0.08)) : 0; const closedEyes = reduced ? 0 : clamp01((0.55 - blink) / 0.47); const openEyes = 1 - closedEyes; const irisX = reduced ? 0 : gazeX * 0.42; const irisY = reduced ? 0 : gazeY * 0.28; const hairSway = reduced ? 0 : breath * 0.24 + headTilt * 0.18; const brow = browPoseForVariant(variant); const expressionMouth = mouthForVariant(variant); const eyeDetailLayers: Array<{ name: string; mask: string; zIndex: number }> = [ { name: "iris-left", mask: "eye-white-left", zIndex: 9 }, { name: "iris-right", mask: "eye-white-right", zIndex: 9 }, { name: "pupil-left", mask: "eye-white-left", zIndex: 10 }, { name: "pupil-right", mask: "eye-white-right", zIndex: 10 }, { name: "highlight-left", mask: "eye-white-left", zIndex: 11 }, { name: "highlight-right", mask: "eye-white-right", zIndex: 11 }, ]; return ( ); } return ( ); }