diff --git a/frontend/public/live2d/models/kira/outfits/cozy-hoodie.png b/frontend/public/live2d/models/kira/outfits/cozy-hoodie.png new file mode 100644 index 0000000..0507234 Binary files /dev/null and b/frontend/public/live2d/models/kira/outfits/cozy-hoodie.png differ diff --git a/frontend/public/live2d/models/kira/outfits/girly-dress.png b/frontend/public/live2d/models/kira/outfits/girly-dress.png new file mode 100644 index 0000000..7e4790f Binary files /dev/null and b/frontend/public/live2d/models/kira/outfits/girly-dress.png differ diff --git a/frontend/public/live2d/models/kira/outfits/going-out.png b/frontend/public/live2d/models/kira/outfits/going-out.png new file mode 100644 index 0000000..ebd7832 Binary files /dev/null and b/frontend/public/live2d/models/kira/outfits/going-out.png differ diff --git a/frontend/public/live2d/models/kira/outfits/pajama-set.png b/frontend/public/live2d/models/kira/outfits/pajama-set.png new file mode 100644 index 0000000..9f5696f Binary files /dev/null and b/frontend/public/live2d/models/kira/outfits/pajama-set.png differ diff --git a/frontend/public/live2d/models/kira/outfits/study-sweater.png b/frontend/public/live2d/models/kira/outfits/study-sweater.png new file mode 100644 index 0000000..f8df7fb Binary files /dev/null and b/frontend/public/live2d/models/kira/outfits/study-sweater.png differ diff --git a/frontend/src/components/KiraAvatar.tsx b/frontend/src/components/KiraAvatar.tsx index a4877e1..608bc7a 100644 --- a/frontend/src/components/KiraAvatar.tsx +++ b/frontend/src/components/KiraAvatar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useCallback } from 'react'; +import { useEffect, useRef, useState } from 'react'; import AnimatedAvatar from './AnimatedAvatar'; interface Props { @@ -9,162 +9,193 @@ interface Props { onTalkToggle: () => void; } +type ExpressionName = 'Normal' | 'Smile' | 'Sad' | 'Angry' | 'Surprised' | 'Blushing'; + +const EXPRESSIONS: ExpressionName[] = ['Normal', 'Smile', 'Sad', 'Angry', 'Surprised', 'Blushing']; +const IDLE_EXPRESSIONS: ExpressionName[] = ['Normal', 'Smile', 'Blushing']; + +const OUTFIT_TEXTURES: Record = { + 'cozy-hoodie': '/live2d/models/kira/outfits/cozy-hoodie.png', + 'girly-dress': '/live2d/models/kira/outfits/girly-dress.png', + 'pajama-set': '/live2d/models/kira/outfits/pajama-set.png', + 'study-sweater': '/live2d/models/kira/outfits/study-sweater.png', + 'going-out': '/live2d/models/kira/outfits/going-out.png', +}; + export default function KiraAvatar(props: Props) { const canvasRef = useRef(null); const appRef = useRef(null); const modelRef = useRef(null); + const textureRef = useRef(null); + const lipSyncRef = useRef(0); + const idleExprRef = useRef>(); const [live2dReady, setLive2dReady] = useState(false); const [loadError, setLoadError] = useState(false); - const animFrameRef = useRef(0); + const [currentExpression, setCurrentExpression] = useState('Normal'); - // Initialize Live2D + // ── Initialize Live2D ── useEffect(() => { let mounted = true; - const canvasEl = canvasRef.current; - if (!canvasEl) return; + const container = canvasRef.current; + if (!container) return; - const init = async () => { + (async () => { try { - // Check if model exists const resp = await fetch('/live2d/models/kira/kira.model3.json', { method: 'HEAD' }); if (!resp.ok) { if (mounted) setLoadError(true); return; } - // Load Cubism core script await loadScript('/live2d/cubism/live2dcubismcore.min.js'); - - // Dynamic imports - const { Application } = await import('pixi.js'); + const { Application, Texture } = await import('pixi.js'); const { Live2DModel } = await import('pixi-live2d-display/cubism4'); - // Create Pixi app + // Responsive sizing + const size = Math.min(container.clientWidth || 260, 260); const app = new Application({ - width: 220, - height: 280, + width: size, + height: size * 1.25, transparent: true, antialias: true, - resolution: 2, + resolution: Math.min(window.devicePixelRatio || 1, 2), backgroundAlpha: 0, }); appRef.current = app; + if (!mounted) { app.destroy(true); return; } - if (!mounted) { - app.destroy(true); - return; - } + container.appendChild(app.view as HTMLCanvasElement); - // Append canvas - canvasEl.appendChild(app.view as HTMLCanvasElement); - - // Load model + // Load model with default textures const model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', { autoInteract: false, }); modelRef.current = model; - // Scale and center - const scale = Math.min(220 / model.width, 280 / model.height) * 0.85; + // Fit model in canvas + const maxW = size * 0.78; + const maxH = size * 1.1; + const scale = Math.min(maxW / model.width, maxH / model.height); model.scale.set(scale); model.anchor.set(0.5, 0.5); - model.position.set(app.screen.width / 2, app.screen.height / 2 + 10); + model.position.set(app.screen.width / 2, app.screen.height / 2 + 6); app.stage.addChild(model); + + // Store reference to texture_02 for outfit swapping + try { + textureRef.current = { + index: 2, + original: model.internalModel.coreModel.getTexture(2), + }; + } catch { /* ignore */ } + if (mounted) setLive2dReady(true); // Start idle animation - try { - model.motion('Idle'); - } catch { - // Some models may not have Idle motion - } + try { model.motion('Idle'); } catch { /* no idle */ } + try { model.expression('Normal'); } catch { /* no expressions */ } - // Set default expression - try { - model.expression('Normal'); - } catch { - // Expressions are optional - } + // Random idle expression changes + idleExprRef.current = setInterval(() => { + if (props.isSpeaking) return; + const expr = IDLE_EXPRESSIONS[Math.floor(Math.random() * IDLE_EXPRESSIONS.length)]; + try { model.expression(expr); } catch { /* */ } + setCurrentExpression(expr); + }, 8000 + Math.random() * 7000); } catch (e) { - console.warn('[Live2D] Failed to load:', e); + console.warn('[Live2D]', e); if (mounted) setLoadError(true); } - }; - - init(); + })(); return () => { mounted = false; - cancelAnimationFrame(animFrameRef.current); + cancelAnimationFrame(lipSyncRef.current); + clearInterval(idleExprRef.current); if (appRef.current) { appRef.current.destroy(true, { children: true }); appRef.current = null; } modelRef.current = null; + textureRef.current = null; }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Handle speaking → lip sync + // ── Lip sync from TTS ── useEffect(() => { - if (!modelRef.current || !live2dReady) return; - const model = modelRef.current; - const core = (window as any).Live2DCubismCore; + if (!model || !live2dReady) return; + + cancelAnimationFrame(lipSyncRef.current); if (props.isSpeaking) { - // Animate mouth while speaking - let mouthPhase = 0; - const animateMouth = () => { - mouthPhase += 0.15; - const openness = 0.3 + Math.sin(mouthPhase) * 0.35; + let phase = 0; + const animate = () => { + phase += 0.12; + const openness = 0.25 + Math.sin(phase) * 0.35; try { model.internalModel.coreModel.setParameterValueByIndex( - findParameterIndex(model, 'PARAM_MOUTH_OPEN_Y'), + findParam(model, 'PARAM_MOUTH_OPEN_Y'), openness, ); - } catch { /* ignore */ } - animFrameRef.current = requestAnimationFrame(animateMouth); + } catch { /* */ } + lipSyncRef.current = requestAnimationFrame(animate); }; - animateMouth(); + animate(); } else { - // Close mouth when not speaking - cancelAnimationFrame(animFrameRef.current); try { model.internalModel.coreModel.setParameterValueByIndex( - findParameterIndex(model, 'PARAM_MOUTH_OPEN_Y'), + findParam(model, 'PARAM_MOUTH_OPEN_Y'), 0, ); - } catch { /* ignore */ } + } catch { /* */ } } - return () => cancelAnimationFrame(animFrameRef.current); + return () => cancelAnimationFrame(lipSyncRef.current); }, [props.isSpeaking, live2dReady]); - // Handle outfit changes + // ── Outfit texture swapping ── useEffect(() => { - if (!modelRef.current || !live2dReady) return; - // Texture swapping for outfits would go here - // For now, the outfit system works on the fallback avatar + const model = modelRef.current; + if (!model || !live2dReady) return; + + const outfitUrl = OUTFIT_TEXTURES[props.outfit]; + if (!outfitUrl) return; + + (async () => { + try { + const { Texture, BaseTexture } = await import('pixi.js'); + const base = await BaseTexture.from(outfitUrl); + const tex = new Texture(base); + model.internalModel.coreModel.setTexture(2, base); + } catch (e) { + console.warn('[Outfit] texture swap failed:', e); + } + })(); }, [props.outfit, live2dReady]); - // Handle listening pulse effect + // ── Accessory toggle ── useEffect(() => { - if (!modelRef.current || !live2dReady) return; - // Could add a glow/breathing effect when listening - }, [props.isListening, live2dReady]); + const model = modelRef.current; + if (!model || !live2dReady) return; + + // The model has hair clips on texture_01 that could be toggled + // For now, the accessory system works on the fallback avatar + }, [props.accessory, live2dReady]); return ( -
+
{/* Live2D canvas */}
- {/* Animated SVG placeholder when Live2D isn't available */} + {/* SVG fallback */} {(!live2dReady || loadError) && (
)} - {/* Loading state */} + {/* Loading spinner */} {!live2dReady && !loadError && ( -
+

loading Kira...

)} + {/* Expression indicator */} + {live2dReady && ( +
+ {EXPRESSIONS.map((expr) => ( + + ))} +
+ )} + {/* Status bar */}
@@ -192,34 +244,25 @@ export default function KiraAvatar(props: Props) { {props.isSpeaking ? 'speaking...' : props.isListening ? 'listening...' : live2dReady ? 'here with you' : 'loading...'}
- - {/* Name + outfit indicator */} -
- Kira - {live2dReady && · Live2D} -
); } -/** Load a script tag dynamically */ +// ── Helpers ── + function loadScript(src: string): Promise { return new Promise((resolve, reject) => { - // Check if already loaded - if (document.querySelector(`script[src="${src}"]`)) { - resolve(); - return; - } - const script = document.createElement('script'); - script.src = src; - script.onload = () => resolve(); - script.onerror = () => reject(new Error(`Failed to load ${src}`)); - document.head.appendChild(script); + if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; } + const s = document.createElement('script'); + s.src = src; + s.onload = () => resolve(); + s.onerror = () => reject(new Error(`Failed ${src}`)); + document.head.appendChild(s); }); } -/** Find a parameter index by name in the Live2D model */ -function findParameterIndex(model: any, name: string): number { - const parameters = model.internalModel.coreModel.getParameterIds(); - return parameters.indexOf(name); +function findParam(model: any, name: string): number { + try { + return model.internalModel.coreModel.getParameterIds().indexOf(name); + } catch { return 0; } } diff --git a/scripts/gen_outfits.py b/scripts/gen_outfits.py new file mode 100644 index 0000000..2080d84 --- /dev/null +++ b/scripts/gen_outfits.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Generate recolored outfit textures for Kira's Live2D model. + +Takes the base texture_02.png (clothing layer) and creates +color variants for each outfit using HSL shifts on non-skin areas. +""" + +from PIL import Image +import colorsys +import os + +BASE_DIR = os.path.expanduser( + "~/Projects/ai-body-double/frontend/public/live2d/models/kira" +) +TEXTURE_DIR = os.path.join(BASE_DIR, "Epsilon.1024") +OUT_DIR = os.path.join(BASE_DIR, "outfits") + +# Outfit → HSL shift mapping (hue_shift_deg, sat_mult, light_mult) +# Applied to non-white, non-skin pixels +OUTFITS = { + "cozy-hoodie": (10, 1.1, 1.0), # warm pink shift + "girly-dress": (240, 1.2, 0.95), # lavender/purple + "pajama-set": (140, 0.8, 1.1), # minty green + "study-sweater": (30, 1.1, 1.0), # warm orange/amber + "going-out": (320, 1.3, 1.05), # bright pink/magenta +} + +def is_skin(r, g, b): + """Roughly detect skin tones (avoid recoloring skin).""" + return 180 < r < 250 and 120 < g < 200 and 80 < b < 170 + +def is_white_or_void(r, g, b, a): + """Detect transparent or near-white pixels.""" + if a < 10: + return True + return r > 240 and g > 240 and b > 240 + +def recolor_texture(src_path, dst_path, hue_shift, sat_mult, light_mult): + """Recolor a texture by shifting HSL on non-skin, non-white pixels.""" + img = Image.open(src_path).convert("RGBA") + pixels = img.load() + w, h = img.size + + for y in range(h): + for x in range(w): + r, g, b, a = pixels[x, y] + if is_white_or_void(r, g, b, a) or is_skin(r, g, b): + continue + + # Convert to HSL + h_val, l_val, s_val = colorsys.rgb_to_hls(r / 255, g / 255, b / 255) + + # Apply shifts + h_val = (h_val + hue_shift / 360) % 1.0 + s_val = min(1.0, s_val * sat_mult) + l_val = max(0, min(1.0, l_val * light_mult)) + + # Convert back to RGB + nr, ng, nb = colorsys.hls_to_rgb(h_val, l_val, s_val) + pixels[x, y] = (int(nr * 255), int(ng * 255), int(nb * 255), a) + + os.makedirs(os.path.dirname(dst_path), exist_ok=True) + img.save(dst_path) + size = os.path.getsize(dst_path) + print(f" Created {os.path.basename(dst_path)} ({size//1024}KB)") + + +def main(): + src = os.path.join(TEXTURE_DIR, "texture_02.png") + if not os.path.exists(src): + print(f"Source not found: {src}") + return 1 + + print(f"Generating outfit textures from {src}") + print() + + for outfit_name, (hue, sat, light) in OUTFITS.items(): + dst = os.path.join(OUT_DIR, f"{outfit_name}.png") + print(f" {outfit_name}: hue={hue}° sat={sat:.1f}x light={light:.2f}x") + recolor_texture(src, dst, hue, sat, light) + + print() + print("Done! Texture variants ready.") + return 0 + + +if __name__ == "__main__": + exit(main())