Remove bundled Live2D demo assets

This commit is contained in:
Yun Chan 2026-06-26 15:09:32 +09:00
parent a3483067ed
commit 435cbafbc5
52 changed files with 13 additions and 39568 deletions

View file

@ -6,7 +6,7 @@
( 2),
(·· , ), "감지되되 단언되지 않게".
(§4.7): Live2D Cubism 4 + SVG/CSS/Web Audio .
(§4.7): SVG/CSS/Web Audio .
- rAF useAvatarMotion: 호흡 + + (RMS) + saccade
- 6 (persona.ts): eyelidDrop/gazeAvert/shoulderTurn/
breathRate/auraHue/blinkInterval. 8°0° ( ).
@ -14,14 +14,9 @@
- prefers-reduced-motion: 호흡/ + .
- analyser=null: .
Live2D
.model3.json ,
(persona / state / affect / analyser / rapport)
Live2DAvatar Cubism model parameter wiring . /
SVG .
===================================================================== */
import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
ageLookFor,
baseResistanceOf,
@ -41,10 +36,6 @@ import { Mouth } from "./Mouth";
Session.tsx ClientAvatar . */
export type { AvatarState, AvatarAffect, AvatarPersona } from "./persona";
const Live2DAvatar = lazy(() =>
import("./Live2DAvatar").then((mod) => ({ default: mod.Live2DAvatar })),
);
export interface ClientAvatarProps {
persona: AvatarPersona;
state: AvatarState;
@ -93,29 +84,6 @@ const STATE_TEXT: Record<AvatarState, string> = {
speaking: "이야기하는 중",
};
const DISABLED_LIVE2D_MODEL_URLS = new Set(["off", "false", "none"]);
const BUNDLED_DEMO_LIVE2D_MODEL_PATHS = new Set([
"/live2d/mao/Mao.model3.json",
"/live2d/haru/haru_greeter_t03.model3.json",
]);
function isBundledDemoLive2DModel(url: string): boolean {
try {
return BUNDLED_DEMO_LIVE2D_MODEL_PATHS.has(
new URL(url, "http://vignette.local").pathname,
);
} catch {
return false;
}
}
function resolveLive2DModelUrl(persona: AvatarPersona): string | null {
const url = persona.live2dModelUrl?.trim();
if (!url || DISABLED_LIVE2D_MODEL_URLS.has(url.toLowerCase())) return null;
if (import.meta.env.PROD && isBundledDemoLive2DModel(url)) return null;
return url;
}
export function ClientAvatar({
persona,
state,
@ -142,29 +110,6 @@ export function ClientAvatar({
() => resolveAffectParams(affect, state, effectiveRapport),
[affect, state, effectiveRapport],
);
const live2DModelUrl = useMemo(() => resolveLive2DModelUrl(persona), [persona]);
const [live2DReady, setLive2DReady] = useState(false);
const [live2DFailed, setLive2DFailed] = useState(false);
const [live2DError, setLive2DError] = useState<string | null>(null);
useEffect(() => {
setLive2DReady(false);
setLive2DFailed(false);
setLive2DError(null);
}, [live2DModelUrl]);
const handleLive2DReady = useCallback(() => {
setLive2DReady(true);
setLive2DError(null);
}, []);
const handleLive2DUnavailable = useCallback((reason: string) => {
setLive2DReady(false);
setLive2DFailed(true);
setLive2DError(reason);
}, []);
const shouldUseLive2D = Boolean(live2DModelUrl) && !live2DFailed;
// 모션 루프 (reduced 면 정지)
const frame = useAvatarMotion({
@ -198,8 +143,6 @@ export function ClientAvatar({
style={{ width: size }}
data-state={state}
data-affect={affect}
data-live2d={live2DReady ? "ready" : shouldUseLive2D ? "loading" : "off"}
data-live2d-error={live2DError ?? undefined}
data-realism={realism} /* 사실성은 데이터로만 유지(시각 표식 비노출, §4.6) */
aria-label={`교육용 가상 내담자: ${persona.label}, ${STATE_TEXT[state]}`}
>
@ -223,7 +166,7 @@ export function ClientAvatar({
/>
<svg
className={"vg-avatar__svg" + (live2DReady ? " is-live2d-covered" : "")}
className="vg-avatar__svg"
viewBox="0 0 200 200"
width={size}
height={size}
@ -258,22 +201,6 @@ export function ClientAvatar({
</g>
</svg>
{shouldUseLive2D && live2DModelUrl ? (
<Suspense fallback={null}>
<Live2DAvatar
modelUrl={live2DModelUrl}
state={state}
affect={affect}
params={params}
analyser={analyser}
speakingProgress={speakingProgress}
reduced={reduced}
size={size}
onReady={handleLive2DReady}
onUnavailable={handleLive2DUnavailable}
/>
</Suspense>
) : null}
</div>
{/* 페르소나 메타 + 상태 텍스트 */}
@ -307,9 +234,6 @@ const AVATAR_CSS = `
.vg-avatar__aura.is-reduced{animation:none;}
@keyframes vgAuraBreathe{0%,100%{opacity:.85;transform:scale(1)}50%{opacity:1;transform:scale(1.04)}}
.vg-avatar__svg{position:relative;z-index:1;display:block;transition:opacity var(--dur-base) var(--ease-out);}
.vg-avatar__svg.is-live2d-covered{opacity:0;}
.vg-avatar__live2d{position:absolute;inset:0;z-index:2;display:flex;align-items:center;justify-content:center;pointer-events:none;}
.vg-avatar__live2d-canvas{width:100%;height:100%;display:block;}
.vg-avatar__meta{display:flex;flex-direction:column;align-items:center;gap:3px;text-align:center;}
.vg-avatar__persona{font-size:var(--fs-sm);font-weight:600;color:var(--text-strong);}
.vg-avatar__state{font-size:var(--fs-xs);color:var(--text-muted);}

View file

@ -1,383 +0,0 @@
import { useEffect, useRef } from "react";
import type { AffectParams, AvatarAffect, AvatarState } from "./persona";
type PixiNamespace = typeof import("pixi.js");
type PixiApplication = import("pixi.js").Application;
type Live2DModule = typeof import("pixi-live2d-display/cubism4");
type Live2DModelCtor = Live2DModule["Live2DModel"];
type Live2DModelInstance = import("pixi-live2d-display/cubism4").Live2DModel;
interface Live2DAvatarProps {
modelUrl: string;
state: AvatarState;
affect: AvatarAffect;
params: AffectParams;
analyser: AnalyserNode | null;
speakingProgress: number | null;
reduced: boolean;
size: number;
onReady: () => void;
onUnavailable: (reason: string) => void;
}
type MutableLive2DModel = Live2DModelInstance & {
internalModel?: {
coreModel?: {
setParameterValueById?: (id: string, value: number, weight?: number) => void;
addParameterValueById?: (id: string, value: number, weight?: number) => void;
};
};
};
type Live2DRefState = Pick<
Live2DAvatarProps,
"state" | "affect" | "params" | "analyser" | "speakingProgress" | "reduced" | "size"
>;
declare global {
interface Window {
PIXI?: PixiNamespace;
Live2DCubismCore?: unknown;
}
}
const DEFAULT_CUBISM_CORE = "/live2d/live2dcubismcore.min.js";
const CORE_SCRIPT =
import.meta.env.VITE_LIVE2D_CUBISM_CORE?.trim() || DEFAULT_CUBISM_CORE;
let pixiRegistered = false;
let cubismCorePromise: Promise<void> | null = null;
let runtimePromise:
| Promise<{ PIXI: PixiNamespace; Live2DModel: Live2DModelCtor; MotionPreloadStrategy: Live2DModule["MotionPreloadStrategy"] }>
| null = null;
function registerPixi(PIXI: PixiNamespace, Live2DModel: Live2DModelCtor) {
if (pixiRegistered) return;
window.PIXI = PIXI;
Live2DModel.registerTicker(PIXI.Ticker);
pixiRegistered = true;
}
function loadLive2DRuntime() {
if (!runtimePromise) {
runtimePromise = Promise.all([
import("pixi.js"),
import("pixi-live2d-display/cubism4"),
]).then(([PIXI, live2d]) => {
registerPixi(PIXI, live2d.Live2DModel);
return {
PIXI,
Live2DModel: live2d.Live2DModel,
MotionPreloadStrategy: live2d.MotionPreloadStrategy,
};
});
}
return runtimePromise;
}
function hasCubismCore(): boolean {
return typeof window !== "undefined" && Boolean(window.Live2DCubismCore);
}
function appendScript(src: string): Promise<void> {
if (hasCubismCore()) return Promise.resolve();
const existing = Array.from(document.scripts).find(
(script) => script.dataset.vgLive2dCore === src,
);
if (existing) {
return new Promise((resolve, reject) => {
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error(`Cubism Core load failed: ${src}`)), {
once: true,
});
});
}
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.async = true;
script.dataset.vgLive2dCore = src;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Cubism Core load failed: ${src}`));
document.head.appendChild(script);
});
}
function ensureCubismCore(): Promise<void> {
if (hasCubismCore()) return Promise.resolve();
if (!cubismCorePromise) {
cubismCorePromise = (async () => {
await appendScript(CORE_SCRIPT);
if (!hasCubismCore()) throw new Error("Cubism Core did not initialize.");
})().catch((err) => {
cubismCorePromise = null;
throw err;
});
}
return cubismCorePromise;
}
function fitModel(model: Live2DModelInstance, size: number) {
model.anchor.set(0.5, 0.5);
model.scale.set(1);
const bounds = model.getLocalBounds();
const boundsWidth = Math.max(1, bounds.width);
const boundsHeight = Math.max(1, bounds.height);
const scale = Math.min((size * 0.86) / boundsWidth, (size * 0.98) / boundsHeight);
model.scale.set(scale);
model.x = size / 2;
model.y = size * 0.58;
}
function fallbackMouthTarget(tSec: number, progress: number | null): number {
if (progress !== null && progress >= 1) return 0;
const syl = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 5.5);
const jitter = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 11 + 1.3);
return Math.min(0.85, 0.18 + 0.42 * syl * jitter);
}
function mouthFromAnalyser(
analyser: AnalyserNode | null,
buffer: Float32Array<ArrayBuffer>,
previous: number,
dtSec: number,
tSec: number,
progress: number | null,
): number {
let target = fallbackMouthTarget(tSec, progress);
if (analyser) {
analyser.getFloatTimeDomainData(buffer);
let sum = 0;
for (let i = 0; i < buffer.length; i++) sum += buffer[i] * buffer[i];
const rms = Math.sqrt(sum / buffer.length);
target = rms < 0.04 ? 0 : Math.min(1, rms * 3.2);
}
const tau = 0.16;
const alpha = 1 - Math.exp(-dtSec / tau);
return previous + (target - previous) * alpha;
}
function setParam(model: MutableLive2DModel, id: string, value: number, weight = 0.85) {
try {
model.internalModel?.coreModel?.setParameterValueById?.(id, value, weight);
} catch {
/* Some models omit optional standard parameters. */
}
}
function applyLive2DParams(
model: MutableLive2DModel,
snapshot: Live2DRefState,
mouth: number,
tSec: number,
) {
const { state, affect, params, reduced } = snapshot;
if (reduced) return;
const gazeBase = state === "listening" ? -params.gazeAvert * 0.15 : -params.gazeAvert * 0.22;
const thinkingDrift = state === "thinking" ? Math.sin(tSec * 0.9) * 0.08 : 0;
const speakEnergy = state === "speaking" ? mouth : 0;
const smile = Math.max(0, params.mouthCurve);
const downturn = Math.max(0, -params.mouthCurve);
setParam(model, "ParamMouthOpenY", speakEnergy, 1);
setParam(model, "ParamA", speakEnergy, 1);
setParam(model, "ParamMouthForm", params.mouthCurve * 0.25, 0.35);
setParam(model, "ParamMouthUp", smile * 0.45, 0.25);
setParam(model, "ParamMouthDown", downturn * 0.45, 0.25);
setParam(model, "ParamMouthAngry", affect === "resistant" ? 0.55 : 0, 0.25);
setParam(model, "ParamEyeBallX", gazeBase + thinkingDrift, 0.5);
setParam(model, "ParamEyeBallY", state === "thinking" ? -0.12 : 0.03, 0.45);
setParam(model, "ParamAngleX", gazeBase * 14, 0.35);
setParam(model, "ParamAngleY", state === "thinking" ? -3 : 1, 0.3);
setParam(model, "ParamAngleZ", -params.shoulderTurn * 0.25, 0.25);
setParam(model, "ParamBodyAngleX", -params.shoulderTurn * 0.45, 0.35);
setParam(model, "ParamBreath", 0.5 + Math.sin(tSec * 2 * Math.PI / params.breathPeriod) * 0.22, 0.35);
}
function focusPointFor(state: AvatarState, params: AffectParams, size: number): [number, number] {
if (state === "thinking") return [size * 0.42, size * 0.62];
if (state === "listening") return [size * 0.56, size * 0.48];
if (state === "speaking") return [size * 0.5, size * 0.46];
return [size * (0.5 - params.gazeAvert * 0.008), size * 0.52];
}
function expressionsFor(affect: AvatarAffect): string[] {
if (affect === "depressed") return ["sad", "f02", "f03", "exp_05", "exp_06"];
if (affect === "anxious") return ["surprised", "f03", "f04", "exp_03", "exp_04"];
if (affect === "resistant") return ["angry", "f06", "f07", "exp_07", "exp_08"];
return ["normal", "f00", "f01", "exp_01", "exp_02"];
}
async function tryMotion(model: Live2DModelInstance, groups: string[]) {
for (const group of groups) {
const ok = await model.motion(group).catch(() => false);
if (ok) return;
}
}
async function tryExpression(model: Live2DModelInstance, names: string[]) {
for (const name of names) {
const ok = await model.expression(name).catch(() => false);
if (ok) return;
}
}
export function Live2DAvatar({
modelUrl,
state,
affect,
params,
analyser,
speakingProgress,
reduced,
size,
onReady,
onUnavailable,
}: Live2DAvatarProps) {
const hostRef = useRef<HTMLDivElement>(null);
const appRef = useRef<PixiApplication | null>(null);
const modelRef = useRef<MutableLive2DModel | null>(null);
const snapshotRef = useRef<Live2DRefState>({
state,
affect,
params,
analyser,
speakingProgress,
reduced,
size,
});
useEffect(() => {
snapshotRef.current = { state, affect, params, analyser, speakingProgress, reduced, size };
}, [state, affect, params, analyser, speakingProgress, reduced, size]);
useEffect(() => {
const model = modelRef.current;
if (!model) return;
model.autoUpdate = !reduced;
}, [reduced]);
useEffect(() => {
const model = modelRef.current;
if (!model || reduced) return;
const [x, y] = focusPointFor(state, params, size);
model.focus(x, y);
void tryExpression(model, expressionsFor(affect));
if (state === "speaking") void tryMotion(model, ["Speak", "Speaking", "TapBody"]);
else if (state === "thinking") void tryMotion(model, ["Think", "Thinking"]);
else if (state === "idle") void tryMotion(model, ["Idle", "idle"]);
}, [affect, params, reduced, size, state]);
useEffect(() => {
const app = appRef.current;
const model = modelRef.current;
if (!app || !model) return;
app.renderer.resize(size, size);
fitModel(model, size);
}, [size]);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
let cancelled = false;
let mouth = 0;
let lastT = performance.now();
const t0 = lastT;
const audioBuffer: Float32Array<ArrayBuffer> = new Float32Array(1024);
const tick = () => {
const model = modelRef.current;
if (!model) return;
const now = performance.now();
const dtSec = Math.min(0.05, (now - lastT) / 1000);
const tSec = (now - t0) / 1000;
lastT = now;
const snapshot = snapshotRef.current;
if (snapshot.state === "speaking") {
mouth = mouthFromAnalyser(
snapshot.analyser,
audioBuffer,
mouth,
dtSec,
tSec,
snapshot.speakingProgress,
);
} else if (mouth > 0.001) {
mouth = mouth * Math.exp(-dtSec / 0.1);
if (mouth < 0.005) mouth = 0;
}
applyLive2DParams(model, snapshot, mouth, tSec);
};
(async () => {
try {
await ensureCubismCore();
const runtime = await loadLive2DRuntime();
if (cancelled) return;
const app = new runtime.PIXI.Application({
width: size,
height: size,
antialias: true,
autoDensity: true,
backgroundAlpha: 0,
resolution: Math.min(window.devicePixelRatio || 1, 2),
});
appRef.current = app;
const canvas = app.view as HTMLCanvasElement;
canvas.className = "vg-avatar__live2d-canvas";
canvas.setAttribute("aria-hidden", "true");
host.appendChild(canvas);
const model = (await runtime.Live2DModel.from(modelUrl, {
autoInteract: false,
autoUpdate: !snapshotRef.current.reduced,
motionPreload: runtime.MotionPreloadStrategy.IDLE,
})) as MutableLive2DModel;
if (cancelled) {
model.destroy({ children: true, texture: true, baseTexture: true });
app.destroy(true, { children: true, texture: true, baseTexture: true });
return;
}
modelRef.current = model;
fitModel(model, size);
app.stage.addChild(model);
app.ticker.add(tick);
void tryMotion(model, ["Idle", "idle"]);
onReady();
} catch (err) {
if (!cancelled) {
const reason = err instanceof Error ? err.message : "Live2D model load failed.";
onUnavailable(reason);
}
}
})();
return () => {
cancelled = true;
const app = appRef.current;
const model = modelRef.current;
if (app) app.ticker.remove(tick);
modelRef.current = null;
appRef.current = null;
if (model && !model.destroyed) {
model.destroy({ children: true, texture: true, baseTexture: true });
}
if (app) {
app.destroy(true, { children: true, texture: true, baseTexture: true });
}
host.replaceChildren();
};
}, [modelUrl, onReady, onUnavailable, size]);
return <div className="vg-avatar__live2d" ref={hostRef} aria-hidden="true" />;
}

View file

@ -34,8 +34,6 @@ export interface AvatarPersona {
* affect="resistant" 0.6, 0 .
*/
resistance?: number;
/** Optional persona-specific Live2D Cubism 3/4 model3.json URL. Omit for SVG fallback. */
live2dModelUrl?: string | null;
}
/* 6 (§4.5)