Stabilize runtime auth and E2E coverage

This commit is contained in:
Yun Chan 2026-06-26 14:47:00 +09:00
parent 6a3e3b541c
commit 188e899394
133 changed files with 55987 additions and 6775 deletions

View file

@ -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);}