import { useEffect, useRef, useState } from "react"; import type { AffectParams } from "./persona"; const NUMERIC_PARAM_KEYS = [ "eyelidDrop", "eyeOpen", "pupilScale", "gazeAvert", "headTilt", "shoulderTurn", "breathPeriod", "breathAmp", "auraOpacity", "blinkMin", "blinkMax", "mouthCurve", "mouthOpen", "mouthWidth", "mouthTension", "browTilt", "browLift", "browPinch", ] as const satisfies readonly (keyof AffectParams)[]; export interface ExpressionTransitionFrame { params: AffectParams; progress: number; active: boolean; } function easeOutCubic(t: number): number { return 1 - Math.pow(1 - t, 3); } function interpolateParams(from: AffectParams, to: AffectParams, progress: number): AffectParams { const eased = easeOutCubic(progress); const next: AffectParams = { ...to }; for (const key of NUMERIC_PARAM_KEYS) { next[key] = from[key] + (to[key] - from[key]) * eased; } return next; } export function useExpressionTransition( target: AffectParams, fadeInMs: number, enabled: boolean, ): ExpressionTransitionFrame { const [frame, setFrame] = useState({ params: target, progress: 1, active: false, }); const currentRef = useRef(target); useEffect(() => { if (!enabled) { currentRef.current = target; setFrame({ params: target, progress: 1, active: false }); return; } let raf = 0; const from = currentRef.current; const duration = Math.max(80, fadeInMs); const startedAt = performance.now(); const tick = (now: number) => { const progress = Math.min(1, (now - startedAt) / duration); const params = interpolateParams(from, target, progress); currentRef.current = params; setFrame({ params, progress, active: progress < 1 }); if (progress < 1) { raf = requestAnimationFrame(tick); } }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [enabled, fadeInMs, target]); return frame; }