/* avatar_core.js — talking-drawing-avatar 리포 static/avatar_core.js 의 vendored 사본.
* 여기서 수정하지 말 것 — 원본에서 고친 뒤 아래로 갱신한다:
* ./scripts/sync_avatar_core.sh (이 헤더는 보존하고 본문만 갱신)
* ./scripts/sync_avatar_core.sh --check (뒤처졌는지 확인 — CI 가 매번 돌린다)
* 이 리포에서는 index.html(미러링 스튜디오)이 소비한다.
*/
window.AvatarCore = (() => {
// ---------- 로케일 (미러링 UI 문자열) ----------
// 코어를 공유하는 두 리포의 언어가 다르다: ~/face 는 한국어(국내용), drawface-live 는 영어(HF Space 공개).
// 페이지가 팩토리 호출 전에 AvatarCore.setLocale("en") 을 한 번 부른다. 기본값은 "ko".
// 화면에 뜨는 문자열만 여기 모은다 — EMO_RULES 정규식·쇼케이스 대사의 한국어는 UI 가 아니라
// 한국어 입력을 매칭·재생하는 데이터라 로케일과 무관하게 그대로 둔다.
const STRINGS = {
ko: {
mirrorLoading: "미러링 모델 로드 중…",
mirrorCalib: "캘리브레이션 — 정면·무표정으로 잠시 계세요",
mirrorCalibCount: n => `정면을 보고 무표정을 유지해주세요… ${n}/30`,
mirrorOn: "미러링 중 — 캐릭터가 따라합니다 (버튼으로 종료)",
panelTitle: "내 얼굴 — 트래킹 미리보기",
panelIdle: "미러링을 시작하면 표시됩니다",
radar: { mouth: "입", smile: "미소", pucker: "오므림", eye: "눈", brow: "눈썹" },
},
en: {
mirrorLoading: "Loading mirroring model…",
mirrorCalib: "Calibrating — hold a neutral, front-facing pose",
mirrorCalibCount: n => `Look straight ahead and keep a neutral face… ${n}/30`,
mirrorOn: "Mirroring — your character follows you (button to stop)",
panelTitle: "Your face — tracking preview",
panelIdle: "Appears once mirroring starts",
radar: { mouth: "Mouth", smile: "Smile", pucker: "Pucker", eye: "Eyes", brow: "Brows" },
},
};
let locale = "ko";
const T = () => STRINGS[locale]; // 호출 시점에 읽는다 — setLocale 이 늦게 와도 반영된다
function setLocale(loc) { locale = STRINGS[loc] ? loc : "ko"; }
// ---------- 내부 유틸 ----------
const norm = s => s.toLowerCase().replace(/[_\-\s]/g, "");
const clamp01 = v => Math.min(1, Math.max(0, v)); // 0~1 로 자르기
const avgLR = (W, base) => (W(base + "left") + W(base + "right")) / 2; // 좌우 채널 평균
const roundness = W => Math.max(W("mouthpucker"), W("mouthfunnel")); // 오므림 세기
const WARP_JAW_G = Math.exp(-(38 * 38) / (2 * 55 * 55)); // jaw 변위장을 입 앵커(38px 위)에서 평가한 가우시안 (시그마 55 = 워프 셰이더와 동일)
// 텍스트 감정 추론 (발화 시 자동 프리셋 — 세 페이지 동일 규칙)
// 감정 사전: [정규식, 가중치]. 어간 문자클래스로 활용형을 함께 잡는다(슬프/슬퍼/슬펐/슬픈…).
// 순서가 아니라 점수 합으로 뽑으므로 "ㅋㅋ 대박 웃겨"(joy 4.0 vs surprise 1.6)처럼 겹쳐도 옳게 갈린다.
const EMO_RULES = {
joy: [
[/ㅋ{2,}|ㅎ{2,}/g, 2.2], [/하하|호호|ㅍㅎ|웃[겨긴음었]/g, 1.8],
[/신[나난났]|행복|기[쁘뻐뻤쁜]|즐거|좋[아다은네았]|최고|사랑|반[가갑]|재[밌미]|고마[워웠운]|감사|😊|😄|🎉|👍|❤/g, 1.4],
[/축하|성공|해냈|굿|짱/g, 1.2], [/!/g, 0.35],
],
sad: [
[/[ㅠㅜ]{2,}/g, 2.2],
[/슬[프퍼펐픈]|우울|눈물|울[고었]|외로|쓸쓸|속상|서운|아쉽|안타깝|그립|😢|😭/g, 1.6],
[/힘[들듦드]|아[파프픈]|지[친쳐쳤]|미안|죄송|망했|실패|포기/g, 1.2], [/\.{3,}/g, 0.4],
],
angry: [
[/화[나난가났]|짜증|열받|빡[쳐치친]|분노|억울|싫[어다은]|그만해|😠|😡/g, 1.8],
[/최악|엉망|어이없|말도 안|참[나내]/g, 1.4],
],
surprise: [
[/헉|깜짝|놀[라랐랍]|대박|세상에|어머|웬일|믿[을기]\s*수\s*없|😲|😮/g, 1.6],
[/[?!]{2,}/g, 1.0], [/진짜\?|정말\?|\?/g, 0.3],
],
fear: [
[/무[서섭섰]|겁[나난났이]|소름|섬뜩|오싹|공포|끔찍|😱|😨/g, 1.8],
[/떨[려린렸]|불안|어떡해|어쩌지|살려/g, 1.2],
],
shy: [
[/부끄|쑥스|민망|수줍|창피|😳|☺️/g, 1.8],
[/어머나|아이참|헤헤|히히/g, 1.0],
],
};
// 부정 표현 — "안 좋아", "좋지 않아", "재미없어"는 기쁨이 아니다.
const NEGATION = /안\s*(좋|기쁘|행복|즐거|반가)|(좋|기쁘|행복|즐겁)지\s*(않|못)|재미\s*없|별로|싫증/;
/** 텍스트 → {emo, intensity} | null. 모델 없이 점수 사전으로 추론(정적 데모에서도 동작). */
function inferEmotion(text) {
const score = {};
for (const emo in EMO_RULES) score[emo] = 0; // 사전에 감정 추가 시 자동 반영
for (const emo in EMO_RULES) {
for (const [re, w] of EMO_RULES[emo]) {
const m = text.match(re);
if (m) score[emo] += w * Math.min(m.length, 3); // 반복 강조는 3회까지만 가산
}
}
if (NEGATION.test(text)) { score.joy = 0; score.sad += 1.0; }
let best = null, top = 0;
for (const k in score) if (score[k] > top) { top = score[k]; best = k; }
if (!best || top < 1.0) return null; // 신호가 약하면 중립 유지
return { emo: best, intensity: Math.min(1, 0.45 + top * 0.16) }; // 0.45~1.0
}
// ---------- LLM 감정 분류 (규칙 매칭의 상위 경로) ----------
// inferEmotion 정규식은 한국어 10문장 벤치에서 1/10 이었다 — 상주 LLM 에 물어본다.
// 실패·타임아웃·503 은 전부 null 로 접어서 호출측이 규칙으로 떨어지게 한다.
async function classifyEmotion(text, { timeoutMs = 4000 } = {}) {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeoutMs);
try {
const res = await fetch("/api/emotion", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }), signal: ctl.signal,
});
if (!res.ok) return null;
const segs = (await res.json()).segments;
return Array.isArray(segs) && segs.length ? segs : null;
} catch {
return null; // 오프라인·중단·파싱 실패 — 규칙 폴백
} finally {
clearTimeout(timer);
}
}
// ---------- 감정 → 목소리 톤 (얼굴만 웃고 목소리는 무표정한 괴리 해소) ----------
// 비율값: rate=속도, pitch=음높이, volume=크기. edge-tts("+10%"/"+14Hz")와 브라우저 TTS(배수) 양쪽에서 사용.
const VOICE_STYLE = {
joy: { rate: 0.10, pitch: 0.16 },
sad: { rate: -0.12, pitch: -0.12 },
angry: { rate: 0.06, pitch: -0.06, volume: 0.15 },
surprise: { rate: 0.08, pitch: 0.22 },
fear: { rate: 0.10, pitch: 0.10, volume: -0.10 }, // 빠르고 떨리는 작은 소리
shy: { rate: -0.06, pitch: 0.06, volume: -0.15 }, // 작고 조심스럽게
neutral: {},
};
function voiceProsody(emo, intensity = 1) {
const s = VOICE_STYLE[emo] || VOICE_STYLE.neutral;
return { rate: (s.rate || 0) * intensity, pitch: (s.pitch || 0) * intensity,
volume: (s.volume || 0) * intensity };
}
// 지수 평활 (무할당, in-place). target 에만 있는 새 키는 0에서 출발.
function smoothStep(smooth, target) {
for (const k in target) if (!(k in smooth)) smooth[k] = 0;
for (const k in smooth) smooth[k] = 0.42 * smooth[k] + 0.58 * (target[k] || 0);
}
// 재생 중 발화 프레임 → 채널 가중치 (puppet·studio3d; docs 는 브라우저 TTS 타임라인이라 자체 구현)
function weightsFromAnim(anim, audio) {
const w = {};
if (anim && !audio.paused && !audio.ended) {
const f = anim.frames[Math.min(Math.floor(audio.currentTime * anim.fps), anim.frames.length - 1)];
for (const [key, col] of anim.index) w[key] = f[col];
}
return w;
}
// ---------- 엔진 출력 셰이핑 (발화당 1회) ----------
// A2F·NeuroSync 둘 다 턱을 거의 안 벌리고(jawOpen 최대 0.31~0.40 실측) 발화 내내 켜져 있는
// 편향 채널이 있다(A2F jawRight 평균 0.17, NeuroSync browInnerUp 0.145). 엔진 쪽에는 세기
// 손잡이가 없어서(A2F model.json 은 경로 설정뿐) 받은 프레임 전체를 여기서 정규화한다.
const SHAPE = {
a2f: { gain: { jawopen: 2.3 }, kill: ["jawright", "jawleft"] },
// browInnerUp·browOuterUp·eyeWide 는 NeuroSync가 발화 내내 놀란 표정을 상시 띄우는
// 원인(상시 켜짐이 아니라 높은 값 주변에서 진동) — 게인<1로 눌러야 목표(평균<=0.05)에 닿는다.
// 0.35/0.25 는 채널 프로브 실측(문장1, 자음·무음 섞인 발화)에서 평균이 0.047~0.060 으로
// 목표(10% 마진 0.0455)에 걸쳐 있었다 — 재현 시 FAIL 이 나올 수 있는 수준이라 더 눌렀다.
// worst-case(brow 0.060, eyewide 0.050) 기준 0.035 목표(마진선보다 23%↓, TTS 합성 변동
// 여유분)로 역산: brow 0.35*(0.035/0.060)=0.204→0.20, eyewide 0.25*(0.035/0.050)=0.175→0.18.
// 감정 프리셋(EMOTIONS.sad/surprise/fear 의 brow*/eyewide* 값 0.6~0.85)은 applyMax 로
// 별도 max-결합되므로 이 게인과 무관하게 그대로 유지된다(makeEmotion.applyMax 참고).
neurosync: { gain: { jawopen: 2.8, mouthfunnel: 0.7, browinnerup: 0.20, browouterupleft: 0.20,
browouterupright: 0.20, eyewideleft: 0.18, eyewideright: 0.18 }, kill: [] },
};
const BASELINE_PCT = 10; // 채널별 하위 백분위 = "상시 켜져 있는" 성분
function shapeAnim(anim, engine) {
const prof = SHAPE[engine];
// 이번 발화에 실제로 적용한 jawopen 게인을 기록 — 3D 모프용 증폭이라 2D(퍼펫)의 입 크기·
// 몸짓 임계값처럼 원래(증폭 전) 스케일에 맞춰진 소비자가 이걸 나눠 되돌리는 용도.
// 엔진 미상/증폭 없음이면 1(무보정).
if (anim) anim.jawGain = (prof && prof.gain.jawopen) || 1;
if (!prof || !anim || !anim.frames || !anim.frames.length || !anim.index) return anim;
const kill = new Set(prof.kill);
for (const [name, col] of anim.index) {
if (kill.has(name)) {
for (const f of anim.frames) f[col] = 0;
continue;
}
const vals = anim.frames.map(f => f[col]); // 스냅샷 — 아래서 f[col] 을 덮어써도 원본값 기준으로 계산
const base = vals.slice().sort((a, b) => a - b)[Math.floor((vals.length - 1) * BASELINE_PCT / 100)];
const gain = prof.gain[name] || 1;
if (!base && gain === 1) continue; // 손댈 것 없는 채널은 건너뛴다
anim.frames.forEach((f, i) => {
f[col] = clamp01((vals[i] - base) * gain);
});
}
return anim;
}
// ---------- 감정 프리셋 (studio3d 버전이 superset 이라 그것으로 통합) ----------
const EMOTIONS = {
neutral: {},
// eyesquint 가 0.25 이던 시절엔 눈꺼풀이 14%만 닫혀 눈웃음이 보이지 않았다 —
// 진짜 웃음(Duchenne)의 핵심 채널이라 입꼬리에 준하는 값을 준다.
joy: { mouthsmileleft: 0.55, mouthsmileright: 0.55, cheeksquintleft: 0.45, cheeksquintright: 0.45, eyesquintleft: 0.5, eyesquintright: 0.5 },
sad: { mouthfrownleft: 0.5, mouthfrownright: 0.5, browinnerup: 0.7, mouthshrugupper: 0.2 },
angry: { browdownleft: 0.85, browdownright: 0.85, nosesneerleft: 0.4, nosesneerright: 0.4, mouthpressleft: 0.4, mouthpressright: 0.4, jawforward: 0.25 },
surprise: { browinnerup: 0.6, browouterupleft: 0.75, browouterupright: 0.75, eyewideleft: 0.8, eyewideright: 0.8, jawopen: 0.3 },
fear: { eyewideleft: 0.7, eyewideright: 0.7, browinnerup: 0.85, mouthstretchleft: 0.35, mouthstretchright: 0.35, jawopen: 0.12 },
// shy 의 eyelookdown 은 makeGaze 채널 결합을 타고 눈동자도 실제로 내려간다.
shy: { mouthsmileleft: 0.3, mouthsmileright: 0.3, eyelookdownleft: 0.55, eyelookdownright: 0.55, mouthpressleft: 0.25, mouthpressright: 0.25 },
};
const CROSSFADE_S = 0.25; // 문장 경계 표정 전환 시간
// 감정 상태 + 버튼 배선. buttons/activeColor 는 페이지가 주입(2D #5b8cff / 3D #76b900).
function makeEmotion(buttons, activeColor) {
let emotion = EMOTIONS.neutral;
let sticky = true; // 수동 버튼=유지, 자동(발화 감정)=발화 끝나면 중립 복귀
let hold = 1; // 감정 세기 게이트(0~1). 자동 감정은 유휴 중 감쇠.
// intensity: 자동 추론 시 감정 세기(0~1)로 프리셋 값을 스케일. 버튼 클릭은 항상 1.
// 항상 새 객체로 스케일 — 공유 EMOTIONS 프리셋 앨리어싱 회피(v*1===v 라 무손실).
// isSticky=false(자동 발화 감정)면 발화가 끝난 뒤 표정이 얼어붙지 않고 천천히 풀린다.
let curKey = "neutral", curInt = 1; // 몸짓 연동용 현재 감정 (current() 로 노출)
let trackSeg = null, fadeFrom = {}; // 문장별 전환용 크로스페이드 상태
function setEmotion(key, intensity = 1, isSticky = true) {
const base = EMOTIONS[key] || EMOTIONS.neutral;
emotion = {};
for (const k in base) emotion[k] = base[k] * intensity;
sticky = isSticky; hold = 1;
curKey = key; curInt = intensity;
buttons.forEach(x => x.style.background = x.dataset.emo === key ? activeColor : "#2a2a35");
}
buttons.forEach(b => { b.onclick = () => setEmotion(b.dataset.emo); }); // 버튼은 sticky 기본
return {
setEmotion,
// 발화 중 문장이 바뀌면 표정도 바뀐다. track=[{start, emo, intensity}], tSec=audio.currentTime.
// setEmotion 을 쓰지 않고 직접 섞는 이유: setEmotion 은 새 프리셋으로 통째 교체라
// 경계에서 이전 표정이 한 프레임에 사라진다(그게 바로 없애려는 팝이다).
// 문장이 바뀌는 순간의 표정을 박제해 두고 CROSSFADE_S 동안 새 프리셋과 겹쳐 넘긴다.
followTrack(track, tSec) {
if (!track || !track.length || sticky) return; // 수동 버튼이 눌렸으면 사용자 의도가 우선
let seg = track[0];
for (const t of track) if (tSec >= t.start) seg = t;
if (seg !== trackSeg) { fadeFrom = emotion; trackSeg = seg; }
// fadeAt=seg.start: 경계에서 tSec≈seg.start(위 루프가 tSec>=seg.start 인 세그를 고르므로)이고,
// 첫 호출은 speakWithEmotion 이 이미 세그 0 프리셋을 적용해둔 뒤라 k=1 이 곧바로 맞다.
// 별도 변수로 관측 시각을 박제하지 않으므로 일시정지 후에도 페이드가 어긋나지 않는다.
const k = clamp01((tSec - seg.start) / CROSSFADE_S);
const base = EMOTIONS[seg.emo] || EMOTIONS.neutral;
const blended = {};
for (const key in fadeFrom) blended[key] = fadeFrom[key] * (1 - k);
for (const key in base) blended[key] = (blended[key] || 0) + base[key] * seg.intensity * k;
emotion = blended;
hold = 1; // 발화 중 유지, 끝나면 기존대로 감쇠
curKey = seg.emo; curInt = seg.intensity;
},
// 감정 프리셋을 현재 평활값에 max-결합. speaking=발화 중이면 유지, 자동 감정은 유휴 시 ~1.5s 감쇠.
applyMax(smooth, speaking) {
hold = (sticky || speaking) ? 1 : hold * 0.98;
for (const k in emotion) smooth[k] = Math.max(smooth[k] || 0, emotion[k] * hold);
},
// 현재 감정과 세기(표정과 같은 hold 감쇠를 공유) — makeHeadWander 몸짓 연동용.
current() { return { key: curKey, level: curInt * hold }; },
};
}
// ---------- 깜빡임 (버튼 + 자동) ----------
// autoBlink:()=>bool, intervalMs:()=>ms 는 매 프레임 라이브 조회. duration/jitter 상수 페이지별
// (puppet/docs 140·0.6·슬라이더, studio3d 150·0.8·3500). 상태는 클로저에 캡슐화.
function makeBlink({ autoBlink, intervalMs = () => 4000, duration, jitter }) {
let blinkAt = -1e9, nextAutoBlink = performance.now() + 4000;
return {
trigger() { blinkAt = performance.now(); },
// 0~1 연속값. 예전엔 (now < blinkUntil ? 1 : 0) 사각파여서 눈이 순간이동으로 닫혔고,
// 소비 측의 blink>0.5 이진 분기와 맞물려 부분 감김이 원리적으로 표현 불가능했다.
// 실제 눈깜빡임은 감기는 쪽이 뜨는 쪽보다 빠르다 — 앞 35%를 감김, 나머지를 뜸에 준다.
value(now) {
if (autoBlink() && now > nextAutoBlink) {
blinkAt = now;
nextAutoBlink = now + intervalMs() * (0.7 + Math.random() * jitter); // 자연스러운 지터
}
const t = (now - blinkAt) / duration;
if (t < 0 || t > 1) return 0;
return t < 0.35 ? t / 0.35 : 1 - (t - 0.35) / 0.65;
},
};
}
// ---------- 커서 시선 추적 ----------
// el 의 pointermove/leave → {gx, gy} (-1..1). 프레임별 평활·엔진채널 결합은 makeGaze 또는 페이지 인라인.
function makeCursorTracker(el) {
const s = { gx: 0, gy: 0 };
el.addEventListener("pointermove", e => {
const r = el.getBoundingClientRect();
s.gx = ((e.clientX - r.left) / r.width - 0.5) * 2;
s.gy = ((e.clientY - r.top) / r.height - 0.5) * 2;
});
el.addEventListener("pointerleave", () => { s.gx = 0; s.gy = 0; });
return s;
}
// ---------- 시선 결합 (슬라이더 > 엔진 채널 > 커서) + 0.15 평활 ----------
// cursor: makeCursorTracker 결과, mulX/mulY: 커서 배율(puppet·docs 0.9/0.6, studio3d 0.8/0.5).
// 반환: (sliderVal, W) => [gx, gy]. docs 는 슬라이더가 없어 sliderVal=0 고정으로 호출.
function makeGaze(cursor, { mulX, mulY }) {
let gx = 0, gy = 0, sacX = 0, sacY = 0, sacNext = 0;
return (W) => {
const now = performance.now();
const chX = (W("eyelookoutright") + W("eyelookinleft") - W("eyelookoutleft") - W("eyelookinright")) / 2;
const chY = (W("eyelookdownleft") + W("eyelookdownright") - W("eyelookupleft") - W("eyelookupright")) / 2;
// 엔진채널·커서 다 없으면 유휴 → 눈동자 미세 saccade(죽은 눈 방지)
const idle = !chX && !chY
&& Math.abs(cursor.gx) < 0.02 && Math.abs(cursor.gy) < 0.02;
if (idle && now > sacNext) {
sacNext = now + 1200 + Math.random() * 2000;
const center = Math.random() < 0.35; // 가끔 정면 복귀
sacX = center ? 0 : (Math.random() - 0.5) * 0.5;
sacY = center ? 0 : (Math.random() - 0.5) * 0.3;
}
// 우선순위: 유휴 saccade, 아니면 엔진채널 > 커서
const tgtX = idle ? sacX : (chX || cursor.gx * mulX);
const tgtY = idle ? sacY : (chY || cursor.gy * mulY);
gx += (tgtX - gx) * 0.15;
gy += (tgtY - gy) * 0.15;
return [gx, gy];
};
}
// ---------- 머리 워블 (2D: 발화 끄덕임 nod + 느린 표류 wander + 잔잔한 사인) ----------
// shakeEl 에 CSS 변환 적용. sway 는 페이지가 넘김(발화 중 1, 아니면 0.5). studio3d 는 3D라 미사용.
// 감정 → 몸짓 계수 (speed 배속·amp 진폭·droop px 아래로·beat 끄덕임 배율). 없는 키(neutral)는 전부 1/0.
const EMO_MOTION = {
joy: { speed: 1.25, amp: 1.5, droop: -2, beat: 1.3 }, // 들썩임 커지고 살짝 들림
sad: { speed: 0.55, amp: 0.5, droop: 9, beat: 0.4 }, // 고개 숙이고 느리고 작게
angry: { speed: 1.5, amp: 1.25, droop: 0, beat: 1.6 }, // 빠르고 절도 있게
surprise: { speed: 1.2, amp: 1.3, droop: -4, beat: 1.0 }, // 번쩍 들림
fear: { speed: 1.45, amp: 0.7, droop: 3, beat: 0.6 }, // 움츠리고 잔떨림
shy: { speed: 0.8, amp: 0.6, droop: 5, beat: 0.5 }, // 수줍게 숙임
};
// 2D 는 스프라이트라 yaw·pitch 를 회전시킬 수 없다 — 평행이동(%)으로 근사한다.
// 코어 클램프(±0.5/±0.35rad)에서 최대 ±5%/±3.5% 이동 — 과하면 얼굴이 프레임을 벗어난다.
const HEAD_SHIFT = 10;
function makeHeadWander() {
let nod = 0, wanderNext = 0, wanderR = 0, wanderY = 0, wanderGoalR = 0, wanderGoalY = 0;
let beat = 0, beatTilt = 0, lowSince = 0; // 강조 제스처: 구절 시작마다 끄덕임 임펄스
let phR = 0, phY = 0, phB = 0, last = 0; // 사인 위상 누적 — 감정 배속이 변해도 위상 연속(점프 없음)
// head: 웹캠 머리 자세 [yaw, pitch, roll] (mirror.head(), 선택). 2D 라 roll 은 실제 기울기,
// yaw·pitch 는 회전 대신 평행이동으로 근사한다 — 스프라이트를 3D 로 돌릴 수 없으므로.
return function tick(shakeEl, now, jawopen, sway, emoState, head) {
const m = emoState && EMO_MOTION[emoState.key], lv = m ? Math.min(emoState.level, 1) : 0;
const speed = 1 + ((m ? m.speed : 1) - 1) * lv;
const amp = 1 + ((m ? m.amp : 1) - 1) * lv;
const beatG = 1 + ((m ? m.beat : 1) - 1) * lv;
const droop = (m ? m.droop : 0) * lv;
const dt = last ? Math.min((now - last) / 1000, 0.1) : 0;
last = now;
phR += dt * 0.9 * speed; phY += dt * 1.7 * speed; phB += dt * 1.2 * speed;
nod = 0.85 * nod + 0.15 * jawopen;
// 조용(≥250ms)하다 입이 열리는 순간 = 구절 시작 → 끄덕임 비트 + 고개 기울임 변주.
// 매 음절마다가 아니라 pause 뒤 온셋에만 걸려 "말의 리듬"이 됨.
if (jawopen < 0.1) {
if (!lowSince) lowSince = now;
} else {
if (jawopen > 0.2 && lowSince && now - lowSince > 250) {
beat = 1;
beatTilt = (Math.random() - 0.5) * 0.02;
}
lowSince = 0;
}
beat *= 0.90; // ~0.5s 감쇠
if (now > wanderNext) {
wanderNext = now + 2200 + Math.random() * 2500;
wanderGoalR = (Math.random() - 0.5) * 0.03;
wanderGoalY = (Math.random() - 0.5) * 5;
}
wanderR += (wanderGoalR - wanderR) * 0.02;
wanderY += (wanderGoalY - wanderY) * 0.02;
const breath = Math.sin(phB) * 2 * amp; // ~5s 주기 호흡 — sway 무관(유휴에도 숨 쉼)
// wander 도 amp 로 스케일 — 움츠린 감정(sad/fear)은 표류까지 작아져야 일관됨.
const rot = Math.sin(phR) * 0.008 * sway * amp + wanderR * amp + nod * 0.015 + beatTilt * beat * beatG;
const dy = Math.sin(phY) * 1.5 * sway * amp + wanderY * amp + nod * 3 + breath + beat * 7 * beatG + droop;
// 머리 흔들림은 두 캔버스(WebGL base + 2D 오버레이)를 함께 감싼 래퍼에 CSS 변환으로 적용.
// transform-origin=center + translateY(% of height) 조합이 기존 ctx translate/rotate와 수학적으로 동일.
// 웹캠 머리 자세가 있으면 같은 문자열에 합성 — 유휴 흔들림 위에 실제 고개 방향이 얹힌다.
const hR = head ? head[2] : 0; // 갸웃 = 실제 회전
const hX = head ? head[0] * HEAD_SHIFT : 0; // 좌우 = 평행이동(%)
const hY = head ? head[1] * HEAD_SHIFT : 0; // 상하 = 평행이동(%)
shakeEl.style.transform =
`rotate(${(rot + hR).toFixed(5)}rad) translate(${hX.toFixed(3)}%, ${(dy / 512 * 100 + hY).toFixed(4)}%)`;
};
}
// ---------- 스프라이트 입모양 선택기 (개방도 우선 + 히스테리시스) ----------
// W 를 생성 시 주입. targetMouth 는 puppet superset — docs 발화경로에서 mouthpress·mouthstretch=0 이라 정확히 환원.
// pick(now) → {cur, prev, fade}: 크로스페이드 상태를 계산해 반환(내부 상태는 노출 안 함).
function makeMouthPicker(W) {
let curMouth = "closed", prevMouth = null, switchAt = 0, mouthCand = "closed", candSince = 0;
const FADE_MS = 90;
function targetMouth() {
const jaw = W("jawopen");
const round = roundness(W);
const wide = Math.max(avgLR(W, "mouthsmile"), avgLR(W, "mouthstretch"));
const press = avgLR(W, "mouthpress");
if (jaw < 0.06) return (press > 0.2 || W("mouthclose") > 0.25) ? "M" : "closed";
if (round > wide + 0.08) return jaw > 0.28 ? "O" : "U";
if (jaw > 0.42) return "A";
if (wide > 0.22) return jaw < 0.16 ? "I" : "E";
return jaw < 0.14 ? "closed" : "E";
}
return {
pick(now) {
const t = targetMouth();
if (t !== mouthCand) { mouthCand = t; candSince = now; }
if (mouthCand !== curMouth && now - candSince >= 70) { // 70ms 유지 시에만 전환
prevMouth = curMouth; switchAt = now; curMouth = mouthCand;
}
return { cur: curMouth, prev: prevMouth, fade: Math.min(1, (now - switchAt) / FADE_MS) };
},
};
}
// ---------- 벡터 입 (근육 채널 → 윤곽 제어점 연속 변형) ----------
// puppet 의 superset 공식으로 통합. 닫힘곡선 제어점 압력 = max(근육 press, mouthclose*0.5) 로
// puppet(press 위주)·docs(mouthclose 위주) 양쪽 기존 픽셀을 회귀 없이 재현. frown 반영은 puppet 항.
function drawVectorMouth(ctx, W, manifest, jawDy) {
const st = manifest.mouthStyle || {};
const [mcx, mcy0] = manifest.mouthCenter || [256, 340];
const jaw = W("jawopen");
const round = roundness(W);
const pressM = avgLR(W, "mouthpress"); // 근육 압력 (openH 폐합용)
const upperUp = avgLR(W, "mouthupperup");
const lowerDown = avgLR(W, "mouthlowerdown");
const smL = W("mouthsmileleft"), smR = W("mouthsmileright");
const frL = W("mouthfrownleft"), frR = W("mouthfrownright");
// 닫힘곡선 제어점 압력: 근육 press 와 mouthclose 유래 압력 중 강한 쪽 (하이브리드 — puppet·docs 양쪽 회귀 0)
const pressCurve = Math.max(pressM, W("mouthclose") * 0.5);
// 오므림(오/우): 세로 개방에 바닥값(+round*18)을 줘 낮은 턱에서도 둥근 구멍이 생기게 한다.
const openH = Math.max(0, jaw * 58 + lowerDown * 10 + round * 18 - Math.max(pressM * 8, W("mouthclose") * 30));
const wBase = st.width || 34;
const halfL = wBase * (1 + 0.45 * W("mouthstretchleft") + 0.3 * smL - 0.6 * round); // 오므림일수록 폭 좁힘
const halfR = wBase * (1 + 0.45 * W("mouthstretchright") + 0.3 * smR - 0.6 * round);
const cy = mcy0 + jawDy;
const xL = mcx - halfL, xR = mcx + halfR;
const yCL = cy - 2 - smL * 12 + frL * 12; // 입꼬리 좌우 독립 (비대칭 표정)
const yCR = cy - 2 - smR * 12 + frR * 12;
// 오므림 강할수록 위/아래 곡선을 대칭(0.5/0.5)으로 → 납작한 렌즈가 아니라 동그란 O.
const topF = 0.38 + 0.12 * round, botF = 1 - topF;
const yU = cy - openH * topF - upperUp * 8;
const yD = cy + openH * botF;
ctx.lineWidth = 5; ctx.lineCap = "round"; ctx.lineJoin = "round";
ctx.strokeStyle = st.line || "#3a2e2a";
if (openH < 2.5) { // 다문 입: 압력·미소·찡그림이 곡선 하나에 반영
ctx.beginPath();
ctx.moveTo(xL, yCL);
ctx.quadraticCurveTo(mcx, cy + 5 + pressCurve * 4, xR, yCR);
ctx.stroke();
return;
}
const path = new Path2D();
path.moveTo(xL, yCL);
path.quadraticCurveTo(mcx, 2 * yU - (yCL + yCR) / 2, xR, yCR); // 윗입술
path.quadraticCurveTo(mcx, 2 * yD - (yCL + yCR) / 2, xL, yCL); // 아랫입술
path.closePath();
ctx.fillStyle = st.fill || "#8a3535"; ctx.fill(path);
ctx.save(); ctx.clip(path);
// 오/우는 입술이 모여 이·혀가 거의 안 보임 — 오므림에 비례해 연속 감쇠(경계 팝 없음).
const inner = Math.max(0, 1 - 1.3 * round);
if (openH > 7) { // 윗니 (inner=0 이면 높이 0 = 안 그려짐)
ctx.fillStyle = st.teeth || "#ffffff";
ctx.fillRect(xL, yU - 2, xR - xL, Math.min(9, openH * 0.32) * inner);
}
if (openH > 18) { // 혀
ctx.fillStyle = st.tongue || "#d97b7b";
ctx.beginPath();
ctx.ellipse(mcx, yD, (xR - xL) * 0.3, openH * 0.28 * inner, 0, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
ctx.stroke(path);
}
// ---------- 스프라이트 입 크로스페이드 ----------
// 반드시 스프라이트 모드 브랜치에서만(프레임당 1회) 호출 — pick() 이 히스테리시스 상태를 전진시킴.
// 전환 중(fade<1)이고 이전 스프라이트가 존재하면 α로 겹쳐 페이드, 아니면 현재만. jawDy 만큼 세로 이동.
// 원본 입술을 위/아래 두 장으로 갈라 아랫입술만 내린다. 그 사이에 구강이 드러난다.
// 원본을 그대로 두면 벌릴 틈이 없고(그림에 입 안쪽이 없다), 구강을 통째로 얹으면
// 입술을 덮어버린다 — 둘 다 실측으로 확인하고 이 구조로 왔다.
// 원본 입술만 변형한다 — 구강은 그리지 않는다.
//
// 다문 입 그림에는 벌어진 입의 픽셀이 아예 없다. 그 자리를 채우려면 없는 걸 만들어야
// 하고, 만든 구강은 어떤 색·모양을 골라도 원본 화풍과 겉돈다(타원·사각형 둘 다 실측).
// 그래서 입을 "벌리지" 않고 입술 자체를 늘였다 오므렸다 한다 — 모든 픽셀이 원본이다.
// 만화 표현에서도 입을 안 벌리고 입술만 움직이는 립싱크는 흔하다.
function drawSplitLips(ctx, parts, W, manifest, jawDy) {
const lips = parts.mouth_lips;
if (!lips) return false;
const box = manifest.mouthBox || [0, 0, 0, 0];
const cx = (box[0] + box[2]) / 2, cy = (box[1] + box[3]) / 2;
// 벌림(jaw)은 세로로 늘리고, 오므림(round)은 가로로 좁힌다 — 입술 모양만으로
// "아/오/우"의 차이가 읽힌다.
const sy = 1 + W("jawopen") * 0.5 + avgLR(W, "mouthlowerdown") * 0.15;
const sx = 1 - roundness(W) * 0.22 + avgLR(W, "mouthstretch") * 0.12;
ctx.save();
ctx.translate(cx, cy + jawDy);
ctx.scale(sx, sy);
ctx.translate(-cx, -cy);
ctx.drawImage(lips, 0, 0);
ctx.restore();
return true;
}
function drawSpriteMouth(ctx, parts, picker, now, jawDy) {
const { cur, prev, fade } = picker.pick(now);
const drawM = name => parts[name] && ctx.drawImage(parts[name], 0, jawDy);
if (fade < 1 && prev && parts["mouth_" + prev]) {
ctx.globalAlpha = 1 - fade; drawM("mouth_" + prev);
ctx.globalAlpha = fade; drawM("mouth_" + cur);
ctx.globalAlpha = 1;
} else {
drawM("mouth_" + cur);
}
}
// ---------- WebGL 얼굴 워핑 (base 정점 변위 그리드) ----------
// 세분 평면(512×512, 48세그) + base 텍스처를 직교카메라로 픽셀 정합 렌더. 정점셰이더가 근육 채널값
// (uniform)으로 가우시안 변위장을 적용해 턱·볼을 미세 변형. 색공간은 NoColorSpace + 순수 셰이더
// 패스스루라 워프 0일 때 2D drawImage 와 픽셀 동일.
// threeUrl 페이지별(/static/vendor/… 절대 vs ./vendor/… 상대 — 클래식 스크립트라 문서 기준 해석).
// getParts/getManifest/W 는 페이지 상태 접근자.
function makeWarp({ threeUrl, glCanvas, getParts, getManifest, W }) {
return {
ready: false,
T: null, renderer: null, scene: null, camera: null, material: null, texture: null,
// 입 오버레이 세로 이동: 워프 ON이면 jaw 변위장을 입 앵커에서 평가한 값(≈11px·jaw)으로 대체해
// base 워프와 정확히 함께 움직이게 함(이중 이동 방지). OFF면 기존 jawDrop 사용. (38/55가 워프 시그마 결합)
jawOverlayDy(jaw, warpOn, manifest) {
return warpOn ? 14 * jaw * WARP_JAW_G : jaw * (manifest.jawDrop || 8);
},
vert: `
uniform vec2 uJawC, uCornerL, uCornerR;
uniform float uJaw, uSmileL, uSmileR, uRound, uFrownL, uFrownR, uSneer;
uniform float uCheek, uJawFwd, uShrug;
uniform vec2 uNoseC;
varying vec2 vUv;
float gk(vec2 p, vec2 c, float s){ vec2 d = p - c; return exp(-dot(d, d) / (2.0 * s * s)); }
void main() {
vUv = uv;
vec2 img = vec2(position.x + 256.0, 256.0 - position.y); // plane → 이미지 픽셀좌표(y down)
vec2 disp = vec2(0.0);
// 턱 드롭. 벡터 입을 안 쓰는(원본 입을 살린) 캐릭터는 이 워프가 유일한 립싱크라
// 진폭이 커야 보인다 — 14px 로는 jawOpen 0.3 에서 4px 라 화면에서 안 읽혔다.
disp += vec2( 0.0, 34.0) * uJaw * gk(img, uJawC, 40.0); // 턱 드롭
disp += vec2(-7.0, -9.0) * uSmileL * gk(img, uCornerL, 32.0); // 좌 입꼬리 (볼 당김)
disp += vec2( 7.0, -9.0) * uSmileR * gk(img, uCornerR, 32.0); // 우 입꼬리
disp += vec2( 8.0, 0.0) * uRound * gk(img, uCornerL, 32.0); // 오므림 (안쪽)
disp += vec2(-8.0, 0.0) * uRound * gk(img, uCornerR, 32.0);
disp += vec2(-3.0, 8.0) * uFrownL * gk(img, uCornerL, 32.0); // 찡그림 (내림)
disp += vec2( 3.0, 8.0) * uFrownR * gk(img, uCornerR, 32.0);
// 코 찡긋 — 화남의 유일한 상단 얼굴 변형. 앵커가 입뿐이라 화남이 이미지를
// 0픽셀 변형하던 문제를 푼다. 코를 위로 당겨 콧등에 주름이 잡히는 인상을 준다.
disp += vec2( 0.0, -7.0) * uSneer * gk(img, uNoseC, 30.0);
// 아래 세 앵커는 입꼬리에서 유도한다 — 볼·입 중심은 입꼬리와의 상대 위치가
// 캐릭터가 달라도 거의 일정해서, manifest 를 늘리지 않고도 자리를 잡을 수 있다.
// 볼은 눈에서 멀리·좁게 잡는다. 눈은 base 위에 별도 스프라이트로 얹히는데 워프는
// base 만 변형해서, 커널이 눈까지 닿으면 볼 홍조가 눈 밑으로 밀려 올라와 겹쳤다.
vec2 cheekL = uCornerL + vec2(-18.0, -20.0);
vec2 cheekR = uCornerR + vec2( 18.0, -20.0);
vec2 mouthC = (uCornerL + uCornerR) * 0.5;
disp += vec2( 0.0, -5.0) * uCheek * gk(img, cheekL, 26.0); // 볼 올림 (진짜 웃음)
disp += vec2( 0.0, -5.0) * uCheek * gk(img, cheekR, 26.0);
disp += vec2( 0.0, 5.0) * uJawFwd * gk(img, uJawC, 48.0); // 턱 내밈 (화남)
disp += vec2( 0.0, -5.0) * uShrug * gk(img, mouthC, 22.0); // 윗입술 삐죽 (슬픔)
vec2 pos = position.xy;
pos.x += disp.x; pos.y -= disp.y; // 이미지 y-down → plane y-up
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 0.0, 1.0);
}`,
frag: `
uniform sampler2D uTex;
varying vec2 vUv;
void main() { gl_FragColor = texture2D(uTex, vUv); }`, // colorspace include 없음 → sRGB 바이트 그대로
async init() {
try {
const T = await import(threeUrl);
this.T = T;
// preserveDrawingBuffer: 움짤 녹화가 drawImage 로 gl 캔버스를 캡처할 때 빈 프레임 방지(정석 옵션).
this.renderer = new T.WebGLRenderer({ canvas: glCanvas, alpha: true, antialias: false, premultipliedAlpha: false, preserveDrawingBuffer: true });
// 셰이더 링크 실패는 three가 throw하지 않고 콘솔 로깅만 함 → 이 콜백으로 감지해 폴백 전환.
this.renderer.debug.onShaderError = () => { this.ready = false; };
this.renderer.setClearColor(0x000000, 0);
this.renderer.setSize(512, 512, false);
this.scene = new T.Scene();
this.camera = new T.OrthographicCamera(-256, 256, 256, -256, -1, 1);
this.material = new T.ShaderMaterial({
uniforms: {
uTex: { value: null },
uJawC: { value: new T.Vector2(256, 378) },
uCornerL: { value: new T.Vector2(220, 340) },
uCornerR: { value: new T.Vector2(292, 340) },
uNoseC: { value: new T.Vector2(256, 300) },
uSneer: { value: 0 },
uCheek: { value: 0 },
uJawFwd: { value: 0 },
uShrug: { value: 0 },
uJaw: { value: 0 }, uSmileL: { value: 0 }, uSmileR: { value: 0 },
uRound: { value: 0 }, uFrownL: { value: 0 }, uFrownR: { value: 0 },
},
vertexShader: this.vert, fragmentShader: this.frag, transparent: true, side: T.DoubleSide,
});
this.scene.add(new T.Mesh(new T.PlaneGeometry(512, 512, 48, 48), this.material));
this.ready = true;
this.setCharacter(); // 경쟁 처리: base가 이미 로드됐으면 여기서 텍스처 설정
// init 시 강제 1회 렌더 → 셰이더 컴파일/링크를 지금 유발. 실패하면 throw(catch) 또는
// onShaderError가 ready=false로 내려 렌더 루프가 base를 2D로 그리는 폴백이 확실히 작동.
this.renderer.render(this.scene, this.camera);
} catch (e) {
this.ready = false; // WebGL 불가/로드 실패 → 조용히 폴백
}
},
setCharacter() {
const parts = getParts(), manifest = getManifest();
if (!this.ready || !parts.base) return;
const T = this.T;
if (this.texture) this.texture.dispose();
// 정적 캐릭터 base는 Image, 드래그앤드랍 유저 캐릭터 base는 canvas → CanvasTexture로 수용.
this.texture = (parts.base instanceof HTMLCanvasElement)
? new T.CanvasTexture(parts.base)
: new T.Texture(parts.base);
this.texture.colorSpace = T.NoColorSpace; // sRGB 바이트 그대로 업로드 (GPU 선형화 안 함)
this.texture.premultiplyAlpha = false;
this.texture.minFilter = T.LinearFilter;
this.texture.magFilter = T.LinearFilter;
this.texture.generateMipmaps = false;
this.texture.needsUpdate = true;
this.material.uniforms.uTex.value = this.texture;
const mc = manifest.mouthCenter || [256, 340];
const mw = (manifest.mouthStyle && manifest.mouthStyle.width) || 30;
this.material.uniforms.uJawC.value.set(mc[0], mc[1] + 38);
this.material.uniforms.uCornerL.value.set(mc[0] - mw * 1.2, mc[1]);
this.material.uniforms.uCornerR.value.set(mc[0] + mw * 1.2, mc[1]);
// 코는 입 중심에서 위로 — manifest 에 noseCenter 가 있으면 그걸 쓴다
const nc = manifest.noseCenter || [mc[0], mc[1] - 44];
this.material.uniforms.uNoseC.value.set(nc[0], nc[1]);
},
render() {
if (!this.ready || !this.texture) return;
const u = this.material.uniforms;
u.uJaw.value = W("jawopen");
u.uSmileL.value = W("mouthsmileleft");
u.uSmileR.value = W("mouthsmileright");
u.uRound.value = roundness(W);
u.uFrownL.value = W("mouthfrownleft");
u.uFrownR.value = W("mouthfrownright");
u.uSneer.value = avgLR(W, "nosesneer");
u.uCheek.value = avgLR(W, "cheeksquint");
u.uJawFwd.value = W("jawforward");
u.uShrug.value = W("mouthshrugupper");
this.renderer.render(this.scene, this.camera);
},
};
}
// ---------- 발화 요청 → 결과 (내부) ----------
// 서버가 동기 응답(0.6초급 작업) — 잡 폴링 제거로 발화당 ~0.2~0.35s 단축.
async function speakRT({ text, voice, engine, prosody }) {
const res = await fetch("/api/speak_rt", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, voice, engine, ...(prosody || {}) }),
});
if (!res.ok) throw new Error((await res.json()).detail || res.statusText);
return res.json();
}
// ---------- 발화 플로우 (puppet·studio3d 공용) ----------
// speakRT → anim 조립(head 포함, 2D는 미사용이라 무해) → onAnim(재생 전 반영) → audio.src → play.
// onAnim 은 play 로딩 창 동안 렌더 루프가 최신 anim 을 보게 하려고 src/play 이전에 호출(원본 순서 유지).
// 페이지 핸들러는 autoEmo·버튼 disable·에러 setStatus 만 담당.
async function speakFlow({ text, voice, engine, audioEl, onAnim, prosody }) {
const r = await speakRT({ text, voice, engine, prosody });
const anim = { fps: r.fps, frames: r.frames, head: r.head, index: r.names.map((n, i) => [norm(n), i]) };
shapeAnim(anim, engine); // 엔진 출력 정규화 (입 벌림·편향 제거)
anim.sentences = r.sentences; // 문장 시작 시각 (없을 수 있음)
if (onAnim) onAnim(anim);
audioEl.src = r.audio_url;
await audioEl.play();
return anim;
}
// ---------- 감정 결정 + 발화 (puppet·studio3d 공용) ----------
// emotion 지정(LLM 판단) 있으면 그대로, 없으면 LLM 분류 → 실패 시 규칙 추론. autoEmo(호출 시점 boolean) 켜져 있으면
// emo(makeEmotion 인스턴스) 프리셋 + 목소리 톤 적용 후 speakFlow. voice/engine 은 호출 시점 값.
// intensity/sticky 는 emotion 이 있을 때만 쓴다 — 호출부가 수동 버튼(1·고정)과 명시 감정
// (0.9·감쇠)을 구분해 넘긴다. 영상 경로(puppet speakVideo)와 같은 규칙이어야 한다.
async function speakWithEmotion({ text, emotion, intensity = 0.9, sticky = false,
autoEmo, emo, voice, engine, audioEl, onAnim }) {
let segs = null;
let r = emotion ? { emo: emotion, intensity } : null;
if (!r && autoEmo) {
// 직렬 호출: 목소리 톤(prosody)이 TTS 요청 파라미터라 감정을 먼저 알아야 한다.
segs = await classifyEmotion(text);
r = segs ? { emo: segs[0].emo, intensity: segs[0].intensity } : inferEmotion(text);
} else if (!r) {
r = inferEmotion(text);
}
let prosody = null;
// 명시 감정(대화 모드 LLM·클릭 반응)은 autoEmo 와 무관하게 적용한다 — 예전엔 autoEmo 가
// 꺼져 있으면 넘어온 emotion 까지 무시돼 표정·목소리 톤이 둘 다 사라졌다.
if (r && (autoEmo || emotion)) {
// 자동 판정은 발화가 끝나면 중립 복귀(sticky=false). 수동 버튼만 고정으로 넘어온다.
emo.setEmotion(r.emo, r.intensity, sticky);
prosody = voiceProsody(r.emo, r.intensity);
}
const anim = await speakFlow({ text, voice, engine, audioEl, onAnim, prosody });
// 문장별 감정 전환은 두 배열의 길이가 맞을 때만 (분할 결과가 같다는 전제 확인)
if (segs && anim.sentences && anim.sentences.length === segs.length) {
anim.emotionTrack = anim.sentences.map((s, i) => ({
start: s.start, emo: segs[i].emo, intensity: segs[i].intensity }));
}
return anim;
}
// ---------- 자동 쇼케이스 (첫 방문자 유휴 시 인사·감정 시연) ----------
// 오늘 넣은 표현력(감정 7종·몸짓·입 오므림)을 방문자가 아무것도 안 눌러도 보게 하는 유도.
const SHOWCASE_SCRIPT = [
{ text: "안녕하세요! 저는 그림에서 태어난 아바타예요.", emo: "joy" },
{ text: "이렇게 활짝 웃기도 하고,", emo: "joy" },
{ text: "시무룩해지기도,", emo: "sad" },
{ text: "깜짝 놀라기도,", emo: "surprise" },
{ text: "무서워하기도,", emo: "fear" },
{ text: "부끄러워하기도 한답니다.", emo: "shy" },
{ text: "위에 문장을 입력하면 제가 말해드릴게요!", emo: "joy" },
];
// playStep(step) → Promise(발화 완료). 발화 방식은 페이지가 주입(docs=speechSynthesis).
// 유휴 감지·중단 트리거는 엘리먼트가 페이지마다 달라 페이지가 소유 — 여긴 순차 재생 엔진만.
function makeShowcase(playStep, script = SHOWCASE_SCRIPT) {
let running = false, cancelled = false;
return {
get running() { return running; },
async play() {
if (running) return;
running = true; cancelled = false;
for (const step of script) {
if (cancelled) break;
await playStep(step);
if (cancelled) break;
await new Promise(r => setTimeout(r, 220)); // 스텝 사이 짧은 숨
}
running = false;
},
stop() { cancelled = true; },
};
}
// ---------- 캐릭터 클릭 반응 (아바타를 누르면 감정 섞인 한마디) ----------
const REACTIONS = [
{ text: "우와, 깜짝이야!", emo: "surprise" },
{ text: "헤헤, 간지러워요.", emo: "joy" },
{ text: "아이, 부끄럽게 왜 그래요.", emo: "shy" },
{ text: "안녕하세요! 반가워요.", emo: "joy" },
{ text: "어? 왜 그러세요?", emo: "surprise" },
{ text: "으, 살살 해주세요.", emo: "fear" },
];
let _lastReact = -1;
function pickReaction() { // 직전과 다른 반응을 뽑아 연속 중복 방지
let i;
do { i = Math.floor(Math.random() * REACTIONS.length); } while (i === _lastReact && REACTIONS.length > 1);
_lastReact = i;
return REACTIONS[i];
}
// ---------- 분석 패널 (비교군): 내 얼굴 + 478점 랜드마크 + 구동 채널값 ----------
// 어느 페이지든 컨테이너 하나 주면 동일 패널 — 스타일 인라인이라 페이지 CSS 의존 없음.
// 반환된 draw(W) 를 렌더 루프에서 매 프레임 호출 (W = 채널 접근자). 컨테이너 숨김이면 즉시 반환.
// 미리보기: 웹캠 + 얼굴 메시(어디를 잡았나) + 오각형(얼마나 움직였나).
// 값 막대를 쌓지 않는 이유 — 채널 계수는 전송 데이터이지 읽는 정보가 아니고(Live Link Face·
// VSeeFace 도 메시만 겹쳐 보여준다), 모델 내부 채널명·0.00 정밀도를 UI 에 노출하면 값을
// 그대로 렌더한 티가 난다. 대신 다섯 축이 한 덩어리로 일그러지는 모양을 보여준다:
// 개별 수치는 못 읽어도 "지금 살아서 반응한다"가 한눈에 들어온다.
//
// 시선은 여기 없다. 크기가 아니라 방향(좌우·상하)이라 축 하나에 올릴 수 없고,
// 메시 위 홍채 점이 이미 그 자체로 보여준다.
// 첫 원소는 라벨이 아니라 STRINGS[*].radar 의 키다 — 표시 문구는 로케일에서 온다.
const RADAR_CHS = [
["mouth", W => W("jawopen")],
["smile", W => (W("mouthsmileleft") + W("mouthsmileright")) / 2],
["pucker", W => W("mouthpucker")],
["eye", W => (W("eyeblinkleft") + W("eyeblinkright")) / 2],
["brow", W => W("browinnerup")],
];
function makeMirrorPanel(mirror, mount) {
let lastFrame = -1; // 마지막으로 미리보기에 그린 웹캠 프레임 번호
mount.innerHTML = '
' + T().panelTitle + '
';
const cv = document.createElement("canvas");
cv.width = 320; cv.height = 240;
cv.style.cssText = "width:100%;border-radius:12px;border:1px solid #2a2a35;background:#0d0d12;display:block";
mount.appendChild(cv);
const ctx = cv.getContext("2d");
const rv = document.createElement("canvas");
rv.width = 320; rv.height = 218;
rv.style.cssText = "width:100%;margin-top:8px;display:block";
mount.appendChild(rv);
const rc = rv.getContext("2d");
// 12시부터 시계방향. 축 순서를 바꾸면 같은 표정이 다른 모양이 되므로 고정이다.
const N = RADAR_CHS.length, CX = 160, CY = 118, R = 90;
const AX = i => -Math.PI / 2 + i * 2 * Math.PI / N;
const poly = (get, close) => {
rc.beginPath();
for (let i = 0; i < N; i++) {
const r = get(i), a = AX(i);
const x = CX + Math.cos(a) * r, y = CY + Math.sin(a) * r;
i ? rc.lineTo(x, y) : rc.moveTo(x, y);
}
if (close !== false) rc.closePath();
};
return {
canvas: cv, // 동시 구동 화면의 녹화 합성용
draw(W) {
if (!mount.offsetParent) return; // 숨김 상태 — 일 안 함
const d = mirror.debug();
// 웹캠 프레임이 그대로면 같은 그림을 다시 그리게 된다 — 렌더 루프(60~144fps)가
// 추론(~30fps)보다 빨라 4~5배 헛일. 오각형은 값이 계속 변하므로 매 프레임 갱신한다.
if (d.frame !== lastFrame || !mirror.on) {
lastFrame = d.frame;
const w = cv.width, h = cv.height;
ctx.fillStyle = "#0d0d12"; ctx.fillRect(0, 0, w, h);
if (!mirror.on || !d.video || d.video.readyState < 2) {
ctx.fillStyle = "#9a9ab0"; ctx.font = "13px sans-serif"; ctx.textAlign = "center";
ctx.fillText(T().panelIdle, w / 2, h / 2);
} else {
ctx.save(); ctx.scale(-1, 1); ctx.drawImage(d.video, -w, 0, w, h); ctx.restore(); // 거울 반전
if (d.lm) {
const X = i => (1 - d.lm[i].x) * w, Y = i => d.lm[i].y * h;
if (d.mesh) {
// MediaPipe 표준 토폴로지(2556 세그먼트)를 한 경로로 모아 한 번에 stroke —
// 개별 stroke 면 프레임을 먹는다.
ctx.beginPath();
for (const c of d.mesh) {
const a = c.start ?? c[0], b = c.end ?? c[1];
ctx.moveTo(X(a), Y(a)); ctx.lineTo(X(b), Y(b));
}
ctx.strokeStyle = "rgba(232,232,239,.22)"; ctx.lineWidth = 0.5; ctx.stroke();
} else {
ctx.fillStyle = "rgba(232,232,239,.5)"; // 구버전 tasks-vision 폴백
for (let i = 0; i < 468; i++) ctx.fillRect(X(i) - .5, Y(i) - .5, 1.5, 1.5);
}
ctx.fillStyle = "#ffb03a"; // 홍채 10점 = 시선 계산의 실제 입력
for (let i = 468; i < d.lm.length; i++) ctx.fillRect(X(i) - 1.5, Y(i) - 1.5, 3, 3);
}
}
}
rc.clearRect(0, 0, rv.width, rv.height);
rc.strokeStyle = "rgba(232,232,239,.13)"; rc.lineWidth = 1;
for (const f of [0.5, 1]) { poly(() => R * f); rc.stroke(); }
rc.beginPath();
for (let i = 0; i < N; i++) { rc.moveTo(CX, CY); rc.lineTo(CX + Math.cos(AX(i)) * R, CY + Math.sin(AX(i)) * R); }
rc.stroke();
poly(i => Math.max(0, Math.min(1, RADAR_CHS[i][1](W))) * R);
rc.fillStyle = "rgba(255,176,58,.20)"; rc.fill();
rc.strokeStyle = "#ffb03a"; rc.lineWidth = 1.5; rc.stroke();
rc.fillStyle = "#9a9ab0"; rc.font = "13px sans-serif"; rc.textAlign = "center"; rc.textBaseline = "middle";
for (let i = 0; i < N; i++) {
const a = AX(i);
rc.fillText(T().radar[RADAR_CHS[i][0]], CX + Math.cos(a) * (R + 22), CY + Math.sin(a) * (R + 16));
}
},
};
}
// ---------- 2D 캐릭터 그리기 (소년·소녀 공용) ----------
// 파츠 스프라이트(눈썹·눈·동공) + 벡터 입을 한 번에. 워프를 쓰는 페이지는 warp 를 넘기면
// 배경/턱 오프셋을 워프 기준으로, 안 넘기면 흰 배경 + manifest.jawDrop 폴백으로 그린다.
//
// ctx: 2D 컨텍스트(512²), parts: 이미지 맵, manifest: 캐릭터 튜닝값,
// W: 채널 접근자, blink: 깜빡임(자동+미러 max 결합), gaze: [gx, gy],
// opts.warp: makeWarp 인스턴스(선택), opts.clearBg: 배경 채우기 여부(클린 모드면 false)
// 눈썹 기울기 최대 각(rad) — 12px 이동과 균형이 맞는 크기로 실측 조정.
const BROW_TILT = 0.22;
// 눈썹을 자기 중심에서 회전시켜 그린다. 스프라이트는 512² 전체 캔버스에 그려져 있으므로
// 회전 피벗은 manifest 의 눈 중심(없으면 캔버스 중앙 위쪽)을 쓴다.
function drawBrow(name, dy, tilt, manifest) {
const img = _partsRef && _partsRef[name];
if (!img) return;
const c = _ctxRef;
if (!tilt) { c.drawImage(img, 0, dy); return; }
const [px, py] = manifest.browPivot || [256, 250];
c.save();
c.translate(px, py + dy); c.rotate(tilt); c.translate(-px, -py);
c.drawImage(img, 0, 0);
c.restore();
}
let _partsRef = null, _ctxRef = null; // drawBrow/drawEyes 가 쓰는 프레임 지역 참조
// 눈 스프라이트의 열별 프로파일. 캐릭터당 1회 계산해 캐싱한다.
//
// 알파 bbox 를 쓰면 안 된다 — 빌더가 클릭 좌표 ±반경 사각형을 통째로 잘라 넣어서 알파
// 경계가 곧 클릭 상자이고 실제 눈과 다르다. 그래서 잉크(어두운 픽셀)를 기준으로 잡되,
// 최대 연결성분만 남긴다 — 소녀 캐릭터는 앞머리 획 141px 이 눈 상자에 걸쳐 있어
// 잉크 bbox 가 58px 로 부풀고 눈이 14% 과압축됐다. 그 획은 눈이 아니므로 화면에는
// 그대로 두되 애니메이션만 안 받는 게 맞다.
//
// 화풍은 두 지표로 갈린다(실측): 열마다 잉크가 끊겨 여러 런이 되는 비율(=내부가 비었다),
// 그리고 채움률. 만화눈 0.84/0.29, 점눈 0.00/0.69~0.78, 실눈 0.00/0.43~0.47 로
// 마진이 넓다.
const INK_MAX = 300; // r+g+b 합이 이 미만이면 '획'. character_builder.snap_eye_box 와 같은 값 —
// 한쪽만 바꾸면 빌더가 자른 상자와 렌더가 재는 프로파일이 다른 눈을 가리킨다.
const MIN_VIS = 3; // 완전히 감겨도 남기는 최소 높이(px). 512² 스프라이트(CANVAS) 기준.
const EYE_SIDES = [["L", "eye_L_open", "pupil_L"], ["R", "eye_R_open", "pupil_R"]];
const _pairCache = new WeakMap();
let _scratch = null;
// 양쪽 눈을 함께 분류한다. 한 캐릭터의 두 눈은 같은 화풍이므로 판정도 하나여야 하는데,
// 눈마다 따로 재면 지표가 임계 근처인 그림에서 좌우가 갈린다 — 돼지·졸라맨이 실제로
// blob/line 으로 갈려 한쪽 눈만 감겼다(채움률 0.69 vs 0.58, 임계 0.6).
//
// 반환: { cls, L, R } — cls 는 공통, L/R 은 각자의 열 프로파일(선눈이면 null).
// 캐시 키는 왼쪽 눈 이미지. loadCharacter 가 캐릭터 전환 때만 새 Image 를 만들어
// 두 이미지의 수명이 같으므로 한쪽만 키로 잡아도 짝이 어긋나지 않는다.
function eyeProfiles(parts) {
const eL = parts.eye_L_open;
if (!eL) return { cls: null, L: null, R: null };
const hit = _pairCache.get(eL);
if (hit) return hit;
let out = { cls: null, L: null, R: null };
try {
const raws = [_scan(eL), parts.eye_R_open ? _scan(parts.eye_R_open) : null];
const seen = raws.filter(Boolean);
if (seen.length) {
// 화풍은 두 지표로 갈린다(실측): 열마다 잉크가 끊겨 여러 런이 되는 비율
// (=내부가 비었다), 그리고 채움률. 만화눈 0.84/0.29, 점눈 0.00/0.69~0.78,
// 실눈 0.00/0.43~0.47. 양쪽 눈의 픽셀을 합쳐 한 번 판정한다.
const sum = k => seen.reduce((a, r) => a + r[k], 0);
const cls = sum("multi") / sum("cols") >= 0.4 ? "outline"
: (sum("area") / sum("boxArea") >= 0.6 ? "blob" : "line");
out = { cls, L: _finish(raws[0], cls), R: _finish(raws[1], cls) };
}
} catch { /* 픽셀을 못 읽으면 폴백 */ }
_pairCache.set(eL, out);
return out;
}
// 선눈(획 자체가 그림)은 null 이 된다 — 지킬 굵기가 곧 눈 전체라 어떤 lid 에서도
// 움직일 여지가 없다. 소비 측의 `!p` 폴백이 그대로 "원본을 그린다"라서, 별도의
// travel 배열이나 플래그 없이 같은 동작이 나온다.
function _finish(raw, cls) {
if (!raw || cls === "line") return null;
const { x0, x1, top, bot, cap, cy0, cy1 } = raw;
const prof = { cls, x0, x1, top0: cy0, bot1: cy1 + 1, top };
if (cls === "blob") {
// 점은 지킬 획이 없다 — 아래 MIN_VIS 만 남기고 위에서 가린다.
const travel = new Int16Array(top.length);
for (let x = x0; x <= x1; x++) {
if (top[x] >= 0) travel[x] = Math.max(0, bot[x] - top[x] + 1 - MIN_VIS);
}
prof.travel = travel;
} else {
// 윤곽눈은 눈알 전체를 눌러 내리므로 열별 이동량이 아니라 최소 잔여 높이만 필요하다.
const caps = [];
for (let x = x0; x <= x1; x++) if (top[x] >= 0) caps.push(cap[x]);
caps.sort((a, b) => a - b);
prof.capMed = caps[caps.length >> 1] || MIN_VIS;
}
return prof;
}
function _scan(img) {
const W = img.width, H = img.height;
if (!_scratch || _scratch.canvas.width !== W || _scratch.canvas.height !== H) {
const c = document.createElement("canvas");
c.width = W; c.height = H;
_scratch = c.getContext("2d", { willReadFrequently: true });
}
_scratch.clearRect(0, 0, W, H);
_scratch.drawImage(img, 0, 0);
const d = _scratch.getImageData(0, 0, W, H).data;
let x0 = W, y0 = H, x1 = -1, y1 = -1;
for (let i = 3, p = 0; p < W * H; p++, i += 4) {
if (d[i] > 0 && d[i - 3] + d[i - 2] + d[i - 1] < INK_MAX) {
const x = p % W, y = (p / W) | 0;
if (x < x0) x0 = x;
if (x > x1) x1 = x;
if (y < y0) y0 = y;
if (y > y1) y1 = y;
}
}
if (x1 < 0) return null;
// 최대 연결성분(8-이웃)만 남긴다 — 소녀 캐릭터는 앞머리 획 141px 이 눈 상자에 걸쳐
// 잉크 bbox 가 58px 로 부풀고 눈이 14% 과압축됐다. 그 획은 눈이 아니므로 화면에는
// 그대로 두되 애니메이션만 안 받는 게 맞다.
// 라벨 배열은 눈 bbox 크기로만 잡는다(512² 는 1MB memset). 0=미라벨이라 fill 불필요.
const bw = x1 - x0 + 1, bh = y1 - y0 + 1;
const isInk = (x, y) => {
const i = (y * W + x) * 4;
return d[i + 3] > 0 && d[i] + d[i + 1] + d[i + 2] < INK_MAX;
};
const lab = new Int32Array(bw * bh);
const stack = [];
let best = 0, bestN = 0;
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
const s = (y - y0) * bw + (x - x0);
if (lab[s] || !isInk(x, y)) continue;
const id = s + 1;
let n = 0;
stack.push(x, y); lab[s] = id;
while (stack.length) {
const qy = stack.pop(), qx = stack.pop(); n++;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
const nx = qx + dx, ny = qy + dy;
if (nx < x0 || nx > x1 || ny < y0 || ny > y1) continue;
const t = (ny - y0) * bw + (nx - x0);
if (!lab[t] && isInk(nx, ny)) { lab[t] = id; stack.push(nx, ny); }
}
}
}
if (n > bestN) { bestN = n; best = id; }
}
}
if (!best) return null;
// 열별 최상단/최하단 + 최상단 런 두께, 그리고 분류 지표
const top = new Int16Array(W).fill(-1);
const bot = new Int16Array(W), cap = new Int16Array(W);
let cols = 0, multi = 0, area = 0, cy0 = H, cy1 = -1;
for (let x = x0; x <= x1; x++) {
let first = -1, last = -1, runs = 0, capH = 0, prev = -2;
for (let y = y0; y <= y1; y++) {
if (lab[(y - y0) * bw + (x - x0)] !== best) continue;
if (first < 0) first = y;
last = y; area++;
if (y !== prev + 1) runs++;
if (runs === 1) capH++;
prev = y;
}
if (first < 0) continue;
cols++; if (runs > 1) multi++;
top[x] = first; bot[x] = last; cap[x] = capH;
if (first < cy0) cy0 = first;
if (last > cy1) cy1 = last;
}
if (!cols) return null;
// 분류는 여기서 하지 않는다 — 양쪽 눈 지표를 합쳐 eyeProfiles 가 한 번에 판정한다.
// 채움률 분모는 잉크 전체 bbox 가 아니라 **최대 성분이 차지하는 열** 기준이다.
// 전체 bbox 를 쓰면 성분 밖 잡티가 폭을 늘려 값이 반토막 난다(돼지 오른눈: 전체
// 폭 20 인데 점은 8열 → 0.89 가 0.36 으로 떨어져 점눈이 선눈으로 오분류됐다).
return { x0, x1, top, bot, cap, cy0, cy1,
cols, multi, area, boxArea: cols * (cy1 - cy0 + 1) };
}
// 눈꺼풀이 내려오는 렌더. 눈알을 찌그러뜨리지 않고 위에서 가린다 — Live2D 도
// "윗속눈썹을 아래속눈썹 위치까지 내리고 눈알은 클리핑"이라고 정의한다.
// 화풍마다 가리는 방식이 다른 건 지킬 것이 다르기 때문이다(내부 vs 획 굵기).
function occludeEye(dst, img, p, lid) {
if (!p) { dst.drawImage(img, 0, 0); return; }
if (p.cls === "outline") {
// 흰자가 있는 눈은 눈알째 눌러 내린다. 열별로 가리기만 하면 흰자가 뚫려
// '빈 테두리 원'이 되고 눈꺼풀이 내려오는 인상이 안 난다 — 눈알이 눈꺼풀 뒤로
// 들어가는 걸 흉내내는 쪽이 이 화풍에 맞는다(자매 리포 deriveHalfEye 와 같은 기하).
const w = p.x1 - p.x0 + 1, h = p.bot1 - p.top0;
// 최소 높이를 테두리 두께로 — 1px 까지 누르면 눈 하단의 흰자 한 줄만 남아
// 감은 눈 자리에 흰 선이 그어진다. 테두리 두께면 그 화풍의 선 색으로 닫힌다.
const nh = Math.max(p.capMed, h * (1 - lid));
dst.drawImage(img, p.x0, p.top0, w, h, p.x0, p.bot1 - nh, w, nh);
return;
}
// 점눈은 눌러도 지킬 내부가 없다. 위에서 가리기만 해 획 굵기를 보존한다 —
// 균일 압축은 실눈 획 6.4px 를 1px 이하로 만들어 화풍을 지우다시피 했다.
dst.drawImage(img, 0, 0);
for (let x = p.x0; x <= p.x1; x++) {
if (p.top[x] < 0 || !p.travel[x]) continue;
// 정수 — 서브픽셀 리샘플은 가는 선을 프레임마다 깜박이게 한다
const gone = Math.round(lid * p.travel[x]);
if (gone > 0) dst.clearRect(x, p.top[x], 1, gone);
}
}
function drawEyes(ctx, parts, drawXY, lid, gaze, manifest) {
const draw = n => parts[n] && ctx.drawImage(parts[n], 0, 0);
const pr = manifest.pupilRange || 0;
const gx = gaze[0] * pr, gy = gaze[1] * pr;
if (lid <= 0.01) {
draw("eye_L_open"); draw("eye_R_open");
drawXY("pupil_L", gx, gy); drawXY("pupil_R", gx, gy);
return;
}
const profs = eyeProfiles(parts);
for (const [side, eyeName, pupilName] of EYE_SIDES) {
const eye = parts[eyeName], pupil = parts[pupilName];
if (!eye) continue;
const p = profs[side];
if (!pupil || !p) { occludeEye(ctx, eye, p, lid); continue; }
// 눈동자는 변형하지 않는다 — 같이 누르면 동공이 타원이 돼 졸린 눈처럼 보인다.
// Live2D·Character Animator 모두 눈동자를 흰자로 '클리핑'할 뿐 변형하지 않는다.
// 오프스크린은 눈 주변만 다룬다 — 512² 를 통째로 지우고 되합성하면 실제로 픽셀이
// 바뀌는 5천여 개를 위해 프레임당 1M px 를 왕복하게 된다.
const m = Math.ceil(Math.max(Math.abs(gx), Math.abs(gy))) + 2;
const sx = Math.max(0, p.x0 - m), sy = Math.max(0, p.top0 - m);
const sw = Math.min(ctx.canvas.width - sx, p.x1 - p.x0 + 1 + 2 * m);
const sh = Math.min(ctx.canvas.height - sy, p.bot1 - p.top0 + 2 * m);
const off = _offscreen(ctx.canvas.width, ctx.canvas.height, sx, sy, sw, sh);
occludeEye(off, eye, p, lid);
off.globalCompositeOperation = "source-atop"; // 남은 눈 알파 안에만 그려진다
off.drawImage(pupil, gx, gy);
off.globalCompositeOperation = "source-over";
ctx.drawImage(off.canvas, sx, sy, sw, sh, sx, sy, sw, sh);
}
// 흰자가 있는 눈만 감은 눈 호를 섞는다. 눈알을 끝까지 눌러도 남는 건 흰자 한 줄이라
// 그 화풍은 스스로 닫히지 못한다. 점·선 눈은 반대로 자기 획이 곧 닫힌 모습이라
// 호를 얹으면 이중선이 된다 — 실측으로 실눈은 획(275~291)과 호(282~292)가 겹쳤다.
const seal = profs.cls === "outline" ? clamp01((lid - 0.7) / 0.3) : 0;
if (seal > 0.01) {
ctx.globalAlpha = seal;
draw("eye_L_closed"); draw("eye_R_closed");
ctx.globalAlpha = 1;
}
}
// 눈동자 클리핑용 오프스크린 — 매 프레임 새로 만들면 GC 압력이 커서 하나를 돌려쓴다.
// 지우는 건 넘겨받은 사각형만(캔버스 전체를 지우면 프레임당 262k px 가 낭비된다).
let _off = null;
function _offscreen(w, h, sx, sy, sw, sh) {
if (!_off || _off.canvas.width !== w || _off.canvas.height !== h) {
const c = document.createElement("canvas");
c.width = w; c.height = h;
_off = c.getContext("2d");
}
_off.clearRect(sx, sy, sw, sh);
return _off;
}
// 눈썹 + 눈 (파츠 스프라이트 기반). drawChar2D 와 puppet.html 이 함께 쓴다 —
// 한쪽에만 개선이 들어가는 사고를 막으려고 한 곳에 둔다.
function drawFaceParts(ctx, { parts, manifest, W, blink, gaze }) {
_partsRef = parts; _ctxRef = ctx;
const drawXY = (n, dx, dy) => parts[n] && ctx.drawImage(parts[n], dx, dy);
const bR = manifest.browRange || 0;
const browUp = -bR * Math.min(1, W("browinnerup") + (W("browouterupleft") + W("browouterupright")) / 2)
+ bR * 0.8 * (W("browdownleft") + W("browdownright")) / 2; // 찡그림은 반대로 내림
// 눈썹 각도 — 위아래 이동만으로는 화남·슬픔이 구분되지 않는다(둘 다 같은 눈썹).
// 안쪽 끝을 내리면 찡그림(화남), 올리면 팔자(슬픔·무서움)가 된다. 좌우 대칭이라 부호 반전.
const browTilt = (W("browdownleft") + W("browdownright")) / 2 * BROW_TILT
- W("browinnerup") * BROW_TILT * 0.8;
drawBrow("brow_L", browUp, +browTilt, manifest);
drawBrow("brow_R", browUp, -browTilt, manifest);
// 눈 — 이진 교체 대신 연속 눈꺼풀. lid 0=완전히 뜸, 1=완전히 감김.
// eyeSquint(눈웃음)는 부분적으로 감기게, eyeWide(놀람·무서움)는 음수로 더 뜨게 한다.
const squint = (W("eyesquintleft") + W("eyesquintright")) / 2;
const wide = (W("eyewideleft") + W("eyewideright")) / 2;
const lid = clamp01(Math.max(blink, squint * 0.8) - wide * 0.35);
drawEyes(ctx, parts, drawXY, lid, gaze, manifest);
}
function drawChar2D(ctx, { parts, manifest, W, blink, gaze, warp, clearBg = true, head }) {
if (!parts.base || !manifest) return false;
const warpOn = !!(warp && warp.ready);
ctx.clearRect(0, 0, 512, 512);
// head 를 주면 캔버스 자체를 변형한다 — CSS 변환이 안 먹는 경로(캔버스를 drawImage 로
// 합성하는 녹화 등)용. CSS 래퍼를 쓰는 페이지는 head 를 넘기지 말 것(이중 적용).
if (head) {
ctx.save();
ctx.translate(256 + head[0] * HEAD_SHIFT * 5.12, 256 + head[1] * HEAD_SHIFT * 5.12);
ctx.rotate(head[2]);
ctx.translate(-256, -256);
}
const draw = (n, dy = 0) => parts[n] && ctx.drawImage(parts[n], 0, dy);
const drawXY = (n, dx, dy) => parts[n] && ctx.drawImage(parts[n], dx, dy);
_partsRef = parts; _ctxRef = ctx; // drawBrow 가 쓰는 프레임 지역 참조
// 워프가 켜져 있으면 base 는 WebGL 레이어가 그리므로 여기선 생략
if (!warpOn) {
if (clearBg) { ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, 512, 512); }
draw("base");
}
drawFaceParts(ctx, { parts, manifest, W, blink, gaze });
const jawDy = warp ? warp.jawOverlayDy(W("jawopen"), warpOn, manifest)
: W("jawopen") * (manifest.jawDrop || 8);
drawVectorMouth(ctx, W, manifest, jawDy);
if (head) ctx.restore();
return warpOn;
}
// ---------- 3D 헤드 마운트 (mark·claire·RPM 공용) ----------
// 씬에 GLB 를 얹고 ① 모프 구동 대상 수집 ② 눈알 구체(필요 시) ③ 카메라 프레이밍까지 한 번에.
// three.js 는 페이지가 importmap 으로 로드하므로 THREE 를 주입받는다(코어는 클래식 스크립트).
//
// cfg: { eyeSpheres, headFrac } — eyeSpheres 는 안구 메시가 없는 석고 헤드용,
// headFrac 는 모델 높이 중 머리가 차지하는 비율(석고=1 머리만 / RPM≈0.22 반신).
// 반환: { sceneNode, morphMeshes, eyes } — morphMeshes 는 {inf, map} 배열로,
// RPM 처럼 머리·눈·이빨이 각각 모프를 가진 모델도 전부 구동된다(하나만 쓰면 눈·이빨이 따로 논다).
function mountHead3D(THREE, { group, camera, gltf, cfg, prev }) {
// 검사를 먼저 — 실패 시 이전 헤드를 건드리지 않아야 호출측의 "실패하면 이전 상태 유지"가 성립한다.
const sceneNode = gltf.scene;
const morphMeshes = [];
sceneNode.traverse(o => {
if (!o.isMesh || !o.morphTargetInfluences) return;
o.frustumCulled = false; // 모프로 변형되면 원래 바운딩을 벗어나 컬링될 수 있다
morphMeshes.push({ inf: o.morphTargetInfluences,
map: Object.entries(o.morphTargetDictionary || {}).map(([n, i]) => [norm(n), i]) });
});
if (!morphMeshes.length) return null; // 표정 모프가 없는 모델 — 호출측이 상태 표시
if (prev) {
if (prev.sceneNode) group.remove(prev.sceneNode);
// 눈알 구체는 헤드마다 새로 만드므로 교체 시 GPU 자원을 해제한다(반복 전환 시 누수).
(prev.eyes || []).forEach(e => {
group.remove(e);
e.traverse(o => { if (o.isMesh) { o.geometry.dispose(); o.material.dispose(); } });
});
}
group.position.set(0, 0, 0);
group.updateMatrixWorld(true); // .position 변경을 matrixWorld 에 즉시 반영 —
// 안 하면 setFromObject 가 이전 헤드 오프셋을 물어 bbox 가 어긋난다
group.add(sceneNode);
const box = new THREE.Box3().setFromObject(sceneNode);
const c = box.getCenter(new THREE.Vector3()), s = box.getSize(new THREE.Vector3());
const eyes = cfg.eyeSpheres ? [makeEyeball(THREE, group, 1, box), makeEyeball(THREE, group, -1, box)] : [];
// 프레이밍: 석고는 전체가 머리 / 반신 모델은 상단 headFrac 만큼을 머리로 보고 맞춘다
const headH = s.y * cfg.headFrac;
const focusY = cfg.headFrac === 1 ? c.y : box.max.y - headH * 0.42;
group.position.set(-c.x, -focusY, -c.z); // 헤드·눈 함께 recenter
camera.position.set(0, 0, headH * 2.0); camera.lookAt(0, 0, 0);
camera.near = headH / 100; camera.far = headH * 100; camera.updateProjectionMatrix();
return { sceneNode, morphMeshes, eyes };
}
// 눈알 구체 — 안구 메시가 없는 석고 헤드(mark·claire)의 소켓을 채운다.
// 위치·크기는 mark 로 보정한 bbox 분율이라 헤드 크기가 달라도 따라간다.
function makeEyeball(THREE, group, sign, box) {
const c = box.getCenter(new THREE.Vector3()), s = box.getSize(new THREE.Vector3());
const k = s.y / 41.5; // mark 기준 스케일
const eg = new THREE.Group();
eg.position.set(c.x + sign * 0.111 * s.x, c.y + 0.151 * s.y, c.z + 0.284 * s.z);
const mat = (color, roughness) => new THREE.MeshStandardMaterial({ color, roughness });
const sclera = new THREE.Mesh(new THREE.SphereGeometry(1.15 * k, 32, 16), mat(0xf2f0ee, 0.35));
const iris = new THREE.Mesh(new THREE.SphereGeometry(0.46 * k, 24, 12), mat(0x44546e, 0.3));
iris.position.z = 0.78 * k;
const pupil = new THREE.Mesh(new THREE.SphereGeometry(0.2 * k, 16, 8), mat(0x0c0c0c, 0.4));
pupil.position.z = 1.0 * k;
eg.add(sclera, iris, pupil);
group.add(eg);
return eg;
}
// 모프 구동 — mountHead3D 가 준 morphMeshes 에 채널값을 쓴다. blink 는 별도(자동 깜빡임과 max 결합).
function applyMorphs(morphMeshes, smooth, blink) {
for (const { inf, map } of morphMeshes) {
for (const [k, idx] of map) {
inf[idx] = k === "eyeblinkleft" || k === "eyeblinkright" ? blink : (smooth[k] || 0);
}
}
}
// ---------- 아이리스 시선 (478점 랜드마크의 홍채 10점 → [-1..1] 근사) ----------
// 홍채 중심이 눈꼬리(가로)·눈꺼풀(세로) 기준 어디 있는지의 비율 — 머리 회전에 1차 자체 보정.
// 눈을 거의 감으면(개방도 < 0.28) null — 호출측이 직전 시선을 유지하게 한다(깜빡임 간섭 차단).
function irisGaze(lm) {
if (!lm || lm.length < 478) return null;
const eye = (iris0, c0, c1, top, bot) => {
let ix = 0, iy = 0;
for (let i = iris0; i < iris0 + 5; i++) { ix += lm[i].x / 5; iy += lm[i].y / 5; }
const halfW = Math.abs(lm[c1].x - lm[c0].x) / 2 || 1e-6;
const h = Math.abs(lm[bot].y - lm[top].y);
const cx = (lm[c0].x + lm[c1].x) / 2, cy = (lm[top].y + lm[bot].y) / 2;
return { gx: (ix - cx) / halfW, gy: (iy - cy) / Math.max(h, 1e-6), open: h / halfW };
};
const L = eye(468, 33, 133, 159, 145); // 영상 기준 왼눈
const R = eye(473, 362, 263, 386, 374); // 영상 기준 오른눈
if ((L.open + R.open) / 2 < 0.28) return null; // 감김 — 세로 신호 무의미
return [(L.gx + R.gx) / 2, (L.gy + R.gy) / 2];
}
// 머리 회전 부호 — 거울 방향은 렌더러와 무관하게 공통이라 코어에서 한 번만 정한다.
// yaw·roll 이 음수인 이유: 웹캠 영상은 거울 반전해 쓰므로(분석 패널 scale(-1,1)) 가로 성분은
// 뒤집어야 사용자가 왼쪽을 향할 때 캐릭터도 화면 왼쪽을 향한다 — 시선 gx 와 같은 규약(아래 gazeX 항).
// pitch(상하)는 반전과 무관해 그대로. 어긋나 보이면 ?hs=y,p,r 로 실험 후 이 기본값을 고친다.
const HEAD_SIGN = (() => {
const def = [-1, 1, -1];
const v = (new URLSearchParams(location.search).get("hs") || "").split(",").map(Number);
return def.map((d, i) => (Number.isFinite(v[i]) && v[i] !== 0 ? v[i] : d));
})();
// ---------- 웹캠 표정 미러링 (MediaPipe FaceLandmarker 블렌드셰이프 52채널) ----------
// 브라우저 전용(서버·GPU 추론 불필요, github.io OK). 채널 이름이 ARKit 표준이라 lowercase 로
// 렌더러 W() 채널과 1:1. 시작 시 30프레임 중립 캘리브레이션 후 상대값만 전이(drawface 정규화).
// 사용: const mirror = makeMirror({ onStatus }); 렌더 루프에서 mirror.apply(smooth, now) 한 줄.
// 머리 회전은 산만해서 전이하지 않는다(표정 채널만). gain 기본값은 말하기 수준 벌림 보정 —
// 페이지별 오버라이드 가능하나 6페이지 실측에서 동일 값이 맞았다.
function makeMirror({ gain, onStatus } = {}) {
gain = gain || { jawopen: 1.6, mouthsmileleft: 1.4, mouthsmileright: 1.4 };
const st = { on: false, w: null, neutral: null, samples: [], gsamples: [], gN: [0, 0], head: null, hN: null, frame: 0 };
let lm = null, video = null, lastT = -1;
const say = (msg, err) => onStatus && onStatus(msg, err);
async function start() {
if (!lm) {
say(T().mirrorLoading);
const mp = await import("https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.17");
const vision = await mp.FilesetResolver.forVisionTasks(
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.17/wasm");
// 얼굴 메시 토폴로지는 MediaPipe 가 상수로 들고 있다 — 우리가 삼각분할할 필요가 없다.
// 미리보기 오버레이(makeMirrorPanel)가 debug().mesh 로 받아 선으로 그린다.
st.mesh = mp.FaceLandmarker.FACE_LANDMARKS_TESSELATION || null;
lm = await mp.FaceLandmarker.createFromOptions(vision, {
baseOptions: { modelAssetPath: "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task", delegate: "GPU" },
runningMode: "VIDEO", numFaces: 1, outputFaceBlendshapes: true,
outputFacialTransformationMatrixes: true, // 머리 회전(yaw·pitch·roll) 추출용
});
}
video = document.createElement("video");
video.muted = true;
video.srcObject = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } });
await video.play();
Object.assign(st, { on: true, w: null, neutral: null, samples: [], gsamples: [], gN: [0, 0], head: null, hN: null, frame: 0 });
say(T().mirrorCalib);
}
function stop() {
st.on = false; st.w = null;
video?.srcObject?.getTracks().forEach(t => t.stop());
video = null;
say("");
}
// 머리 회전: MediaPipe 얼굴 변환행렬(열 우선 4x4) → yaw·pitch·roll(rad).
// 중립 캘리브레이션 기준 편차만 쓰고, 강한 평활(0.88)+클램프로 산만함을 억제한다.
function headFromMatrix(m) {
if (!m || st.samples) { st.head = null; return; } // 캘리브레이션 중엔 머리 미산출
// 열 우선(col*4+row): m[8]=R02, m[9]=R12, m[10]=R22, m[1]=R10, m[5]=R11.
// 표준 Tait-Bryan(Y-X-Z) 추출 — 축 혼선 없이 yaw/pitch/roll 분리.
const pitch = Math.asin(Math.max(-1, Math.min(1, -m[9])));
const yaw = Math.atan2(m[8], m[10]);
const roll = Math.atan2(m[1], m[5]);
const cur = [yaw, pitch, roll];
if (!st.hN) st.hN = cur.slice(); // 캘리브 직후 첫 프레임 = 중립 자세
const rel = cur.map((v, i) => v - st.hN[i]);
const cl = (v, lim) => Math.max(-lim, Math.min(lim, v));
const t = [cl(rel[0], 0.5), cl(rel[1], 0.35), cl(rel[2], 0.35)];
st.head = st.head ? st.head.map((v, i) => 0.88 * v + 0.12 * t[i]) : t;
}
const LOOK = ["eyelookoutright", "eyelookinleft", "eyelookoutleft", "eyelookinright",
"eyelookdownleft", "eyelookdownright", "eyelookupleft", "eyelookupright"];
function tick(now) {
if (!st.on || !video || video.readyState < 2) return;
if (video.currentTime === lastT) return; // 새 비디오 프레임에서만 추론
lastT = video.currentTime;
st.frame++; // 패널이 "새 프레임일 때만" 다시 그리도록
const res = lm.detectForVideo(video, now);
const cats = res.faceBlendshapes?.[0]?.categories;
if (!cats) { st.w = null; st.lm = null; return; } // 얼굴 놓침 → 개입 중단(자연 복귀)
st.lm = res.faceLandmarks?.[0] || null; // 분석 패널(비교군 시각화)용 원본 랜드마크
headFromMatrix(res.facialTransformationMatrixes?.[0]?.data);
const raw = {};
for (const c of cats) raw[c.categoryName.toLowerCase()] = c.score;
const g = irisGaze(res.faceLandmarks?.[0]); // 아이리스 정밀 시선 (감김이면 null)
if (!st.neutral) { // 30프레임 평균 = 중립
st.samples.push(raw);
if (g) st.gsamples.push(g);
// 캘리브레이션 중 움직이면 중립이 오염되므로 진행 상황을 안내 (6프레임마다 갱신)
if (st.samples.length % 6 === 1) say(T().mirrorCalibCount(st.samples.length));
if (st.samples.length < 30) return;
const n = {};
for (const k in raw) n[k] = st.samples.reduce((a, s) => a + (s[k] || 0), 0) / st.samples.length;
// 시선은 아이리스 합성값으로 대체되므로 eyeLook 채널의 블렌드셰이프 중립은 0으로 —
// 합성값(이미 게인·클램프 적용)이 표준 파이프라인(중립차감·EMA)을 그대로 통과하게 한다.
for (const k of LOOK) n[k] = 0;
st.gN = st.gsamples.length
? [st.gsamples.reduce((a, v) => a + v[0], 0) / st.gsamples.length,
st.gsamples.reduce((a, v) => a + v[1], 0) / st.gsamples.length]
: [0, 0];
st.neutral = n;
st.samples = null; st.gsamples = null; // 캘리브레이션 끝 — 샘플 버퍼 해제
say(T().mirrorOn);
return;
}
// 아이리스 시선 → eyeLook 8채널 합성 덮어쓰기 (블렌드셰이프 시선치는 거칠어서 대체).
// 부호: 거울 느낌 — 사용자가 화면 왼쪽을 보면 캐릭터 눈동자도 화면 왼쪽으로.
if (g) {
const cl = v => Math.max(-1, Math.min(1, v));
const gx = cl(-(g[0] - st.gN[0]) * (gain.gazeX ?? 2.4));
const gy = cl((g[1] - st.gN[1]) * (gain.gazeY ?? 1.4)); // 세로는 눈꺼풀 가림 탓 신호 약함 → 낮은 게인
raw.eyelookoutright = raw.eyelookinleft = Math.max(0, gx);
raw.eyelookoutleft = raw.eyelookinright = Math.max(0, -gx);
raw.eyelookdownleft = raw.eyelookdownright = Math.max(0, gy);
raw.eyelookupleft = raw.eyelookupright = Math.max(0, -gy);
} else {
for (const k of LOOK) delete raw[k]; // 깜빡임 등 — 직전 시선(EMA) 유지
}
const w = st.w || {};
for (const k in raw) {
if (k === "_neutral") continue;
const n = st.neutral[k] || 0;
const cal = clamp01((raw[k] - n) / Math.max(0.2, 1 - n) * (gain[k] || 1));
w[k] = 0.55 * (w[k] || 0) + 0.45 * cal; // EMA 평활
}
st.w = w;
}
return {
get on() { return st.on; },
start, stop,
// 렌더 루프 한 줄: 추론 tick + smooth 에 max-결합
apply(smooth, now) {
tick(now);
if (st.w) for (const k in st.w) smooth[k] = Math.max(smooth[k] || 0, st.w[k]);
},
// 머리 회전 [yaw, pitch, roll] (rad, 중립 대비·평활·클램프·부호적용됨). 미검출/미시작이면 null.
// 페이지가 켤지 말지만 결정한다 — 축 매핑은 페이지 몫, 부호(거울 방향)는 전 페이지 공통이라 여기서.
head: () => st.head && st.head.map((v, i) => HEAD_SIGN[i] * v),
// 분석 패널용: 원본 비디오 + 478점 랜드마크 + 캘리브레이션된 채널값(캐릭터 구동값과 동일)
debug: () => ({ video, lm: st.lm, w: st.w, frame: st.frame, mesh: st.mesh }),
};
}
// ---------- 상태줄 setter ----------
function bindStatus(el) {
return (msg, isError) => { el.textContent = msg; el.className = isError ? "error" : ""; };
}
// ---------- 마이크 음성인식 (Web Speech API — 브라우저 내장, 모델 불필요) ----------
// 미지원 브라우저(Chrome 계열 외)면 null 반환 → 페이지가 마이크 버튼을 숨긴다.
// onText(텍스트, isFinal): 인식 중간결과(false)와 최종결과(true)를 모두 전달.
function makeMic({ lang = "ko-KR", onText, onState }) {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) return null;
const rec = new SR();
rec.lang = lang;
rec.interimResults = true; // 말하는 도중에도 텍스트를 보여줌
rec.continuous = false; // 한 문장 말하면 자동 종료 (푸시투토크 방식)
let listening = false;
rec.onstart = () => { listening = true; onState && onState("listening"); };
rec.onend = () => { listening = false; onState && onState("idle"); };
rec.onerror = e => { listening = false; onState && onState("error", e.error); };
rec.onresult = e => {
let fin = "", interim = "";
for (let i = e.resultIndex; i < e.results.length; i++) {
const r = e.results[i];
if (r.isFinal) fin += r[0].transcript; else interim += r[0].transcript;
}
if (fin.trim()) onText(fin.trim(), true);
else if (interim) onText(interim, false);
};
return {
toggle() { if (listening) rec.stop(); else { try { rec.start(); } catch (_) {} } },
start() { if (!listening) { try { rec.start(); } catch (_) {} } },
stop() { if (listening) rec.stop(); },
};
}
// ---------- 대화 (LLM 응답 — 로컬 서버 전용) ----------
// 반환: {reply, emotion}. emotion 은 EMOTIONS 키 중 하나(LLM 판단). 실패 시 throw.
async function chat(text, history, persona) {
const res = await fetch("/api/chat", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, history: history || [], persona: persona || null }),
});
if (!res.ok) throw new Error((await res.json()).detail || res.statusText);
return res.json();
}
// ---------- 대화 모드 글루 (마이크/입력 → LLM → 아바타 응답; puppet·studio3d 공용) ----------
// history 상태 + 채팅로그(addTurn) + runChat + 모드 토글 + 지우기 + 마이크 배선을 소유.
// 페이지 주입: speak(text,emotion) 발화 함수, botName(봇 턴 화자명), placeholderOn(대화 모드 안내문),
// logEl/chatModeEl/clearBtnEl/textEl/sendEl/micBtnEl/formEl 엘리먼트, statusSet(bindStatus 결과),
// getPersona()(선택 — 현재 캐릭터 성격; 없으면 기본 정체성). placeholderOff 는 입력창 초기 placeholder 재사용.
// 반환 { runChat } — onsubmit 의 chat|say 분기는 페이지가 얇게 소유(runChat 알맹이만 코어).
function makeChat({ speak, logEl, botName, placeholderOn, chatModeEl, clearBtnEl, textEl, sendEl, micBtnEl, formEl, statusSet, getPersona, audioEl }) {
const history = []; // [{role, content}] 최근 턴만 유지
let handsFree = false, busy = false; // 연속 대화: 응답 재생이 끝나면 자동 재청취
const placeholderOff = textEl.placeholder;
function addTurn(who, text, cls) {
const div = document.createElement("div");
div.className = "turn";
div.innerHTML = `${who}`;
div.lastChild.textContent = text; // 사용자 입력이므로 textContent로 안전하게
logEl.appendChild(div);
logEl.scrollTop = logEl.scrollHeight;
}
chatModeEl.onchange = () => {
const on = chatModeEl.checked;
logEl.style.display = on ? "grid" : "none";
clearBtnEl.style.display = on ? "" : "none";
sendEl.textContent = on ? "말 걸기" : "말하기";
textEl.placeholder = on ? placeholderOn : placeholderOff;
};
clearBtnEl.onclick = () => { history.length = 0; logEl.innerHTML = ""; statusSet(""); };
async function runChat(text) {
busy = true; // 아바타가 생각·발화하는 동안 재청취 금지 (자기 목소리 인식 방지)
try {
addTurn("나", text, "me");
statusSet("생각 중…");
const { reply, emotion } = await chat(text, history.slice(-6), getPersona && getPersona()); // 최근 3턴 + 캐릭터 성격
history.push({ role: "user", content: text }, { role: "assistant", content: reply });
addTurn(botName, reply, "bot");
statusSet("말하는 중…");
await speak(reply, emotion);
// speak 는 재생 시작 시점에 반환 — 연속 대화면 재생이 실제로 끝날 때까지 대기 후 재청취.
// 주입된 audioEl 만 보면 안 된다 — 영상 경로는 오디오가 mp4 안에 있어