From f2ff91730b63fcf4a67a305b5c491ff17e87ef74 Mon Sep 17 00:00:00 2001 From: hobokenchicken Date: Fri, 5 Jun 2026 09:43:12 -0400 Subject: [PATCH] fix(avatar): use ResizeObserver for accurate container sizing; force canvas CSS 100%; reduce margin to 68% Problem: flex layout wasn't ready on first paint, so clientWidth fell back to 400px. Canvas was 400px wide but parent was only 288px, causing the avatar to be clipped on the right. Fix: ResizeObserver measures real laid-out size before init. Canvas forced to width/height 100% via CSS so it never overflows. Model scaled to 68% with centered anchor. Resize handled dynamically. --- frontend/src/components/KiraAvatar.tsx | 74 +++++++++++++++++--------- 1 file changed, 49 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/KiraAvatar.tsx b/frontend/src/components/KiraAvatar.tsx index 530b09e..90bf3a4 100644 --- a/frontend/src/components/KiraAvatar.tsx +++ b/frontend/src/components/KiraAvatar.tsx @@ -39,49 +39,55 @@ export default function KiraAvatar(props: Props) { const container = canvasRef.current; if (!container) return; - (async () => { + let app: any = null; + let model: any = null; + + const init = async () => { try { const resp = await fetch('/live2d/models/kira/kira.model3.json', { method: 'HEAD' }); - if (!resp.ok) { - if (mounted) setLoadError(true); - return; - } + if (!resp.ok) { if (mounted) setLoadError(true); return; } await loadScript('/live2d/cubism/live2dcubismcore.min.js'); const { Application, Ticker } = await import('pixi.js'); const { Live2DModel } = await import('pixi-live2d-display/cubism4'); - (Live2DModel as any).registerTicker(Ticker as any); - const containerW = container.clientWidth || 400; - const containerH = container.clientHeight || 400; - const app = new Application({ - width: containerW, - height: containerH, + // Measure real container size (flex layout may not be ready on first paint) + const rect = container.getBoundingClientRect(); + const w = Math.max(Math.round(rect.width), 260); + const h = Math.max(Math.round(rect.height), 260); + + app = new Application({ + width: w, + height: h, antialias: true, resolution: Math.min(window.devicePixelRatio || 1, 2), backgroundAlpha: 0, + autoDensity: true, }); - appRef.current = app; if (!mounted) { app.destroy(true); return; } - container.appendChild(app.view as HTMLCanvasElement); + // Force canvas to fill container via CSS so it never overflows + const canvas = app.view as HTMLCanvasElement; + canvas.style.width = '100%'; + canvas.style.height = '100%'; + canvas.style.display = 'block'; + container.appendChild(canvas); - const model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', { + model = await Live2DModel.from('/live2d/models/kira/kira.model3.json', { autoInteract: false, }); modelRef.current = model; - // Fit model to fill the canvas — leave generous margin to avoid clipping - const maxW = containerW * 0.72; - const maxH = containerH * 0.72; + // Fit model with generous margin to avoid clipping + const maxW = w * 0.68; + const maxH = h * 0.68; const scale = Math.min(maxW / model.width, maxH / model.height); model.scale.set(scale); - model.anchor.set(0.5, 0.52); - model.position.set(app.screen.width / 2, app.screen.height / 2 + 6); + model.anchor.set(0.5, 0.5); + model.position.set(app.screen.width / 2, app.screen.height / 2); app.stage.addChild(model as any); - (model as any).isInteractive = () => false; try { @@ -107,16 +113,34 @@ export default function KiraAvatar(props: Props) { console.warn('[Live2D]', e); if (mounted) setLoadError(true); } - })(); + }; + + // Use ResizeObserver so we init with the real laid-out size + const ro = new ResizeObserver((entries) => { + if (app) { + // Already init'd — handle resize + const cr = entries[0].contentRect; + app.renderer.resize(Math.round(cr.width), Math.round(cr.height)); + if (model) { + const maxW = cr.width * 0.68; + const maxH = cr.height * 0.68; + const s = Math.min(maxW / model.width, maxH / model.height); + model.scale.set(s); + model.position.set(app.screen.width / 2, app.screen.height / 2); + } + return; + } + // First measurement — run init + init(); + }); + ro.observe(container); return () => { mounted = false; + ro.disconnect(); cancelAnimationFrame(lipSyncRef.current); clearInterval(idleExprRef.current ?? undefined); - if (appRef.current) { - appRef.current.destroy(true, { children: true }); - appRef.current = null; - } + if (app) { app.destroy(true, { children: true }); } modelRef.current = null; textureRef.current = null; };