Stabilize runtime auth and E2E coverage
This commit is contained in:
parent
6a3e3b541c
commit
188e899394
133 changed files with 55987 additions and 6775 deletions
|
|
@ -6,22 +6,22 @@
|
|||
머무는 단 하나의 오브젝트 → 산만하지 않게(동시 채널 ≤2), 실존 인물로 오인되지
|
||||
않게(코·주름·모공 없음, 상단 라벨 상시), 정서는 "감지되되 단언되지 않게".
|
||||
|
||||
기술(§4.7): SVG + CSS/Web Animations + Web Audio (1차안).
|
||||
기술(§4.7): Live2D Cubism 4 런타임 + SVG/CSS/Web Audio 폴백.
|
||||
- 단일 rAF 루프 useAvatarMotion: 호흡 + 깜빡임 + 립싱크(RMS) + saccade
|
||||
- 6파라미터 정서 상태머신(persona.ts): eyelidDrop/gazeAvert/shoulderTurn/
|
||||
breathRate/auraHue/blinkInterval. 라포 상승 시 저항 8°→0° 서서히(교육 핵심).
|
||||
- transform/opacity 만 애니메이션(layout 금지). 동시 움직임 ≤2 채널.
|
||||
- prefers-reduced-motion: 호흡/광배 정지 + 상태 텍스트.
|
||||
- analyser=null: 텍스트 길이/타이핑 기반 가짜 립싱크 폴백.
|
||||
- analyser=null: 음성 분석이 없으면 립싱크 없이 상태 모션만 유지.
|
||||
|
||||
── Rive 2차 교체 지점 ─────────────────────────────────────────────
|
||||
외부 .riv 아트가 준비되면, 이 컴포넌트의 입력 계약
|
||||
── Live2D 교체 지점 ─────────────────────────────────────────────
|
||||
외부 .model3.json 아트가 준비되면, 이 컴포넌트의 입력 계약
|
||||
(persona / state / affect / analyser / rapport) 은 그대로 두고
|
||||
내부의 <svg>+useAvatarMotion 묶음만 <RiveAvatar> 로 교체한다.
|
||||
AffectParams(persona.ts) → .riv state-machine input 으로 wiring.
|
||||
Live2DAvatar 가 Cubism model parameter 로 wiring 한다. 모델/코어가 없거나
|
||||
로딩 실패하면 SVG 폴백이 그대로 남는다.
|
||||
===================================================================== */
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ageLookFor,
|
||||
baseResistanceOf,
|
||||
|
|
@ -41,6 +41,10 @@ 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;
|
||||
|
|
@ -53,7 +57,7 @@ export interface ClientAvatarProps {
|
|||
*/
|
||||
rapport?: number;
|
||||
/**
|
||||
* analyser 없을 때 가짜 립싱크용 타이핑 진행도 0~1.
|
||||
* analyser 없을 때 선택적으로 전달하는 타이핑 진행도 0~1.
|
||||
* null/미지정이면 speaking 동안 차분한 의사 발화 모션.
|
||||
*/
|
||||
speakingProgress?: number | null;
|
||||
|
|
@ -89,6 +93,29 @@ 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,
|
||||
|
|
@ -115,6 +142,29 @@ 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({
|
||||
|
|
@ -148,6 +198,8 @@ 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]}`}
|
||||
>
|
||||
|
|
@ -171,7 +223,7 @@ export function ClientAvatar({
|
|||
/>
|
||||
|
||||
<svg
|
||||
className="vg-avatar__svg"
|
||||
className={"vg-avatar__svg" + (live2DReady ? " is-live2d-covered" : "")}
|
||||
viewBox="0 0 200 200"
|
||||
width={size}
|
||||
height={size}
|
||||
|
|
@ -205,6 +257,23 @@ export function ClientAvatar({
|
|||
</g>
|
||||
</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>
|
||||
|
||||
{/* 페르소나 메타 + 상태 텍스트 */}
|
||||
|
|
@ -237,7 +306,10 @@ 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;}
|
||||
.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);}
|
||||
|
|
|
|||
383
apps/web/src/components/avatar/Live2DAvatar.tsx
Normal file
383
apps/web/src/components/avatar/Live2DAvatar.tsx
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
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" />;
|
||||
}
|
||||
|
|
@ -34,6 +34,8 @@ 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) ──────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -59,10 +59,10 @@ function smoothMouthFromRMS(
|
|||
return prev + (target - prev) * alpha;
|
||||
}
|
||||
|
||||
/* ── analyser 없을 때 가짜 립싱크 (§ 명세: 텍스트/타이핑 기반 폴백) ──────
|
||||
외부에서 speakingProgress(0~1, 발화 타이핑 진행) 를 주면 그걸 따라가고,
|
||||
없으면 음절감 있는 의사 RMS 를 합성한다(과하지 않게). */
|
||||
function fakeMouthTarget(tSec: number, progress: number | null): number {
|
||||
/* ── analyser 없을 때의 립싱크 폴백 ─────────────────────────────────────
|
||||
외부에서 speakingProgress(0~1, 발화 타이핑 진행)를 명시적으로 줄 때만
|
||||
낮은 진폭으로 움직인다. 실제 음성 분석이 없으면 합성 발화처럼 보이지 않게 닫아 둔다. */
|
||||
function fallbackMouthTarget(tSec: number, progress: number | null): number {
|
||||
if (progress !== null) {
|
||||
// 타이핑 진행 중에만 입을 움직임. 진행이 멈추면(완료) 닫힘.
|
||||
if (progress >= 1) return 0;
|
||||
|
|
@ -72,17 +72,14 @@ function fakeMouthTarget(tSec: number, progress: number | null): number {
|
|||
const base = 0.18 + 0.42 * syl * jitter;
|
||||
return Math.min(0.85, base);
|
||||
}
|
||||
// progress 미제공: 차분한 의사 발화 (말하는 듯한 진폭)
|
||||
const a = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 4.5);
|
||||
const b = 0.5 + 0.5 * Math.sin(tSec * 2 * Math.PI * 9.2 + 0.7);
|
||||
return Math.min(0.8, 0.15 + 0.45 * a * b);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export interface AvatarMotionOptions {
|
||||
state: AvatarState;
|
||||
params: AffectParams;
|
||||
analyser: AnalyserNode | null;
|
||||
/** analyser 없을 때 가짜 립싱크용 타이핑 진행도 0~1 (null=의사 발화) */
|
||||
/** analyser 없을 때 선택적으로 쓰는 타이핑 진행도 0~1 */
|
||||
speakingProgress?: number | null;
|
||||
/** false 면 루프 정지(reduced-motion) — IDLE_FRAME 고정 */
|
||||
enabled: boolean;
|
||||
|
|
@ -188,14 +185,14 @@ export function useAvatarMotion({
|
|||
}
|
||||
}
|
||||
|
||||
// ── 4) 립싱크 (speaking 시만; analyser 우선, 없으면 폴백) ──
|
||||
// ── 4) 립싱크 (speaking 시만; analyser 우선, 없으면 정적 폴백) ──
|
||||
let mouth = mouthPrev;
|
||||
if (st === "speaking") {
|
||||
const a = analyserRef.current;
|
||||
if (a) {
|
||||
mouthPrev = smoothMouthFromRMS(a, audioBuf, mouthPrev, dtSec);
|
||||
} else {
|
||||
const target = fakeMouthTarget(tSec, progressRef.current ?? null);
|
||||
const target = fallbackMouthTarget(tSec, progressRef.current ?? null);
|
||||
const tau = 0.12;
|
||||
const alpha = 1 - Math.exp(-dtSec / tau);
|
||||
mouthPrev = mouthPrev + (target - mouthPrev) * alpha;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ export interface AppShellProps {
|
|||
hideNav?: boolean;
|
||||
/** 메인 패딩·최대폭 제거 (풀-블리드 레이아웃) */
|
||||
bleed?: boolean;
|
||||
/** 톱바까지 제거하는 실제 전체화면 작업 공간 */
|
||||
hideTopbar?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -18,13 +20,13 @@ export interface AppShellProps {
|
|||
* body[data-role] 은 AuthProvider 가 관리(여기선 셸 골격만).
|
||||
* 미인증 시에도 안전하게 렌더(네비 없이) — 가드는 라우터(RequireAuth)가 담당.
|
||||
*/
|
||||
export function AppShell({ children, contextLabel, hideNav, bleed }: AppShellProps) {
|
||||
export function AppShell({ children, contextLabel, hideNav, bleed, hideTopbar }: AppShellProps) {
|
||||
const { user } = useAuth();
|
||||
const showNav = !hideNav && !!user;
|
||||
|
||||
return (
|
||||
<div className="vg-shell">
|
||||
<Topbar contextLabel={contextLabel} />
|
||||
<div className={"vg-shell" + (hideTopbar ? " vg-shell--fullscreen" : "")}>
|
||||
{hideTopbar ? null : <Topbar contextLabel={contextLabel} />}
|
||||
<div className={"vg-shell__body" + (showNav ? "" : " vg-shell__body--bare")}>
|
||||
{showNav ? <Sidebar role={user.role} /> : null}
|
||||
<main className={"vg-main" + (bleed ? " vg-main--bleed" : "")}>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
min-height: 100vh;
|
||||
background: var(--bg-app);
|
||||
}
|
||||
.vg-shell--fullscreen {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* ── 톱바 ── */
|
||||
.vg-topbar {
|
||||
|
|
@ -42,7 +45,7 @@
|
|||
.vg-topbar__wm {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
.vg-topbar__wm .v {
|
||||
|
|
@ -219,6 +222,9 @@
|
|||
.vg-shell__body {
|
||||
grid-template-columns: var(--nav-w-collapsed) 1fr;
|
||||
}
|
||||
.vg-shell__body--bare {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.vg-nav {
|
||||
padding: var(--sp-4) var(--sp-2);
|
||||
}
|
||||
|
|
@ -233,6 +239,49 @@
|
|||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
html {
|
||||
scroll-padding-top: calc(var(--topbar-h) + 68px);
|
||||
scroll-padding-bottom: var(--sp-6);
|
||||
}
|
||||
.vg-shell__body {
|
||||
display: block;
|
||||
}
|
||||
.vg-shell__body--bare {
|
||||
display: block;
|
||||
}
|
||||
.vg-nav {
|
||||
position: sticky;
|
||||
top: var(--topbar-h);
|
||||
z-index: 29;
|
||||
height: 58px;
|
||||
padding: 7px max(12px, env(safe-area-inset-left)) 7px max(12px, env(safe-area-inset-right));
|
||||
border-right: 0;
|
||||
border-top: 0;
|
||||
border-bottom: 1px solid var(--hair);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.vg-nav__label,
|
||||
.vg-nav__spacer,
|
||||
.vg-nav__foot {
|
||||
display: none;
|
||||
}
|
||||
.vg-nav__item {
|
||||
min-width: 112px;
|
||||
height: 44px;
|
||||
justify-content: center;
|
||||
padding: 0 14px;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.vg-nav__item span:not(.vg-nav__ic) {
|
||||
display: inline;
|
||||
font-size: 13px;
|
||||
}
|
||||
.vg-topbar__role {
|
||||
display: none;
|
||||
}
|
||||
|
|
@ -242,4 +291,7 @@
|
|||
.vg-main {
|
||||
padding: var(--sp-5) var(--sp-4) var(--sp-7);
|
||||
}
|
||||
.vg-main--bleed {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useId } from "react";
|
||||
import { forwardRef, useId } from "react";
|
||||
import type { InputHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
|
|
@ -6,16 +6,20 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|||
}
|
||||
|
||||
/** Input — radius 6px, 포커스 보더색 + ring. 좌측바 금지. §7.2 */
|
||||
export function Input({ invalid, className, ...rest }: InputProps) {
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ invalid, className, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const cls = ["vg-input", className ?? ""].filter(Boolean).join(" ");
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cls}
|
||||
aria-invalid={invalid ? "true" : undefined}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export interface FieldProps {
|
||||
/** 라벨 텍스트 */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { clamp01 } from "../../lib/format";
|
||||
|
||||
export type ProgressTone = "accent" | "muted" | "warn" | "clay";
|
||||
export type ProgressTone = "accent" | "muted" | "warn" | "crit" | "clay";
|
||||
|
||||
export interface ProgressBarProps {
|
||||
/** 0~1 비율 (또는 value/max) */
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
.vg-sechead__title {
|
||||
font-size: var(--fs-h2);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.015em;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-strong);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
|
@ -228,7 +228,7 @@
|
|||
font-weight: 700;
|
||||
color: var(--text-strong);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.vg-statline__lab {
|
||||
font-size: var(--fs-xs);
|
||||
|
|
@ -263,6 +263,9 @@
|
|||
.vg-progress__fill--warn {
|
||||
background: var(--warn-solid);
|
||||
}
|
||||
.vg-progress__fill--crit {
|
||||
background: var(--crit-solid);
|
||||
}
|
||||
.vg-progress__fill--clay {
|
||||
background: var(--clay);
|
||||
}
|
||||
|
|
@ -321,7 +324,7 @@
|
|||
.vg-empty__title {
|
||||
font-size: var(--fs-h1);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-strong);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue