kim commited on
Commit
4635d45
·
verified ·
1 Parent(s): 5c37e22

sync from a4623ed60bfdc48500dc98de93020c59fe69bf34

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.gitattributes CHANGED
@@ -33,3 +33,15 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets3d/claire.glb filter=lfs diff=lfs merge=lfs -text
37
+ assets3d/mark.glb filter=lfs diff=lfs merge=lfs -text
38
+ img/auto_visemes.png filter=lfs diff=lfs merge=lfs -text
39
+ img/flp/fail_handdrawn.png filter=lfs diff=lfs merge=lfs -text
40
+ img/flp_illust_ok.png filter=lfs diff=lfs merge=lfs -text
41
+ img/flp_painting_ok.png filter=lfs diff=lfs merge=lfs -text
42
+ img/pig_states.png filter=lfs diff=lfs merge=lfs -text
43
+ img/sprite_vs_flp.gif filter=lfs diff=lfs merge=lfs -text
44
+ img/stick_auto.png filter=lfs diff=lfs merge=lfs -text
45
+ img/studio_5.gif filter=lfs diff=lfs merge=lfs -text
46
+ img/studio_live.gif filter=lfs diff=lfs merge=lfs -text
47
+ img/studio_sync.gif filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -1,10 +1,15 @@
1
  ---
2
- title: Drawface Live
3
- emoji: 🚀
4
- colorFrom: purple
5
- colorTo: yellow
6
  sdk: static
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
1
  ---
2
+ title: DrawFace Live
3
+ emoji: 🪞
4
+ colorFrom: gray
5
+ colorTo: indigo
6
  sdk: static
7
+ app_file: index.html
8
  pinned: false
9
+ short_description: 웹캠 표정을 손그림·3D 캐릭터가 실시간으로 따라 합니다
10
  ---
11
 
12
+ 웹캠 앞 표정을 그림이 그대로 따라 하는 데모. 얼굴 인식(MediaPipe)부터 렌더까지
13
+ **전부 브라우저 안에서** 처리되고 영상은 어디로도 전송되지 않습니다.
14
+
15
+ 소스와 기술 문서: https://github.com/ingon1/drawface-live
assets3d/LICENSE-claire.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ claire head (claire.glb)
2
+ Derived from claire_arkit_v2_topo1.ma in NVIDIA Maya-ACE (https://github.com/NVIDIA/Maya-ACE)
3
+ Blendshape geometry: nvidia/Audio2Face-3D-v2.3-Claire (HuggingFace).
4
+
5
+ MIT License
6
+ Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved.
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy
9
+ of this software and associated documentation files (the "Software"), to deal
10
+ in the Software without restriction, including without limitation the rights
11
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
+ copies of the Software, and to permit persons to whom the Software is
13
+ furnished to do so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
assets3d/LICENSE-mark.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ mark head (mark.glb)
2
+ Derived from mark_arkit_v2_topo1.ma in NVIDIA Maya-ACE (https://github.com/NVIDIA/Maya-ACE)
3
+
4
+ MIT License
5
+ Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved.
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
assets3d/claire.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5f2a96cd63604f8abe4a3f369afe7beb93341d5c53af9705d7a4c2900bdd687d
3
+ size 1928800
assets3d/mark.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:efc715a341e24d8a166690d6525e6e1f83a4b3b7881fcd9dad0b2d9791eb07e5
3
+ size 1839324
avatar_core.js ADDED
@@ -0,0 +1,1256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* avatar_core.js — talking-drawing-avatar 리포 static/avatar_core.js 의 vendored 사본.
2
+ * 여기서 수정하지 말 것 — 원본에서 고친 뒤 아래로 갱신한다:
3
+ * ./scripts/sync_avatar_core.sh (이 헤더는 보존하고 본문만 갱신)
4
+ * ./scripts/sync_avatar_core.sh --check (뒤처졌는지 확인 — CI 가 매번 돌린다)
5
+ * 이 리포에서는 index.html(미러링 스튜디오)이 소비한다.
6
+ */
7
+ window.AvatarCore = (() => {
8
+
9
+ // ---------- 내부 유틸 ----------
10
+ const norm = s => s.toLowerCase().replace(/[_\-\s]/g, "");
11
+ const clamp01 = v => Math.min(1, Math.max(0, v)); // 0~1 로 자르기
12
+ const avgLR = (W, base) => (W(base + "left") + W(base + "right")) / 2; // 좌우 채널 평균
13
+ const roundness = W => Math.max(W("mouthpucker"), W("mouthfunnel")); // 오므림 세기
14
+ const WARP_JAW_G = Math.exp(-(38 * 38) / (2 * 55 * 55)); // jaw 변위장을 입 앵커(38px 위)에서 평가한 가우시안 (시그마 55 = 워프 셰이더와 동일)
15
+
16
+ // 텍스트 감정 추론 (발화 시 자동 프리셋 — 세 페이지 동일 규칙)
17
+ // 감정 사전: [정규식, 가중치]. 어간 문자클래스로 활용형을 함께 잡는다(슬프/슬퍼/슬펐/슬픈…).
18
+ // 순서가 아니라 점수 합으로 뽑으므로 "ㅋㅋ 대박 웃겨"(joy 4.0 vs surprise 1.6)처럼 겹쳐도 옳게 갈린다.
19
+ const EMO_RULES = {
20
+ joy: [
21
+ [/ㅋ{2,}|ㅎ{2,}/g, 2.2], [/하하|호호|ㅍㅎ|웃[겨긴음었]/g, 1.8],
22
+ [/신[나난났]|행복|기[쁘뻐뻤쁜]|즐거|좋[아다은네았]|최고|사랑|반[가갑]|재[밌미]|고마[워웠운]|감사|😊|😄|🎉|👍|❤/g, 1.4],
23
+ [/축하|성공|해냈|굿|짱/g, 1.2], [/!/g, 0.35],
24
+ ],
25
+ sad: [
26
+ [/[ㅠㅜ]{2,}/g, 2.2],
27
+ [/슬[프퍼펐픈]|우울|눈물|울[고었]|외로|쓸쓸|속상|서운|아쉽|안타깝|그립|😢|😭/g, 1.6],
28
+ [/힘[들듦드]|아[파프픈]|지[친쳐쳤]|미안|죄송|망했|실패|포기/g, 1.2], [/\.{3,}/g, 0.4],
29
+ ],
30
+ angry: [
31
+ [/화[나난가났]|짜증|열받|빡[쳐치친]|분노|억울|싫[어다은]|그만해|😠|😡/g, 1.8],
32
+ [/최악|엉망|어이없|말도 안|참[나내]/g, 1.4],
33
+ ],
34
+ surprise: [
35
+ [/헉|깜짝|놀[라랐랍]|대박|세상에|어머|웬일|믿[을기]\s*수\s*없|😲|😮/g, 1.6],
36
+ [/[?!]{2,}/g, 1.0], [/진짜\?|정말\?|\?/g, 0.3],
37
+ ],
38
+ fear: [
39
+ [/무[서섭섰]|겁[나난났이]|소름|섬뜩|오싹|공포|끔찍|😱|😨/g, 1.8],
40
+ [/떨[려린렸]|불안|어떡해|어쩌지|살려/g, 1.2],
41
+ ],
42
+ shy: [
43
+ [/부끄|쑥스|민망|수줍|창피|😳|☺️/g, 1.8],
44
+ [/어머나|아이참|헤헤|히히/g, 1.0],
45
+ ],
46
+ };
47
+ // 부정 표현 — "안 좋아", "좋지 않아", "재미없어"는 기쁨이 아니다.
48
+ const NEGATION = /안\s*(좋|기쁘|행복|즐거|반가)|(좋|기쁘|행복|즐겁)지\s*(않|못)|재미\s*없|별로|싫증/;
49
+
50
+ /** 텍스트 → {emo, intensity} | null. 모델 없이 점수 사전으로 추론(정적 데모에서도 동작). */
51
+ function inferEmotion(text) {
52
+ const score = {};
53
+ for (const emo in EMO_RULES) score[emo] = 0; // 사전에 감정 추가 시 자동 반영
54
+ for (const emo in EMO_RULES) {
55
+ for (const [re, w] of EMO_RULES[emo]) {
56
+ const m = text.match(re);
57
+ if (m) score[emo] += w * Math.min(m.length, 3); // 반복 강조는 3회까지만 가산
58
+ }
59
+ }
60
+ if (NEGATION.test(text)) { score.joy = 0; score.sad += 1.0; }
61
+ let best = null, top = 0;
62
+ for (const k in score) if (score[k] > top) { top = score[k]; best = k; }
63
+ if (!best || top < 1.0) return null; // 신호가 약하면 중립 유지
64
+ return { emo: best, intensity: Math.min(1, 0.45 + top * 0.16) }; // 0.45~1.0
65
+ }
66
+
67
+ // ---------- LLM 감정 분류 (규칙 매칭의 상위 경로) ----------
68
+ // inferEmotion 정규식은 한국어 10문장 벤치에서 1/10 이었다 — 상주 LLM 에 물어본다.
69
+ // 실패·타임아웃·503 은 전부 null 로 접어서 호출측이 규칙으로 떨어지게 한다.
70
+ async function classifyEmotion(text, { timeoutMs = 4000 } = {}) {
71
+ const ctl = new AbortController();
72
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
73
+ try {
74
+ const res = await fetch("/api/emotion", {
75
+ method: "POST", headers: { "Content-Type": "application/json" },
76
+ body: JSON.stringify({ text }), signal: ctl.signal,
77
+ });
78
+ if (!res.ok) return null;
79
+ const segs = (await res.json()).segments;
80
+ return Array.isArray(segs) && segs.length ? segs : null;
81
+ } catch {
82
+ return null; // 오프라인·중단·파싱 실패 — 규칙 폴백
83
+ } finally {
84
+ clearTimeout(timer);
85
+ }
86
+ }
87
+
88
+ // ---------- 감정 → 목소리 톤 (얼굴만 웃고 목소리는 무표정한 괴리 해소) ----------
89
+ // 비율값: rate=속도, pitch=음높이, volume=크기. edge-tts("+10%"/"+14Hz")와 브���우저 TTS(배수) 양쪽에서 사용.
90
+ const VOICE_STYLE = {
91
+ joy: { rate: 0.10, pitch: 0.16 },
92
+ sad: { rate: -0.12, pitch: -0.12 },
93
+ angry: { rate: 0.06, pitch: -0.06, volume: 0.15 },
94
+ surprise: { rate: 0.08, pitch: 0.22 },
95
+ fear: { rate: 0.10, pitch: 0.10, volume: -0.10 }, // 빠르고 떨리는 작은 소리
96
+ shy: { rate: -0.06, pitch: 0.06, volume: -0.15 }, // 작고 조심스럽게
97
+ neutral: {},
98
+ };
99
+ function voiceProsody(emo, intensity = 1) {
100
+ const s = VOICE_STYLE[emo] || VOICE_STYLE.neutral;
101
+ return { rate: (s.rate || 0) * intensity, pitch: (s.pitch || 0) * intensity,
102
+ volume: (s.volume || 0) * intensity };
103
+ }
104
+
105
+ // 지수 평활 (무할당, in-place). target 에만 있는 새 키는 0에서 출발.
106
+ function smoothStep(smooth, target) {
107
+ for (const k in target) if (!(k in smooth)) smooth[k] = 0;
108
+ for (const k in smooth) smooth[k] = 0.42 * smooth[k] + 0.58 * (target[k] || 0);
109
+ }
110
+
111
+ // 재생 중 발화 프레임 → 채널 가중치 (puppet·studio3d; docs 는 브라우저 TTS 타임라인이라 자체 구현)
112
+ function weightsFromAnim(anim, audio) {
113
+ const w = {};
114
+ if (anim && !audio.paused && !audio.ended) {
115
+ const f = anim.frames[Math.min(Math.floor(audio.currentTime * anim.fps), anim.frames.length - 1)];
116
+ for (const [key, col] of anim.index) w[key] = f[col];
117
+ }
118
+ return w;
119
+ }
120
+
121
+ // ---------- 엔진 출력 셰이핑 (발화당 1회) ----------
122
+ // A2F·NeuroSync 둘 다 턱을 거의 안 벌리고(jawOpen 최대 0.31~0.40 실측) 발화 내내 켜져 있는
123
+ // 편향 채널이 있다(A2F jawRight 평균 0.17, NeuroSync browInnerUp 0.145). 엔진 쪽에는 세기
124
+ // 손잡이가 없어서(A2F model.json 은 경로 설정뿐) 받은 프레임 전체를 여기서 정규화한다.
125
+ const SHAPE = {
126
+ a2f: { gain: { jawopen: 2.3 }, kill: ["jawright", "jawleft"] },
127
+ // browInnerUp·browOuterUp·eyeWide 는 NeuroSync가 발화 내내 놀란 표정을 상시 띄우는
128
+ // 원인(상시 켜짐이 아니라 높은 값 주변에서 진동) — 게인<1로 눌러야 목표(평균<=0.05)에 닿는다.
129
+ // 0.35/0.25 는 채널 프로브 실측(문장1, 자음·무음 섞인 발화)에서 평균이 0.047~0.060 으로
130
+ // 목표(10% 마진 0.0455)에 걸쳐 있었다 — 재현 시 FAIL 이 나올 수 있는 수준이라 더 눌렀다.
131
+ // worst-case(brow 0.060, eyewide 0.050) 기준 0.035 목표(마진선보다 23%↓, TTS 합성 변동
132
+ // 여유분)로 역산: brow 0.35*(0.035/0.060)=0.204→0.20, eyewide 0.25*(0.035/0.050)=0.175→0.18.
133
+ // 감정 프리셋(EMOTIONS.sad/surprise/fear 의 brow*/eyewide* 값 0.6~0.85)은 applyMax 로
134
+ // 별도 max-결합되므로 이 게인과 무관하게 그대로 유지된다(makeEmotion.applyMax 참고).
135
+ neurosync: { gain: { jawopen: 2.8, mouthfunnel: 0.7, browinnerup: 0.20, browouterupleft: 0.20,
136
+ browouterupright: 0.20, eyewideleft: 0.18, eyewideright: 0.18 }, kill: [] },
137
+ };
138
+ const BASELINE_PCT = 10; // 채널별 하위 백분위 = "상시 켜져 있는" 성분
139
+
140
+ function shapeAnim(anim, engine) {
141
+ const prof = SHAPE[engine];
142
+ // 이번 발화에 실제로 적용한 jawopen 게인을 기록 — 3D 모프용 증폭이라 2D(퍼펫)의 입 크기·
143
+ // 몸짓 임계값처럼 원래(증폭 전) 스케일에 맞춰진 소비자가 이걸 나눠 되돌리는 용도.
144
+ // 엔진 미상/증폭 없음이면 1(무보정).
145
+ if (anim) anim.jawGain = (prof && prof.gain.jawopen) || 1;
146
+ if (!prof || !anim || !anim.frames || !anim.frames.length || !anim.index) return anim;
147
+ const kill = new Set(prof.kill);
148
+ for (const [name, col] of anim.index) {
149
+ if (kill.has(name)) {
150
+ for (const f of anim.frames) f[col] = 0;
151
+ continue;
152
+ }
153
+ const vals = anim.frames.map(f => f[col]); // 스냅샷 — 아래서 f[col] 을 덮어써도 원본값 기준으로 계산
154
+ const base = vals.slice().sort((a, b) => a - b)[Math.floor((vals.length - 1) * BASELINE_PCT / 100)];
155
+ const gain = prof.gain[name] || 1;
156
+ if (!base && gain === 1) continue; // 손댈 것 없는 채널은 건너뛴다
157
+ anim.frames.forEach((f, i) => {
158
+ f[col] = clamp01((vals[i] - base) * gain);
159
+ });
160
+ }
161
+ return anim;
162
+ }
163
+
164
+ // ---------- 감정 프리셋 (studio3d 버전이 superset 이라 그것으로 통합) ----------
165
+ const EMOTIONS = {
166
+ neutral: {},
167
+ joy: { mouthsmileleft: 0.55, mouthsmileright: 0.55, cheeksquintleft: 0.45, cheeksquintright: 0.45, eyesquintleft: 0.25, eyesquintright: 0.25 },
168
+ sad: { mouthfrownleft: 0.5, mouthfrownright: 0.5, browinnerup: 0.7, mouthshrugupper: 0.2 },
169
+ angry: { browdownleft: 0.85, browdownright: 0.85, nosesneerleft: 0.4, nosesneerright: 0.4, mouthpressleft: 0.4, mouthpressright: 0.4, jawforward: 0.25 },
170
+ surprise: { browinnerup: 0.6, browouterupleft: 0.75, browouterupright: 0.75, eyewideleft: 0.8, eyewideright: 0.8, jawopen: 0.3 },
171
+ fear: { eyewideleft: 0.7, eyewideright: 0.7, browinnerup: 0.85, mouthstretchleft: 0.35, mouthstretchright: 0.35, jawopen: 0.12 },
172
+ // shy 의 eyelookdown 은 makeGaze 채널 결합을 타고 눈동자도 실제로 내려간다.
173
+ shy: { mouthsmileleft: 0.3, mouthsmileright: 0.3, eyelookdownleft: 0.55, eyelookdownright: 0.55, mouthpressleft: 0.25, mouthpressright: 0.25 },
174
+ };
175
+
176
+ const CROSSFADE_S = 0.25; // 문장 경계 표정 전환 시간
177
+
178
+ // 감정 상태 + 버튼 배선. buttons/activeColor 는 페이지가 주입(2D #5b8cff / 3D #76b900).
179
+ function makeEmotion(buttons, activeColor) {
180
+ let emotion = EMOTIONS.neutral;
181
+ let sticky = true; // 수동 버튼=유지, 자동(발화 감정)=발화 끝나면 중립 복귀
182
+ let hold = 1; // 감정 세기 게이트(0~1). 자동 감정은 유휴 중 감쇠.
183
+ // intensity: 자동 추론 시 감정 세기(0~1)로 프리셋 값을 스케일. 버튼 클릭은 항상 1.
184
+ // 항상 새 객체로 스케일 — 공유 EMOTIONS 프리셋 앨리어싱 회피(v*1===v 라 무손실).
185
+ // isSticky=false(자동 발화 감정)면 발화가 끝난 뒤 표정이 얼어붙지 않고 천천히 풀린다.
186
+ let curKey = "neutral", curInt = 1; // 몸짓 연동용 현재 감정 (current() 로 노출)
187
+ let trackSeg = null, fadeFrom = {}; // 문장별 전환용 크로스페이드 상태
188
+ function setEmotion(key, intensity = 1, isSticky = true) {
189
+ const base = EMOTIONS[key] || EMOTIONS.neutral;
190
+ emotion = {};
191
+ for (const k in base) emotion[k] = base[k] * intensity;
192
+ sticky = isSticky; hold = 1;
193
+ curKey = key; curInt = intensity;
194
+ buttons.forEach(x => x.style.background = x.dataset.emo === key ? activeColor : "#2a2a35");
195
+ }
196
+ buttons.forEach(b => { b.onclick = () => setEmotion(b.dataset.emo); }); // 버튼은 sticky 기본
197
+ return {
198
+ setEmotion,
199
+ // 발화 중 문장이 바뀌면 표정도 바뀐다. track=[{start, emo, intensity}], tSec=audio.currentTime.
200
+ // setEmotion 을 쓰지 않고 직접 섞는 이유: setEmotion 은 새 프리셋으로 통째 교체라
201
+ // 경계에서 이전 표정이 한 프레임에 사라진다(그게 바로 없애려는 팝이다).
202
+ // 문장이 바뀌는 순간의 표정을 박제해 두고 CROSSFADE_S 동안 새 프리셋과 겹쳐 넘긴다.
203
+ followTrack(track, tSec) {
204
+ if (!track || !track.length || sticky) return; // 수동 버튼이 눌렸으면 사용자 의도가 우선
205
+ let seg = track[0];
206
+ for (const t of track) if (tSec >= t.start) seg = t;
207
+ if (seg !== trackSeg) { fadeFrom = emotion; trackSeg = seg; }
208
+ // fadeAt=seg.start: 경계에서 tSec≈seg.start(위 루프가 tSec>=seg.start 인 세그를 고르므로)이고,
209
+ // 첫 호출은 speakWithEmotion 이 이미 세그 0 프리셋을 적용해둔 뒤라 k=1 이 곧바로 맞다.
210
+ // 별도 변수로 관측 시각을 박제하지 않으므로 일시정지 후에도 페이드가 어긋나지 않는다.
211
+ const k = clamp01((tSec - seg.start) / CROSSFADE_S);
212
+ const base = EMOTIONS[seg.emo] || EMOTIONS.neutral;
213
+ const blended = {};
214
+ for (const key in fadeFrom) blended[key] = fadeFrom[key] * (1 - k);
215
+ for (const key in base) blended[key] = (blended[key] || 0) + base[key] * seg.intensity * k;
216
+ emotion = blended;
217
+ hold = 1; // 발화 중 유지, 끝나면 기존대로 감쇠
218
+ curKey = seg.emo; curInt = seg.intensity;
219
+ },
220
+ // 감정 프리셋을 현재 평활값에 max-결합. speaking=발화 중이면 유지, 자동 감정은 유휴 시 ~1.5s 감쇠.
221
+ applyMax(smooth, speaking) {
222
+ hold = (sticky || speaking) ? 1 : hold * 0.98;
223
+ for (const k in emotion) smooth[k] = Math.max(smooth[k] || 0, emotion[k] * hold);
224
+ },
225
+ // 현재 감정과 세기(표정과 같은 hold 감쇠를 공유) — makeHeadWander 몸짓 연동용.
226
+ current() { return { key: curKey, level: curInt * hold }; },
227
+ };
228
+ }
229
+
230
+ // ---------- 깜빡임 (버튼 + 자동) ----------
231
+ // autoBlink:()=>bool, intervalMs:()=>ms 는 매 프레임 라이브 조회. duration/jitter 상수 페이지별
232
+ // (puppet/docs 140·0.6·슬라이더, studio3d 150·0.8·3500). 상태는 클로저에 캡슐화.
233
+ function makeBlink({ autoBlink, intervalMs, duration, jitter }) {
234
+ let blinkUntil = 0, nextAutoBlink = performance.now() + 4000;
235
+ return {
236
+ trigger() { blinkUntil = performance.now() + duration; },
237
+ value(now) {
238
+ if (autoBlink() && now > nextAutoBlink) {
239
+ blinkUntil = now + duration;
240
+ nextAutoBlink = now + intervalMs() * (0.7 + Math.random() * jitter); // 자연스러운 지터
241
+ }
242
+ return now < blinkUntil ? 1 : 0;
243
+ },
244
+ };
245
+ }
246
+
247
+ // ---------- 커서 시선 추적 ----------
248
+ // el 의 pointermove/leave → {gx, gy} (-1..1). 프레임별 평활·엔진채널 결합은 makeGaze 또는 페이지 인라인.
249
+ function makeCursorTracker(el) {
250
+ const s = { gx: 0, gy: 0 };
251
+ el.addEventListener("pointermove", e => {
252
+ const r = el.getBoundingClientRect();
253
+ s.gx = ((e.clientX - r.left) / r.width - 0.5) * 2;
254
+ s.gy = ((e.clientY - r.top) / r.height - 0.5) * 2;
255
+ });
256
+ el.addEventListener("pointerleave", () => { s.gx = 0; s.gy = 0; });
257
+ return s;
258
+ }
259
+
260
+ // ---------- 시선 결합 (슬라이더 > 엔진 채널 > 커서) + 0.15 평활 ----------
261
+ // cursor: makeCursorTracker 결과, mulX/mulY: 커서 배율(puppet·docs 0.9/0.6, studio3d 0.8/0.5).
262
+ // 반환: (sliderVal, W) => [gx, gy]. docs 는 슬라이더가 없어 sliderVal=0 고정으로 호출.
263
+ function makeGaze(cursor, { mulX, mulY }) {
264
+ let gx = 0, gy = 0, sacX = 0, sacY = 0, sacNext = 0;
265
+ return (sliderVal, W) => {
266
+ const now = performance.now();
267
+ const chX = (W("eyelookoutright") + W("eyelookinleft") - W("eyelookoutleft") - W("eyelookinright")) / 2;
268
+ const chY = (W("eyelookdownleft") + W("eyelookdownright") - W("eyelookupleft") - W("eyelookupright")) / 2;
269
+ const manual = Math.abs(sliderVal) > 0.01;
270
+ // 슬라이더·엔진채널·커서 다 없으면 유휴 → 눈동자 미세 saccade(죽은 눈 방지)
271
+ const idle = !manual && !chX && !chY
272
+ && Math.abs(cursor.gx) < 0.02 && Math.abs(cursor.gy) < 0.02;
273
+ if (idle && now > sacNext) {
274
+ sacNext = now + 1200 + Math.random() * 2000;
275
+ const center = Math.random() < 0.35; // 가끔 정면 복귀
276
+ sacX = center ? 0 : (Math.random() - 0.5) * 0.5;
277
+ sacY = center ? 0 : (Math.random() - 0.5) * 0.3;
278
+ }
279
+ // 우선순위: 유휴 saccade, 아니면 슬라이더 > 엔진채널 > 커서
280
+ const tgtX = idle ? sacX : (manual ? sliderVal : (chX || cursor.gx * mulX));
281
+ const tgtY = idle ? sacY : (chY || cursor.gy * mulY);
282
+ gx += (tgtX - gx) * 0.15;
283
+ gy += (tgtY - gy) * 0.15;
284
+ return [gx, gy];
285
+ };
286
+ }
287
+
288
+ // ---------- 머리 워블 (2D: 발화 끄덕임 nod + 느린 표류 wander + 잔잔한 사인) ----------
289
+ // shakeEl 에 CSS 변환 적용. sway 는 페이지가 넘김(발화 중 1, 아니면 0.5). studio3d 는 3D라 미사용.
290
+ // 감정 → 몸짓 계수 (speed 배속·amp 진폭·droop px 아래로·beat 끄덕임 배율). 없는 키(neutral)는 전부 1/0.
291
+ const EMO_MOTION = {
292
+ joy: { speed: 1.25, amp: 1.5, droop: -2, beat: 1.3 }, // 들썩임 커지고 살짝 들림
293
+ sad: { speed: 0.55, amp: 0.5, droop: 9, beat: 0.4 }, // 고개 숙이고 느리고 작게
294
+ angry: { speed: 1.5, amp: 1.25, droop: 0, beat: 1.6 }, // 빠르고 절도 있게
295
+ surprise: { speed: 1.2, amp: 1.3, droop: -4, beat: 1.0 }, // 번쩍 들림
296
+ fear: { speed: 1.45, amp: 0.7, droop: 3, beat: 0.6 }, // 움츠리고 잔떨림
297
+ shy: { speed: 0.8, amp: 0.6, droop: 5, beat: 0.5 }, // 수줍게 숙임
298
+ };
299
+
300
+ // 2D 는 스프라이트라 yaw·pitch 를 회전시킬 수 없다 — 평행이동(%)으로 근사한다.
301
+ // 코어 클램프(±0.5/±0.35rad)에서 최대 ±5%/±3.5% 이동 — 과하면 얼굴이 프레임을 벗어난다.
302
+ const HEAD_SHIFT = 10;
303
+
304
+ function makeHeadWander() {
305
+ let nod = 0, wanderNext = 0, wanderR = 0, wanderY = 0, wanderGoalR = 0, wanderGoalY = 0;
306
+ let beat = 0, beatTilt = 0, lowSince = 0; // 강조 제스처: 구절 시작마다 끄덕임 임펄스
307
+ let phR = 0, phY = 0, phB = 0, last = 0; // 사인 위상 누적 — 감정 배속이 변해도 위상 연속(점프 없음)
308
+ // head: 웹캠 머리 자세 [yaw, pitch, roll] (mirror.head(), 선택). 2D 라 roll 은 실제 기울기,
309
+ // yaw·pitch 는 회전 대신 평행이동으로 근사한다 — 스프라이트를 3D 로 돌릴 수 없으므로.
310
+ return function tick(shakeEl, now, jawopen, sway, emoState, head) {
311
+ const m = emoState && EMO_MOTION[emoState.key], lv = m ? Math.min(emoState.level, 1) : 0;
312
+ const speed = 1 + ((m ? m.speed : 1) - 1) * lv;
313
+ const amp = 1 + ((m ? m.amp : 1) - 1) * lv;
314
+ const beatG = 1 + ((m ? m.beat : 1) - 1) * lv;
315
+ const droop = (m ? m.droop : 0) * lv;
316
+ const dt = last ? Math.min((now - last) / 1000, 0.1) : 0;
317
+ last = now;
318
+ phR += dt * 0.9 * speed; phY += dt * 1.7 * speed; phB += dt * 1.2 * speed;
319
+ nod = 0.85 * nod + 0.15 * jawopen;
320
+ // 조용(≥250ms)하다 입이 열리는 순간 = 구절 시작 → 끄덕임 비트 + 고개 기울임 변주.
321
+ // 매 음절마다가 아니라 pause 뒤 온셋에만 걸려 "말의 리듬"이 됨.
322
+ if (jawopen < 0.1) {
323
+ if (!lowSince) lowSince = now;
324
+ } else {
325
+ if (jawopen > 0.2 && lowSince && now - lowSince > 250) {
326
+ beat = 1;
327
+ beatTilt = (Math.random() - 0.5) * 0.02;
328
+ }
329
+ lowSince = 0;
330
+ }
331
+ beat *= 0.90; // ~0.5s 감쇠
332
+ if (now > wanderNext) {
333
+ wanderNext = now + 2200 + Math.random() * 2500;
334
+ wanderGoalR = (Math.random() - 0.5) * 0.03;
335
+ wanderGoalY = (Math.random() - 0.5) * 5;
336
+ }
337
+ wanderR += (wanderGoalR - wanderR) * 0.02;
338
+ wanderY += (wanderGoalY - wanderY) * 0.02;
339
+ const breath = Math.sin(phB) * 2 * amp; // ~5s 주기 호흡 — sway 무관(유휴에도 숨 쉼)
340
+ // wander 도 amp 로 스케일 — 움츠린 감정(sad/fear)은 표류까지 작아져야 일관됨.
341
+ const rot = Math.sin(phR) * 0.008 * sway * amp + wanderR * amp + nod * 0.015 + beatTilt * beat * beatG;
342
+ const dy = Math.sin(phY) * 1.5 * sway * amp + wanderY * amp + nod * 3 + breath + beat * 7 * beatG + droop;
343
+ // 머리 흔들림은 두 캔버스(WebGL base + 2D 오버레이)를 함께 감싼 래퍼에 CSS 변환으로 적용.
344
+ // transform-origin=center + translateY(% of height) 조합이 기존 ctx translate/rotate와 수학적으로 동일.
345
+ // 웹캠 머리 자세가 있으면 같은 문자열에 합성 — 유휴 흔들림 위에 실제 고개 방향이 얹힌다.
346
+ const hR = head ? head[2] : 0; // 갸웃 = 실제 회전
347
+ const hX = head ? head[0] * HEAD_SHIFT : 0; // 좌우 = 평행이동(%)
348
+ const hY = head ? head[1] * HEAD_SHIFT : 0; // 상하 = 평행이동(%)
349
+ shakeEl.style.transform =
350
+ `rotate(${(rot + hR).toFixed(5)}rad) translate(${hX.toFixed(3)}%, ${(dy / 512 * 100 + hY).toFixed(4)}%)`;
351
+ };
352
+ }
353
+
354
+ // ---------- 스프라이트 입모양 선택기 (개방도 우선 + 히스테리시스) ----------
355
+ // W 를 생성 시 주입. targetMouth 는 puppet superset — docs 발화경로에서 mouthpress·mouthstretch=0 이라 정확히 환원.
356
+ // pick(now) → {cur, prev, fade}: 크로스페이드 상태를 계산해 반환(내부 상태는 노출 안 함).
357
+ function makeMouthPicker(W) {
358
+ let curMouth = "closed", prevMouth = null, switchAt = 0, mouthCand = "closed", candSince = 0;
359
+ const FADE_MS = 90;
360
+ function targetMouth() {
361
+ const jaw = W("jawopen");
362
+ const round = roundness(W);
363
+ const wide = Math.max(avgLR(W, "mouthsmile"), avgLR(W, "mouthstretch"));
364
+ const press = avgLR(W, "mouthpress");
365
+ if (jaw < 0.06) return (press > 0.2 || W("mouthclose") > 0.25) ? "M" : "closed";
366
+ if (round > wide + 0.08) return jaw > 0.28 ? "O" : "U";
367
+ if (jaw > 0.42) return "A";
368
+ if (wide > 0.22) return jaw < 0.16 ? "I" : "E";
369
+ return jaw < 0.14 ? "closed" : "E";
370
+ }
371
+ return {
372
+ pick(now) {
373
+ const t = targetMouth();
374
+ if (t !== mouthCand) { mouthCand = t; candSince = now; }
375
+ if (mouthCand !== curMouth && now - candSince >= 70) { // 70ms 유지 시에만 전환
376
+ prevMouth = curMouth; switchAt = now; curMouth = mouthCand;
377
+ }
378
+ return { cur: curMouth, prev: prevMouth, fade: Math.min(1, (now - switchAt) / FADE_MS) };
379
+ },
380
+ };
381
+ }
382
+
383
+ // ---------- 벡터 입 (근육 채널 → 윤곽 제어점 연속 변형) ----------
384
+ // puppet 의 superset 공식으로 통합. 닫힘곡선 제어점 압력 = max(근육 press, mouthclose*0.5) 로
385
+ // puppet(press 위주)·docs(mouthclose 위주) 양쪽 기존 픽셀을 회귀 없이 재현. frown 반영은 puppet 항.
386
+ function drawVectorMouth(ctx, W, manifest, jawDy) {
387
+ const st = manifest.mouthStyle || {};
388
+ const [mcx, mcy0] = manifest.mouthCenter || [256, 340];
389
+ const jaw = W("jawopen");
390
+ const round = roundness(W);
391
+ const pressM = avgLR(W, "mouthpress"); // 근육 압력 (openH 폐합용)
392
+ const upperUp = avgLR(W, "mouthupperup");
393
+ const lowerDown = avgLR(W, "mouthlowerdown");
394
+ const smL = W("mouthsmileleft"), smR = W("mouthsmileright");
395
+ const frL = W("mouthfrownleft"), frR = W("mouthfrownright");
396
+ // 닫힘곡선 제어점 압력: 근육 press 와 mouthclose 유래 압력 중 강한 쪽 (하이브리드 — puppet·docs 양쪽 회귀 0)
397
+ const pressCurve = Math.max(pressM, W("mouthclose") * 0.5);
398
+
399
+ // 오므림(오/우): 세로 개방에 바닥값(+round*18)을 줘 낮은 턱에서도 둥근 구멍이 생기게 한다.
400
+ const openH = Math.max(0, jaw * 58 + lowerDown * 10 + round * 18 - Math.max(pressM * 8, W("mouthclose") * 30));
401
+ const wBase = st.width || 34;
402
+ const halfL = wBase * (1 + 0.45 * W("mouthstretchleft") + 0.3 * smL - 0.6 * round); // 오므림일수록 폭 좁힘
403
+ const halfR = wBase * (1 + 0.45 * W("mouthstretchright") + 0.3 * smR - 0.6 * round);
404
+ const cy = mcy0 + jawDy;
405
+ const xL = mcx - halfL, xR = mcx + halfR;
406
+ const yCL = cy - 2 - smL * 12 + frL * 12; // 입꼬리 좌우 독립 (비대칭 표정)
407
+ const yCR = cy - 2 - smR * 12 + frR * 12;
408
+ // 오므림 강할수록 위/아래 곡선을 대칭(0.5/0.5)으로 → 납작한 렌즈가 아니라 동그란 O.
409
+ const topF = 0.38 + 0.12 * round, botF = 1 - topF;
410
+ const yU = cy - openH * topF - upperUp * 8;
411
+ const yD = cy + openH * botF;
412
+
413
+ ctx.lineWidth = 5; ctx.lineCap = "round"; ctx.lineJoin = "round";
414
+ ctx.strokeStyle = st.line || "#3a2e2a";
415
+
416
+ if (openH < 2.5) { // 다문 입: 압력·미소·찡그림이 곡선 하나에 반영
417
+ ctx.beginPath();
418
+ ctx.moveTo(xL, yCL);
419
+ ctx.quadraticCurveTo(mcx, cy + 5 + pressCurve * 4, xR, yCR);
420
+ ctx.stroke();
421
+ return;
422
+ }
423
+ const path = new Path2D();
424
+ path.moveTo(xL, yCL);
425
+ path.quadraticCurveTo(mcx, 2 * yU - (yCL + yCR) / 2, xR, yCR); // 윗입술
426
+ path.quadraticCurveTo(mcx, 2 * yD - (yCL + yCR) / 2, xL, yCL); // 아랫입술
427
+ path.closePath();
428
+ ctx.fillStyle = st.fill || "#8a3535"; ctx.fill(path);
429
+ ctx.save(); ctx.clip(path);
430
+ // 오/우는 입술이 모여 이·혀가 거의 안 보임 — 오므림에 비례해 연속 감쇠(경계 팝 없음).
431
+ const inner = Math.max(0, 1 - 1.3 * round);
432
+ if (openH > 7) { // 윗니 (inner=0 이면 높이 0 = 안 그려짐)
433
+ ctx.fillStyle = st.teeth || "#ffffff";
434
+ ctx.fillRect(xL, yU - 2, xR - xL, Math.min(9, openH * 0.32) * inner);
435
+ }
436
+ if (openH > 18) { // 혀
437
+ ctx.fillStyle = st.tongue || "#d97b7b";
438
+ ctx.beginPath();
439
+ ctx.ellipse(mcx, yD, (xR - xL) * 0.3, openH * 0.28 * inner, 0, 0, Math.PI * 2);
440
+ ctx.fill();
441
+ }
442
+ ctx.restore();
443
+ ctx.stroke(path);
444
+ }
445
+
446
+ // ---------- 스프라이트 입 크로스페이드 ----------
447
+ // 반드시 스프라이트 모드 브랜치에서만(프레임당 1회) 호출 — pick() 이 히스테리시스 상태를 전진시킴.
448
+ // 전환 중(fade<1)이고 이전 스프라이트가 존재하면 α로 겹쳐 페이드, 아니면 현재만. jawDy 만큼 세로 이동.
449
+ function drawSpriteMouth(ctx, parts, picker, now, jawDy) {
450
+ const { cur, prev, fade } = picker.pick(now);
451
+ const drawM = name => parts[name] && ctx.drawImage(parts[name], 0, jawDy);
452
+ if (fade < 1 && prev && parts["mouth_" + prev]) {
453
+ ctx.globalAlpha = 1 - fade; drawM("mouth_" + prev);
454
+ ctx.globalAlpha = fade; drawM("mouth_" + cur);
455
+ ctx.globalAlpha = 1;
456
+ } else {
457
+ drawM("mouth_" + cur);
458
+ }
459
+ }
460
+
461
+ // ---------- WebGL 얼굴 워핑 (base 정점 변위 그리드) ----------
462
+ // 세분 평면(512×512, 48세그) + base 텍스처를 직교카메라로 픽셀 정합 렌더. 정점셰이더가 근육 채널값
463
+ // (uniform)으로 가우시안 변위장을 적용해 턱·볼을 미세 변형. 색공간은 NoColorSpace + 순수 셰이더
464
+ // 패스스루라 워프 0일 때 2D drawImage 와 픽셀 동일.
465
+ // threeUrl 페이지별(/static/vendor/… 절대 vs ./vendor/… 상대 — 클래식 스크립트라 문서 기준 해석).
466
+ // getParts/getManifest/W 는 페이지 상태 접근자.
467
+ function makeWarp({ threeUrl, glCanvas, getParts, getManifest, W }) {
468
+ return {
469
+ ready: false,
470
+ T: null, renderer: null, scene: null, camera: null, material: null, texture: null,
471
+ // 입 오버레이 세로 이동: 워프 ON이면 jaw 변위장을 입 앵커에서 평가한 값(≈11px·jaw)으로 대체해
472
+ // base 워프와 정확히 함께 움직이게 함(이중 이동 방지). OFF면 기존 jawDrop 사용. (38/55가 워프 시그마 결합)
473
+ jawOverlayDy(jaw, warpOn, manifest) {
474
+ return warpOn ? 14 * jaw * WARP_JAW_G : jaw * (manifest.jawDrop || 8);
475
+ },
476
+ vert: `
477
+ uniform vec2 uJawC, uCornerL, uCornerR;
478
+ uniform float uJaw, uSmileL, uSmileR, uRound, uFrownL, uFrownR;
479
+ varying vec2 vUv;
480
+ float gk(vec2 p, vec2 c, float s){ vec2 d = p - c; return exp(-dot(d, d) / (2.0 * s * s)); }
481
+ void main() {
482
+ vUv = uv;
483
+ vec2 img = vec2(position.x + 256.0, 256.0 - position.y); // plane → 이미지 픽셀좌표(y down)
484
+ vec2 disp = vec2(0.0);
485
+ disp += vec2( 0.0, 14.0) * uJaw * gk(img, uJawC, 55.0); // 턱 드롭
486
+ disp += vec2(-7.0, -9.0) * uSmileL * gk(img, uCornerL, 32.0); // 좌 입꼬리 (볼 당김)
487
+ disp += vec2( 7.0, -9.0) * uSmileR * gk(img, uCornerR, 32.0); // 우 입꼬리
488
+ disp += vec2( 8.0, 0.0) * uRound * gk(img, uCornerL, 32.0); // 오므림 (안쪽)
489
+ disp += vec2(-8.0, 0.0) * uRound * gk(img, uCornerR, 32.0);
490
+ disp += vec2(-3.0, 8.0) * uFrownL * gk(img, uCornerL, 32.0); // 찡그림 (내림)
491
+ disp += vec2( 3.0, 8.0) * uFrownR * gk(img, uCornerR, 32.0);
492
+ vec2 pos = position.xy;
493
+ pos.x += disp.x; pos.y -= disp.y; // 이미지 y-down → plane y-up
494
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 0.0, 1.0);
495
+ }`,
496
+ frag: `
497
+ uniform sampler2D uTex;
498
+ varying vec2 vUv;
499
+ void main() { gl_FragColor = texture2D(uTex, vUv); }`, // colorspace include 없음 → sRGB 바이트 그대로
500
+ async init() {
501
+ try {
502
+ const T = await import(threeUrl);
503
+ this.T = T;
504
+ // preserveDrawingBuffer: 움짤 녹화가 drawImage 로 gl 캔버스를 캡처할 때 빈 프레임 방지(정석 옵션).
505
+ this.renderer = new T.WebGLRenderer({ canvas: glCanvas, alpha: true, antialias: false, premultipliedAlpha: false, preserveDrawingBuffer: true });
506
+ // 셰이더 링크 실패는 three가 throw하지 않고 콘솔 로깅만 함 → 이 콜백으로 감지해 폴백 ��환.
507
+ this.renderer.debug.onShaderError = () => { this.ready = false; };
508
+ this.renderer.setClearColor(0x000000, 0);
509
+ this.renderer.setSize(512, 512, false);
510
+ this.scene = new T.Scene();
511
+ this.camera = new T.OrthographicCamera(-256, 256, 256, -256, -1, 1);
512
+ this.material = new T.ShaderMaterial({
513
+ uniforms: {
514
+ uTex: { value: null },
515
+ uJawC: { value: new T.Vector2(256, 378) },
516
+ uCornerL: { value: new T.Vector2(220, 340) },
517
+ uCornerR: { value: new T.Vector2(292, 340) },
518
+ uJaw: { value: 0 }, uSmileL: { value: 0 }, uSmileR: { value: 0 },
519
+ uRound: { value: 0 }, uFrownL: { value: 0 }, uFrownR: { value: 0 },
520
+ },
521
+ vertexShader: this.vert, fragmentShader: this.frag, transparent: true, side: T.DoubleSide,
522
+ });
523
+ this.scene.add(new T.Mesh(new T.PlaneGeometry(512, 512, 48, 48), this.material));
524
+ this.ready = true;
525
+ this.setCharacter(); // 경쟁 처리: base가 이미 로드됐으면 여기서 텍스처 설정
526
+ // init 시 강제 1회 렌더 → 셰이더 컴파일/링크를 지금 유발. 실패하면 throw(catch) 또는
527
+ // onShaderError가 ready=false로 내려 렌더 루프가 base를 2D로 그리는 폴백이 확실히 작동.
528
+ this.renderer.render(this.scene, this.camera);
529
+ } catch (e) {
530
+ this.ready = false; // WebGL 불가/로드 실패 → 조용히 폴백
531
+ }
532
+ },
533
+ setCharacter() {
534
+ const parts = getParts(), manifest = getManifest();
535
+ if (!this.ready || !parts.base) return;
536
+ const T = this.T;
537
+ if (this.texture) this.texture.dispose();
538
+ // 정적 캐릭터 base는 Image, 드래그앤드랍 유저 캐릭터 base는 canvas → CanvasTexture로 수용.
539
+ this.texture = (parts.base instanceof HTMLCanvasElement)
540
+ ? new T.CanvasTexture(parts.base)
541
+ : new T.Texture(parts.base);
542
+ this.texture.colorSpace = T.NoColorSpace; // sRGB 바이트 그대로 업로드 (GPU 선형화 안 함)
543
+ this.texture.premultiplyAlpha = false;
544
+ this.texture.minFilter = T.LinearFilter;
545
+ this.texture.magFilter = T.LinearFilter;
546
+ this.texture.generateMipmaps = false;
547
+ this.texture.needsUpdate = true;
548
+ this.material.uniforms.uTex.value = this.texture;
549
+ const mc = manifest.mouthCenter || [256, 340];
550
+ const mw = (manifest.mouthStyle && manifest.mouthStyle.width) || 30;
551
+ this.material.uniforms.uJawC.value.set(mc[0], mc[1] + 38);
552
+ this.material.uniforms.uCornerL.value.set(mc[0] - mw * 1.2, mc[1]);
553
+ this.material.uniforms.uCornerR.value.set(mc[0] + mw * 1.2, mc[1]);
554
+ },
555
+ render() {
556
+ if (!this.ready || !this.texture) return;
557
+ const u = this.material.uniforms;
558
+ u.uJaw.value = W("jawopen");
559
+ u.uSmileL.value = W("mouthsmileleft");
560
+ u.uSmileR.value = W("mouthsmileright");
561
+ u.uRound.value = roundness(W);
562
+ u.uFrownL.value = W("mouthfrownleft");
563
+ u.uFrownR.value = W("mouthfrownright");
564
+ this.renderer.render(this.scene, this.camera);
565
+ },
566
+ };
567
+ }
568
+
569
+ // ---------- 발화 요청 → 결과 (내부) ----------
570
+ // 서버가 동기 응답(0.6초급 작업) — 잡 폴링 제거로 발화당 ~0.2~0.35s 단축.
571
+ async function speakRT({ text, voice, engine, prosody }) {
572
+ const res = await fetch("/api/speak_rt", {
573
+ method: "POST", headers: { "Content-Type": "application/json" },
574
+ body: JSON.stringify({ text, voice, engine, ...(prosody || {}) }),
575
+ });
576
+ if (!res.ok) throw new Error((await res.json()).detail || res.statusText);
577
+ return res.json();
578
+ }
579
+
580
+ // ---------- 발화 플로우 (puppet·studio3d 공용) ----------
581
+ // speakRT → anim 조립(head 포함, 2D는 미사용이라 무해) → onAnim(재생 전 반영) → audio.src → play.
582
+ // onAnim 은 play 로딩 창 동안 렌더 루프가 최신 anim 을 보게 하려고 src/play 이전에 호출(원본 순서 유지).
583
+ // 페이지 핸들러는 autoEmo·버튼 disable·에러 setStatus 만 담당.
584
+ async function speakFlow({ text, voice, engine, audioEl, onAnim, prosody }) {
585
+ const r = await speakRT({ text, voice, engine, prosody });
586
+ const anim = { fps: r.fps, frames: r.frames, head: r.head, index: r.names.map((n, i) => [norm(n), i]) };
587
+ shapeAnim(anim, engine); // 엔진 출력 정규화 (입 벌림·편향 제거)
588
+ anim.sentences = r.sentences; // 문장 시작 시각 (없을 수 있음)
589
+ if (onAnim) onAnim(anim);
590
+ audioEl.src = r.audio_url;
591
+ await audioEl.play();
592
+ return anim;
593
+ }
594
+
595
+ // ---------- 감정 결정 + 발화 (puppet·studio3d 공용) ----------
596
+ // emotion 지정(LLM 판단) 있으면 그대로, 없으면 LLM 분류 → 실패 시 규칙 추론. autoEmo(호출 시점 boolean) 켜져 있으면
597
+ // emo(makeEmotion 인스턴스) 프리셋 + 목소리 톤 적용 후 speakFlow. voice/engine 은 호출 시점 값.
598
+ async function speakWithEmotion({ text, emotion, autoEmo, emo, voice, engine, audioEl, onAnim }) {
599
+ let segs = null;
600
+ let r = emotion ? { emo: emotion, intensity: 0.9 } : null;
601
+ if (!r && autoEmo) {
602
+ // 직렬 호출: 목소리 톤(prosody)이 TTS 요청 파라미터라 감정을 먼저 알아야 한다.
603
+ segs = await classifyEmotion(text);
604
+ r = segs ? { emo: segs[0].emo, intensity: segs[0].intensity } : inferEmotion(text);
605
+ } else if (!r) {
606
+ r = inferEmotion(text);
607
+ }
608
+ let prosody = null;
609
+ if (r && autoEmo) {
610
+ emo.setEmotion(r.emo, r.intensity, false); // 자동 감정 — 발화 끝나면 중립 복귀
611
+ prosody = voiceProsody(r.emo, r.intensity);
612
+ }
613
+ const anim = await speakFlow({ text, voice, engine, audioEl, onAnim, prosody });
614
+ // 문장별 감정 전환은 두 배열의 길이가 맞을 때만 (분할 결과가 같다는 전제 확인)
615
+ if (segs && anim.sentences && anim.sentences.length === segs.length) {
616
+ anim.emotionTrack = anim.sentences.map((s, i) => ({
617
+ start: s.start, emo: segs[i].emo, intensity: segs[i].intensity }));
618
+ }
619
+ return anim;
620
+ }
621
+
622
+ // ---------- 자동 쇼케이스 (첫 방문자 유휴 시 인사·감정 시연) ----------
623
+ // 오늘 넣은 표현력(감정 7종·몸짓·입 오므림)을 방문자가 아무것도 안 눌러도 보게 하는 유도.
624
+ const SHOWCASE_SCRIPT = [
625
+ { text: "안녕하세요! 저는 그림에서 태어난 아바타예요.", emo: "joy" },
626
+ { text: "이렇게 활짝 웃기도 하고,", emo: "joy" },
627
+ { text: "시무룩해지기도,", emo: "sad" },
628
+ { text: "깜짝 놀라기도,", emo: "surprise" },
629
+ { text: "무서워하기도,", emo: "fear" },
630
+ { text: "부끄러워하기도 한답니다.", emo: "shy" },
631
+ { text: "위에 문장을 입력하면 제가 말해드릴게요!", emo: "joy" },
632
+ ];
633
+ // playStep(step) → Promise(발화 완료). 발화 방식은 페이지가 주입(docs=speechSynthesis).
634
+ // 유휴 감지·중단 트리거는 엘리먼트가 페이지마다 달라 페이지가 소유 — 여긴 순차 재생 엔진만.
635
+ function makeShowcase(playStep, script = SHOWCASE_SCRIPT) {
636
+ let running = false, cancelled = false;
637
+ return {
638
+ get running() { return running; },
639
+ async play() {
640
+ if (running) return;
641
+ running = true; cancelled = false;
642
+ for (const step of script) {
643
+ if (cancelled) break;
644
+ await playStep(step);
645
+ if (cancelled) break;
646
+ await new Promise(r => setTimeout(r, 220)); // 스텝 사이 짧은 숨
647
+ }
648
+ running = false;
649
+ },
650
+ stop() { cancelled = true; },
651
+ };
652
+ }
653
+
654
+ // ---------- 캐릭터 클릭 반응 (아바타를 누르면 감정 섞인 한마디) ----------
655
+ const REACTIONS = [
656
+ { text: "우와, 깜짝이야!", emo: "surprise" },
657
+ { text: "헤헤, 간지러워요.", emo: "joy" },
658
+ { text: "아이, 부끄럽게 왜 그래요.", emo: "shy" },
659
+ { text: "안녕하세요! 반가워요.", emo: "joy" },
660
+ { text: "어? 왜 그러세요?", emo: "surprise" },
661
+ { text: "으, 살살 해주세요.", emo: "fear" },
662
+ ];
663
+ let _lastReact = -1;
664
+ function pickReaction() { // 직전과 다른 반응을 뽑아 연속 중복 방지
665
+ let i;
666
+ do { i = Math.floor(Math.random() * REACTIONS.length); } while (i === _lastReact && REACTIONS.length > 1);
667
+ _lastReact = i;
668
+ return REACTIONS[i];
669
+ }
670
+
671
+ // ---------- 분석 패널 (비교군): 내 얼굴 + 478점 랜드마크 + 구동 채널값 ----------
672
+ // 어느 페이지든 컨테이너 하나 주면 동일 패널 — 스타일 인라인이라 페이지 CSS 의존 없음.
673
+ // 반환된 draw(W) 를 렌더 루프에서 매 프레임 호출 (W = 채널 접근자). 컨테이너 숨김이면 즉시 반환.
674
+ // 미리보기: 웹캠 + 얼굴 메시(어디를 잡았나) + 오각형(얼마나 움직였나).
675
+ // 값 막대를 쌓지 않는 이유 — 채널 계수는 전송 데이터이지 읽는 정보가 아니고(Live Link Face·
676
+ // VSeeFace 도 메시만 겹쳐 보여준다), 모델 내부 채널명·0.00 정밀도를 UI 에 노출하면 값을
677
+ // 그대로 렌더한 티가 난다. 대신 다섯 축이 한 덩어리로 일그러지는 모양을 보여준다:
678
+ // 개별 수치는 못 읽어도 "지금 살아서 반응한다"가 한눈에 들어온다.
679
+ //
680
+ // 시선은 여기 없다. 크기가 아니라 방향(좌우·상하)이라 축 하나에 올릴 수 없고,
681
+ // 메시 위 홍채 점이 이미 그 자체로 보여준다.
682
+ const RADAR_CHS = [
683
+ ["입", W => W("jawopen")],
684
+ ["미소", W => (W("mouthsmileleft") + W("mouthsmileright")) / 2],
685
+ ["오므림", W => W("mouthpucker")],
686
+ ["눈", W => (W("eyeblinkleft") + W("eyeblinkright")) / 2],
687
+ ["눈썹", W => W("browinnerup")],
688
+ ];
689
+ function makeMirrorPanel(mirror, mount) {
690
+ let lastFrame = -1; // 마지막으로 미리보기에 그린 웹캠 프레임 번호
691
+ mount.innerHTML = '<div style="font-size:.85rem;font-weight:600;color:#9a9ab0;margin:2px 0 8px">내 얼굴 — 트래킹 미리보기</div>';
692
+ const cv = document.createElement("canvas");
693
+ cv.width = 320; cv.height = 240;
694
+ cv.style.cssText = "width:100%;border-radius:12px;border:1px solid #2a2a35;background:#0d0d12;display:block";
695
+ mount.appendChild(cv);
696
+ const ctx = cv.getContext("2d");
697
+
698
+ const rv = document.createElement("canvas");
699
+ rv.width = 320; rv.height = 218;
700
+ rv.style.cssText = "width:100%;margin-top:8px;display:block";
701
+ mount.appendChild(rv);
702
+ const rc = rv.getContext("2d");
703
+ // 12시부터 시계방향. 축 순서를 바꾸면 같은 표정이 다른 모양이 되므로 고정이다.
704
+ const N = RADAR_CHS.length, CX = 160, CY = 118, R = 90;
705
+ const AX = i => -Math.PI / 2 + i * 2 * Math.PI / N;
706
+ const poly = (get, close) => {
707
+ rc.beginPath();
708
+ for (let i = 0; i < N; i++) {
709
+ const r = get(i), a = AX(i);
710
+ const x = CX + Math.cos(a) * r, y = CY + Math.sin(a) * r;
711
+ i ? rc.lineTo(x, y) : rc.moveTo(x, y);
712
+ }
713
+ if (close !== false) rc.closePath();
714
+ };
715
+
716
+ return {
717
+ canvas: cv, // 동시 구동 화면의 녹화 합성용
718
+ draw(W) {
719
+ if (!mount.offsetParent) return; // 숨김 상태 — 일 안 함
720
+ const d = mirror.debug();
721
+ // 웹캠 프레임이 그대로면 같은 그림을 다시 그리게 된다 — 렌더 루프(60~144fps)가
722
+ // 추론(~30fps)보다 빨라 4~5배 헛일. 오각형은 값이 계속 변하므로 매 프레임 갱신한다.
723
+ if (d.frame !== lastFrame || !mirror.on) {
724
+ lastFrame = d.frame;
725
+ const w = cv.width, h = cv.height;
726
+ ctx.fillStyle = "#0d0d12"; ctx.fillRect(0, 0, w, h);
727
+ if (!mirror.on || !d.video || d.video.readyState < 2) {
728
+ ctx.fillStyle = "#9a9ab0"; ctx.font = "13px sans-serif"; ctx.textAlign = "center";
729
+ ctx.fillText("미러링을 시작하면 표시됩니다", w / 2, h / 2);
730
+ } else {
731
+ ctx.save(); ctx.scale(-1, 1); ctx.drawImage(d.video, -w, 0, w, h); ctx.restore(); // 거울 반전
732
+ if (d.lm) {
733
+ const X = i => (1 - d.lm[i].x) * w, Y = i => d.lm[i].y * h;
734
+ if (d.mesh) {
735
+ // MediaPipe 표준 토폴로지(2556 세그먼트)를 한 경로로 모아 한 번에 stroke —
736
+ // 개별 stroke 면 프레임을 먹는다.
737
+ ctx.beginPath();
738
+ for (const c of d.mesh) {
739
+ const a = c.start ?? c[0], b = c.end ?? c[1];
740
+ ctx.moveTo(X(a), Y(a)); ctx.lineTo(X(b), Y(b));
741
+ }
742
+ ctx.strokeStyle = "rgba(232,232,239,.22)"; ctx.lineWidth = 0.5; ctx.stroke();
743
+ } else {
744
+ ctx.fillStyle = "rgba(232,232,239,.5)"; // 구버전 tasks-vision 폴백
745
+ for (let i = 0; i < 468; i++) ctx.fillRect(X(i) - .5, Y(i) - .5, 1.5, 1.5);
746
+ }
747
+ ctx.fillStyle = "#ffb03a"; // 홍채 10점 = 시선 계산의 실제 입력
748
+ for (let i = 468; i < d.lm.length; i++) ctx.fillRect(X(i) - 1.5, Y(i) - 1.5, 3, 3);
749
+ }
750
+ }
751
+ }
752
+
753
+ rc.clearRect(0, 0, rv.width, rv.height);
754
+ rc.strokeStyle = "rgba(232,232,239,.13)"; rc.lineWidth = 1;
755
+ for (const f of [0.5, 1]) { poly(() => R * f); rc.stroke(); }
756
+ rc.beginPath();
757
+ 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); }
758
+ rc.stroke();
759
+
760
+ poly(i => Math.max(0, Math.min(1, RADAR_CHS[i][1](W))) * R);
761
+ rc.fillStyle = "rgba(255,176,58,.20)"; rc.fill();
762
+ rc.strokeStyle = "#ffb03a"; rc.lineWidth = 1.5; rc.stroke();
763
+
764
+ rc.fillStyle = "#9a9ab0"; rc.font = "13px sans-serif"; rc.textAlign = "center"; rc.textBaseline = "middle";
765
+ for (let i = 0; i < N; i++) {
766
+ const a = AX(i);
767
+ rc.fillText(RADAR_CHS[i][0], CX + Math.cos(a) * (R + 22), CY + Math.sin(a) * (R + 16));
768
+ }
769
+ },
770
+ };
771
+ }
772
+
773
+ // ---------- 2D 캐릭터 그리기 (소년·소녀 공용) ----------
774
+ // 파츠 스프라이트(눈썹·눈·동공) + 벡터 입을 한 번에. 워프를 쓰는 페이지는 warp 를 넘기면
775
+ // 배경/턱 오프셋을 워프 기준으로, 안 넘기면 흰 배경 + manifest.jawDrop 폴백으로 그린다.
776
+ //
777
+ // ctx: 2D 컨텍스트(512²), parts: 이미지 맵, manifest: 캐릭터 튜닝값,
778
+ // W: 채널 접근자, blink: 깜빡임(자동+미러 max 결합), gaze: [gx, gy],
779
+ // opts.warp: makeWarp 인스턴스(선택), opts.clearBg: 배경 채우기 여부(클린 모드면 false)
780
+ function drawChar2D(ctx, { parts, manifest, W, blink, gaze, warp, clearBg = true, head }) {
781
+ if (!parts.base || !manifest) return false;
782
+ const warpOn = !!(warp && warp.ready);
783
+ ctx.clearRect(0, 0, 512, 512);
784
+ // head 를 주면 캔버스 자체를 변형한다 — CSS 변환이 안 먹는 경로(캔버스를 drawImage 로
785
+ // 합성하는 녹화 등)용. CSS 래퍼를 쓰는 페이지는 head 를 넘기지 말 것(이중 적용).
786
+ if (head) {
787
+ ctx.save();
788
+ ctx.translate(256 + head[0] * HEAD_SHIFT * 5.12, 256 + head[1] * HEAD_SHIFT * 5.12);
789
+ ctx.rotate(head[2]);
790
+ ctx.translate(-256, -256);
791
+ }
792
+ const draw = (n, dy = 0) => parts[n] && ctx.drawImage(parts[n], 0, dy);
793
+ const drawXY = (n, dx, dy) => parts[n] && ctx.drawImage(parts[n], dx, dy);
794
+ // 워프가 켜져 있으면 base 는 WebGL 레이어가 그리므로 여기선 생략
795
+ if (!warpOn) {
796
+ if (clearBg) { ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, 512, 512); }
797
+ draw("base");
798
+ }
799
+ const bR = manifest.browRange || 0;
800
+ const browUp = -bR * Math.min(1, W("browinnerup") + (W("browouterupleft") + W("browouterupright")) / 2)
801
+ + bR * 0.8 * (W("browdownleft") + W("browdownright")) / 2; // 찡그림은 반대로 내림
802
+ draw("brow_L", browUp); draw("brow_R", browUp);
803
+ if (blink > 0.5) { draw("eye_L_closed"); draw("eye_R_closed"); }
804
+ else {
805
+ draw("eye_L_open"); draw("eye_R_open");
806
+ const pr = manifest.pupilRange || 0;
807
+ drawXY("pupil_L", gaze[0] * pr, gaze[1] * pr); drawXY("pupil_R", gaze[0] * pr, gaze[1] * pr);
808
+ }
809
+ const jawDy = warp ? warp.jawOverlayDy(W("jawopen"), warpOn, manifest)
810
+ : W("jawopen") * (manifest.jawDrop || 8);
811
+ drawVectorMouth(ctx, W, manifest, jawDy);
812
+ if (head) ctx.restore();
813
+ return warpOn;
814
+ }
815
+
816
+ // ---------- 3D 헤드 마운트 (mark·claire·RPM 공용) ----------
817
+ // 씬에 GLB 를 얹고 ① 모프 구동 대상 수집 ② 눈알 구체(필요 시) ③ 카메라 프레이밍까지 한 번에.
818
+ // three.js 는 페이지가 importmap 으로 로드하므로 THREE 를 주입받는다(코어는 클래식 스크립트).
819
+ //
820
+ // cfg: { eyeSpheres, headFrac } — eyeSpheres 는 안구 메시가 없는 석고 헤드용,
821
+ // headFrac 는 모델 높이 중 머리가 차지하는 비율(석고=1 머리만 / RPM≈0.22 반신).
822
+ // 반환: { sceneNode, morphMeshes, eyes } — morphMeshes 는 {inf, map} 배열로,
823
+ // RPM 처럼 머리·눈·이빨이 각각 모프를 가진 모델도 전부 구동된다(하나만 쓰면 눈·이빨이 따로 논다).
824
+ function mountHead3D(THREE, { group, camera, gltf, cfg, prev }) {
825
+ // 검사를 먼저 — 실패 시 이전 헤드를 건드리지 않아야 호출측의 "실패하면 이전 상태 유지"가 성립한다.
826
+ const sceneNode = gltf.scene;
827
+ const morphMeshes = [];
828
+ sceneNode.traverse(o => {
829
+ if (!o.isMesh || !o.morphTargetInfluences) return;
830
+ o.frustumCulled = false; // 모프로 변형되면 원래 바운딩을 벗어나 컬링될 수 있다
831
+ morphMeshes.push({ inf: o.morphTargetInfluences,
832
+ map: Object.entries(o.morphTargetDictionary || {}).map(([n, i]) => [norm(n), i]) });
833
+ });
834
+ if (!morphMeshes.length) return null; // 표정 모프가 없는 모델 — 호출측이 상태 표시
835
+
836
+ if (prev) {
837
+ if (prev.sceneNode) group.remove(prev.sceneNode);
838
+ // 눈알 구체는 헤드마다 새로 만드므로 교체 시 GPU 자원을 해제한다(반복 전환 시 누수).
839
+ (prev.eyes || []).forEach(e => {
840
+ group.remove(e);
841
+ e.traverse(o => { if (o.isMesh) { o.geometry.dispose(); o.material.dispose(); } });
842
+ });
843
+ }
844
+ group.position.set(0, 0, 0);
845
+ group.updateMatrixWorld(true); // .position 변경을 matrixWorld 에 즉시 반영 —
846
+ // 안 하면 setFromObject 가 이전 헤드 오프셋을 물어 bbox 가 어긋난다
847
+ group.add(sceneNode);
848
+
849
+ const box = new THREE.Box3().setFromObject(sceneNode);
850
+ const c = box.getCenter(new THREE.Vector3()), s = box.getSize(new THREE.Vector3());
851
+ const eyes = cfg.eyeSpheres ? [makeEyeball(THREE, group, 1, box), makeEyeball(THREE, group, -1, box)] : [];
852
+ // 프레이밍: 석고는 전체가 머리 / 반신 모델은 상단 headFrac 만큼을 머리로 보고 맞춘다
853
+ const headH = s.y * cfg.headFrac;
854
+ const focusY = cfg.headFrac === 1 ? c.y : box.max.y - headH * 0.42;
855
+ group.position.set(-c.x, -focusY, -c.z); // 헤드·눈 함께 recenter
856
+ camera.position.set(0, 0, headH * 2.0); camera.lookAt(0, 0, 0);
857
+ camera.near = headH / 100; camera.far = headH * 100; camera.updateProjectionMatrix();
858
+ return { sceneNode, morphMeshes, eyes };
859
+ }
860
+
861
+ // 눈알 구체 — 안구 메시가 없는 석고 헤드(mark·claire)의 소켓을 채운다.
862
+ // 위치·크기는 mark 로 보정한 bbox 분율이라 헤드 크기가 달라도 따라간다.
863
+ function makeEyeball(THREE, group, sign, box) {
864
+ const c = box.getCenter(new THREE.Vector3()), s = box.getSize(new THREE.Vector3());
865
+ const k = s.y / 41.5; // mark 기준 스케일
866
+ const eg = new THREE.Group();
867
+ eg.position.set(c.x + sign * 0.111 * s.x, c.y + 0.151 * s.y, c.z + 0.284 * s.z);
868
+ const mat = (color, roughness) => new THREE.MeshStandardMaterial({ color, roughness });
869
+ const sclera = new THREE.Mesh(new THREE.SphereGeometry(1.15 * k, 32, 16), mat(0xf2f0ee, 0.35));
870
+ const iris = new THREE.Mesh(new THREE.SphereGeometry(0.46 * k, 24, 12), mat(0x44546e, 0.3));
871
+ iris.position.z = 0.78 * k;
872
+ const pupil = new THREE.Mesh(new THREE.SphereGeometry(0.2 * k, 16, 8), mat(0x0c0c0c, 0.4));
873
+ pupil.position.z = 1.0 * k;
874
+ eg.add(sclera, iris, pupil);
875
+ group.add(eg);
876
+ return eg;
877
+ }
878
+
879
+ // 모프 구동 — mountHead3D 가 준 morphMeshes 에 채널값을 쓴다. blink 는 별도(자동 깜빡임과 max 결합).
880
+ function applyMorphs(morphMeshes, smooth, blink) {
881
+ for (const { inf, map } of morphMeshes) {
882
+ for (const [k, idx] of map) {
883
+ inf[idx] = k === "eyeblinkleft" || k === "eyeblinkright" ? blink : (smooth[k] || 0);
884
+ }
885
+ }
886
+ }
887
+
888
+ // ---------- 아이리스 시선 (478점 랜드마크의 홍채 10점 → [-1..1] 근사) ----------
889
+ // 홍채 중심이 눈꼬리(가로)·눈꺼풀(세로) 기준 어디 있는지의 비율 — 머리 회전에 1차 자체 보정.
890
+ // 눈을 거의 감으면(개방도 < 0.28) null — 호출측이 직전 시선을 유지하게 한다(깜빡임 간섭 차단).
891
+ function irisGaze(lm) {
892
+ if (!lm || lm.length < 478) return null;
893
+ const eye = (iris0, c0, c1, top, bot) => {
894
+ let ix = 0, iy = 0;
895
+ for (let i = iris0; i < iris0 + 5; i++) { ix += lm[i].x / 5; iy += lm[i].y / 5; }
896
+ const halfW = Math.abs(lm[c1].x - lm[c0].x) / 2 || 1e-6;
897
+ const h = Math.abs(lm[bot].y - lm[top].y);
898
+ const cx = (lm[c0].x + lm[c1].x) / 2, cy = (lm[top].y + lm[bot].y) / 2;
899
+ return { gx: (ix - cx) / halfW, gy: (iy - cy) / Math.max(h, 1e-6), open: h / halfW };
900
+ };
901
+ const L = eye(468, 33, 133, 159, 145); // 영상 기준 왼눈
902
+ const R = eye(473, 362, 263, 386, 374); // 영상 기준 오른눈
903
+ if ((L.open + R.open) / 2 < 0.28) return null; // 감김 — 세로 신호 무의미
904
+ return [(L.gx + R.gx) / 2, (L.gy + R.gy) / 2];
905
+ }
906
+
907
+ // 머리 회전 부호 — 거울 방향은 렌더러와 무관하게 공통이라 코어에서 한 번만 정한다.
908
+ // yaw·roll 이 음수인 이유: 웹캠 영상은 거울 반전해 쓰므로(분석 패널 scale(-1,1)) 가로 성분은
909
+ // 뒤집어야 사용자가 왼쪽을 향할 때 캐릭터도 화면 왼쪽을 향한다 — 시선 gx 와 같은 규약(아래 gazeX 항).
910
+ // pitch(상하)는 반전과 무관해 그대로. 어긋나 보이면 ?hs=y,p,r 로 실험 후 이 기본값을 고친다.
911
+ const HEAD_SIGN = (() => {
912
+ const def = [-1, 1, -1];
913
+ const v = (new URLSearchParams(location.search).get("hs") || "").split(",").map(Number);
914
+ return def.map((d, i) => (Number.isFinite(v[i]) && v[i] !== 0 ? v[i] : d));
915
+ })();
916
+
917
+ // ---------- 웹캠 표정 미러링 (MediaPipe FaceLandmarker 블렌드셰이프 52채널) ----------
918
+ // 브라우저 전용(서버·GPU 추론 불필요, github.io OK). 채널 이름이 ARKit 표준이라 lowercase 로
919
+ // 렌더러 W() 채널과 1:1. 시작 시 30프레임 중립 캘리브레이션 후 상대값만 전이(drawface 정규화).
920
+ // 사용: const mirror = makeMirror({ onStatus }); 렌더 루프에서 mirror.apply(smooth, now) 한 줄.
921
+ // 머리 회전은 산만해서 전이하지 않는다(표정 채널만). gain 기본값은 말하기 수준 벌림 보정 —
922
+ // 페이지별 오버라이드 가능하나 6페이지 실측에서 동일 값이 맞았다.
923
+ function makeMirror({ gain, onStatus } = {}) {
924
+ gain = gain || { jawopen: 1.6, mouthsmileleft: 1.4, mouthsmileright: 1.4 };
925
+ const st = { on: false, w: null, neutral: null, samples: [], gsamples: [], gN: [0, 0], head: null, hN: null, frame: 0 };
926
+ let lm = null, video = null, lastT = -1;
927
+ const say = (msg, err) => onStatus && onStatus(msg, err);
928
+
929
+ async function start() {
930
+ if (!lm) {
931
+ say("미러링 모델 로드 중…");
932
+ const mp = await import("https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.17");
933
+ const vision = await mp.FilesetResolver.forVisionTasks(
934
+ "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.17/wasm");
935
+ // 얼굴 메시 토폴로지는 MediaPipe 가 상수로 들고 있다 — 우리가 삼각분할할 필요가 없다.
936
+ // 미리보기 오버레이(makeMirrorPanel)가 debug().mesh 로 받아 선으로 그린다.
937
+ st.mesh = mp.FaceLandmarker.FACE_LANDMARKS_TESSELATION || null;
938
+ lm = await mp.FaceLandmarker.createFromOptions(vision, {
939
+ baseOptions: { modelAssetPath: "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task", delegate: "GPU" },
940
+ runningMode: "VIDEO", numFaces: 1, outputFaceBlendshapes: true,
941
+ outputFacialTransformationMatrixes: true, // 머리 회전(yaw·pitch·roll) 추출용
942
+ });
943
+ }
944
+ video = document.createElement("video");
945
+ video.muted = true;
946
+ video.srcObject = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } });
947
+ await video.play();
948
+ Object.assign(st, { on: true, w: null, neutral: null, samples: [], gsamples: [], gN: [0, 0], head: null, hN: null, frame: 0 });
949
+ say("캘리브레이션 — 정면·무표정으로 잠시 계세요");
950
+ }
951
+ function stop() {
952
+ st.on = false; st.w = null;
953
+ video?.srcObject?.getTracks().forEach(t => t.stop());
954
+ video = null;
955
+ say("");
956
+ }
957
+ // 머리 회전: MediaPipe 얼굴 변환행렬(열 우선 4x4) → yaw·pitch·roll(rad).
958
+ // 중립 캘리브레이션 기준 편차만 쓰고, 강한 평활(0.88)+클램프로 산만함을 억제한다.
959
+ function headFromMatrix(m) {
960
+ if (!m || st.samples) { st.head = null; return; } // 캘리브레이션 중엔 머리 미산출
961
+ // 열 우선(col*4+row): m[8]=R02, m[9]=R12, m[10]=R22, m[1]=R10, m[5]=R11.
962
+ // 표준 Tait-Bryan(Y-X-Z) 추출 — 축 혼선 없이 yaw/pitch/roll 분리.
963
+ const pitch = Math.asin(Math.max(-1, Math.min(1, -m[9])));
964
+ const yaw = Math.atan2(m[8], m[10]);
965
+ const roll = Math.atan2(m[1], m[5]);
966
+ const cur = [yaw, pitch, roll];
967
+ if (!st.hN) st.hN = cur.slice(); // 캘리브 직후 첫 프레임 = 중립 자세
968
+ const rel = cur.map((v, i) => v - st.hN[i]);
969
+ const cl = (v, lim) => Math.max(-lim, Math.min(lim, v));
970
+ const t = [cl(rel[0], 0.5), cl(rel[1], 0.35), cl(rel[2], 0.35)];
971
+ st.head = st.head ? st.head.map((v, i) => 0.88 * v + 0.12 * t[i]) : t;
972
+ }
973
+
974
+ const LOOK = ["eyelookoutright", "eyelookinleft", "eyelookoutleft", "eyelookinright",
975
+ "eyelookdownleft", "eyelookdownright", "eyelookupleft", "eyelookupright"];
976
+ function tick(now) {
977
+ if (!st.on || !video || video.readyState < 2) return;
978
+ if (video.currentTime === lastT) return; // 새 비디오 프레임에서만 추론
979
+ lastT = video.currentTime;
980
+ st.frame++; // 패널이 "새 프레임일 때만" 다시 그리도록
981
+ const res = lm.detectForVideo(video, now);
982
+ const cats = res.faceBlendshapes?.[0]?.categories;
983
+ if (!cats) { st.w = null; st.lm = null; return; } // 얼굴 놓침 → 개입 중단(자연 복귀)
984
+ st.lm = res.faceLandmarks?.[0] || null; // 분석 패널(비교군 시각화)용 원본 랜드마크
985
+ headFromMatrix(res.facialTransformationMatrixes?.[0]?.data);
986
+ const raw = {};
987
+ for (const c of cats) raw[c.categoryName.toLowerCase()] = c.score;
988
+ const g = irisGaze(res.faceLandmarks?.[0]); // 아이리스 정밀 시선 (감김이면 null)
989
+ if (!st.neutral) { // 30프레임 평균 = 중립
990
+ st.samples.push(raw);
991
+ if (g) st.gsamples.push(g);
992
+ // 캘리브레이션 중 움직이면 중립이 오염되므로 진행 상황을 안내 (6프레임마다 갱신)
993
+ if (st.samples.length % 6 === 1) say(`정면을 보고 무표정을 유지해주세요… ${st.samples.length}/30`);
994
+ if (st.samples.length < 30) return;
995
+ const n = {};
996
+ for (const k in raw) n[k] = st.samples.reduce((a, s) => a + (s[k] || 0), 0) / st.samples.length;
997
+ // 시선은 아이리스 합성값으로 대체되므로 eyeLook 채널의 블렌드셰이프 중립은 0으로 —
998
+ // 합성값(이미 게인·클램프 적용)이 표준 파이프라인(중립차감·EMA)을 그대로 통과하게 한다.
999
+ for (const k of LOOK) n[k] = 0;
1000
+ st.gN = st.gsamples.length
1001
+ ? [st.gsamples.reduce((a, v) => a + v[0], 0) / st.gsamples.length,
1002
+ st.gsamples.reduce((a, v) => a + v[1], 0) / st.gsamples.length]
1003
+ : [0, 0];
1004
+ st.neutral = n;
1005
+ st.samples = null; st.gsamples = null; // 캘리브레이션 끝 — 샘플 버퍼 해제
1006
+ say("미러링 중 — 캐릭터가 따라합니다 (버튼으로 종료)");
1007
+ return;
1008
+ }
1009
+ // 아이리스 시선 → eyeLook 8채널 합성 덮어쓰기 (블렌드셰이프 시선치는 거칠어서 대체).
1010
+ // 부호: 거울 느낌 — 사용자가 화면 왼쪽을 보면 캐릭터 눈동자도 화면 왼쪽으로.
1011
+ if (g) {
1012
+ const cl = v => Math.max(-1, Math.min(1, v));
1013
+ const gx = cl(-(g[0] - st.gN[0]) * (gain.gazeX ?? 2.4));
1014
+ const gy = cl((g[1] - st.gN[1]) * (gain.gazeY ?? 1.4)); // 세로는 눈꺼풀 가림 탓 신호 약함 → 낮은 게인
1015
+ raw.eyelookoutright = raw.eyelookinleft = Math.max(0, gx);
1016
+ raw.eyelookoutleft = raw.eyelookinright = Math.max(0, -gx);
1017
+ raw.eyelookdownleft = raw.eyelookdownright = Math.max(0, gy);
1018
+ raw.eyelookupleft = raw.eyelookupright = Math.max(0, -gy);
1019
+ } else {
1020
+ for (const k of LOOK) delete raw[k]; // 깜빡임 등 — 직전 시선(EMA) 유지
1021
+ }
1022
+ const w = st.w || {};
1023
+ for (const k in raw) {
1024
+ if (k === "_neutral") continue;
1025
+ const n = st.neutral[k] || 0;
1026
+ const cal = clamp01((raw[k] - n) / Math.max(0.2, 1 - n) * (gain[k] || 1));
1027
+ w[k] = 0.55 * (w[k] || 0) + 0.45 * cal; // EMA 평활
1028
+ }
1029
+ st.w = w;
1030
+ }
1031
+ return {
1032
+ get on() { return st.on; },
1033
+ start, stop,
1034
+ // 렌더 루프 한 줄: 추론 tick + smooth 에 max-결합
1035
+ apply(smooth, now) {
1036
+ tick(now);
1037
+ if (st.w) for (const k in st.w) smooth[k] = Math.max(smooth[k] || 0, st.w[k]);
1038
+ },
1039
+ // 머리 회전 [yaw, pitch, roll] (rad, 중립 대비·평활·클램프·부호적용됨). 미검출/미시작이면 null.
1040
+ // 페이지가 켤지 말지만 결정한다 — 축 매핑은 페이지 몫, 부호(거울 방향)는 전 페이지 공통이라 여기서.
1041
+ head: () => st.head && st.head.map((v, i) => HEAD_SIGN[i] * v),
1042
+ // 분석 패널용: 원본 비디오 + 478점 랜드마크 + 캘리브레이션된 채널값(캐릭터 구동값과 동일)
1043
+ debug: () => ({ video, lm: st.lm, w: st.w, frame: st.frame, mesh: st.mesh }),
1044
+ };
1045
+ }
1046
+
1047
+ // ---------- 상태줄 setter ----------
1048
+ function bindStatus(el) {
1049
+ return (msg, isError) => { el.textContent = msg; el.className = isError ? "error" : ""; };
1050
+ }
1051
+
1052
+ // ---------- 마이크 음성인식 (Web Speech API — 브라우저 내장, 모델 불필요) ----------
1053
+ // 미지원 브라우저(Chrome 계열 외)면 null 반환 → 페이지가 마이크 버튼을 숨긴다.
1054
+ // onText(텍스트, isFinal): 인식 중간결과(false)와 최종결과(true)를 모두 전달.
1055
+ function makeMic({ lang = "ko-KR", onText, onState }) {
1056
+ const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
1057
+ if (!SR) return null;
1058
+ const rec = new SR();
1059
+ rec.lang = lang;
1060
+ rec.interimResults = true; // 말하는 도중에도 텍스트를 보여줌
1061
+ rec.continuous = false; // 한 문장 말하면 자동 종료 (푸시투토크 방식)
1062
+ let listening = false;
1063
+ rec.onstart = () => { listening = true; onState && onState("listening"); };
1064
+ rec.onend = () => { listening = false; onState && onState("idle"); };
1065
+ rec.onerror = e => { listening = false; onState && onState("error", e.error); };
1066
+ rec.onresult = e => {
1067
+ let fin = "", interim = "";
1068
+ for (let i = e.resultIndex; i < e.results.length; i++) {
1069
+ const r = e.results[i];
1070
+ if (r.isFinal) fin += r[0].transcript; else interim += r[0].transcript;
1071
+ }
1072
+ if (fin.trim()) onText(fin.trim(), true);
1073
+ else if (interim) onText(interim, false);
1074
+ };
1075
+ return {
1076
+ toggle() { if (listening) rec.stop(); else { try { rec.start(); } catch (_) {} } },
1077
+ start() { if (!listening) { try { rec.start(); } catch (_) {} } },
1078
+ stop() { if (listening) rec.stop(); },
1079
+ };
1080
+ }
1081
+
1082
+ // ---------- 대화 (LLM 응답 — 로컬 서버 전용) ----------
1083
+ // 반환: {reply, emotion}. emotion 은 EMOTIONS 키 중 하나(LLM 판단). 실패 시 throw.
1084
+ async function chat(text, history, persona) {
1085
+ const res = await fetch("/api/chat", {
1086
+ method: "POST", headers: { "Content-Type": "application/json" },
1087
+ body: JSON.stringify({ text, history: history || [], persona: persona || null }),
1088
+ });
1089
+ if (!res.ok) throw new Error((await res.json()).detail || res.statusText);
1090
+ return res.json();
1091
+ }
1092
+
1093
+ // ---------- 대화 모드 글루 (마이크/입력 → LLM → 아바타 응답; puppet·studio3d 공용) ----------
1094
+ // history 상태 + 채팅로그(addTurn) + runChat + 모드 토글 + 지우기 + 마이크 배선을 소유.
1095
+ // 페이지 주입: speak(text,emotion) 발화 함수, botName(봇 턴 화자명), placeholderOn(대화 모드 안내문),
1096
+ // logEl/chatModeEl/clearBtnEl/textEl/sendEl/micBtnEl/formEl 엘리먼트, statusSet(bindStatus 결과),
1097
+ // getPersona()(선택 — 현재 캐릭터 성격; 없으면 기본 정체성). placeholderOff 는 입력창 초기 placeholder 재사용.
1098
+ // 반환 { runChat } — onsubmit 의 chat|say 분기는 페이지가 얇게 소유(runChat 알맹이만 코어).
1099
+ function makeChat({ speak, logEl, botName, placeholderOn, chatModeEl, clearBtnEl, textEl, sendEl, micBtnEl, formEl, statusSet, getPersona, audioEl }) {
1100
+ const history = []; // [{role, content}] 최근 턴만 유지
1101
+ let handsFree = false, busy = false; // 연속 대화: 응답 재생이 끝나면 자동 재청취
1102
+ const placeholderOff = textEl.placeholder;
1103
+ function addTurn(who, text, cls) {
1104
+ const div = document.createElement("div");
1105
+ div.className = "turn";
1106
+ div.innerHTML = `<span class="who">${who}</span><span class="${cls}"></span>`;
1107
+ div.lastChild.textContent = text; // 사용자 입력이므로 textContent로 안전하게
1108
+ logEl.appendChild(div);
1109
+ logEl.scrollTop = logEl.scrollHeight;
1110
+ }
1111
+ chatModeEl.onchange = () => {
1112
+ const on = chatModeEl.checked;
1113
+ logEl.style.display = on ? "grid" : "none";
1114
+ clearBtnEl.style.display = on ? "" : "none";
1115
+ sendEl.textContent = on ? "말 걸기" : "말하기";
1116
+ textEl.placeholder = on ? placeholderOn : placeholderOff;
1117
+ };
1118
+ clearBtnEl.onclick = () => { history.length = 0; logEl.innerHTML = ""; statusSet(""); };
1119
+ async function runChat(text) {
1120
+ busy = true; // 아바타가 생각·발화하는 동안 재청취 금지 (자기 목소리 인식 방지)
1121
+ try {
1122
+ addTurn("나", text, "me");
1123
+ statusSet("생각 중…");
1124
+ const { reply, emotion } = await chat(text, history.slice(-6), getPersona && getPersona()); // 최근 3턴 + 캐릭터 성격
1125
+ history.push({ role: "user", content: text }, { role: "assistant", content: reply });
1126
+ addTurn(botName, reply, "bot");
1127
+ statusSet("말하는 중…");
1128
+ await speak(reply, emotion);
1129
+ // speak 는 재생 시작 시점에 반환 — 연속 대화면 재생이 실제로 끝날 때까지 대기 후 재청취
1130
+ if (handsFree && audioEl && !audioEl.paused && !audioEl.ended)
1131
+ await new Promise(res => audioEl.addEventListener("ended", res, { once: true }));
1132
+ } finally {
1133
+ busy = false;
1134
+ if (handsFree && mic) { statusSet(""); mic.start(); }
1135
+ }
1136
+ }
1137
+ // 마이크 (브라우저 음성인식 — 미지원 브라우저면 makeMic null → 버튼 숨김 유지)
1138
+ const mic = makeMic({
1139
+ onText: (t, isFinal) => {
1140
+ textEl.value = t;
1141
+ if (isFinal) formEl.requestSubmit(); // 말이 끝나면 자동 전송
1142
+ },
1143
+ onState: (st, err) => {
1144
+ micBtnEl.classList.toggle("on", st === "listening" || handsFree);
1145
+ if (st === "listening") statusSet(handsFree ? "듣고 있어요… (연속 대화 — 마이크 버튼으로 종료)" : "듣고 있어요… 말씀하세요");
1146
+ else if (st === "error") {
1147
+ if (err === "not-allowed") { handsFree = false; statusSet("마이크 권한이 필요합니다.", true); }
1148
+ else statusSet(`음성 인식 오류: ${err}`, true);
1149
+ } else if (st === "idle" && handsFree && !busy) {
1150
+ // 침묵 타임아웃으로 끊겨도 연속 모드면 잠시 후 재청취 (발화 처리 중이면 runChat 끝에서 재개)
1151
+ setTimeout(() => { if (handsFree && !busy) mic.start(); }, 400);
1152
+ }
1153
+ },
1154
+ });
1155
+ if (mic) {
1156
+ micBtnEl.style.display = "";
1157
+ // 마이크 버튼 = 연속 대화 토글: 켜면 듣기→응답→재청취 루프, 다시 누르면 종료
1158
+ micBtnEl.onclick = () => {
1159
+ if (!chatModeEl.checked) chatModeEl.click();
1160
+ handsFree = !handsFree;
1161
+ micBtnEl.classList.toggle("on", handsFree);
1162
+ if (handsFree) mic.start();
1163
+ else { mic.stop(); statusSet(""); }
1164
+ };
1165
+ }
1166
+ return { runChat };
1167
+ }
1168
+
1169
+ // ---------- 드래그앤드랍 캐릭터 생성: 어노테이션 캡처 UI (4클릭 상태머신) ----------
1170
+ // ① 왼눈 ② 오른눈 ③ 입중심 클릭 → ④ 입 드래그. cv/ctx: 2D 오버레이 캔버스·컨텍스트.
1171
+ // needDataUrl: puppet(서버 POST에 b64 필요) true / docs false. onCreate(annot, name, done):
1172
+ // 완료 콜백(페이지별 — 서버 POST vs 클라이언트 빌드). 성공 시 done() 호출로 annot 해제(정리 시점 페이지 제어).
1173
+ function makeAnnotator({ cv, ctx, setStatus, needDataUrl, onCreate }) {
1174
+ let annot = null; // {img, iw, ih, s, ox, oy, step, eyeL, eyeR, mouthC, box, dragStart, dataUrl}
1175
+ const STEPS = ["① 왼쪽 눈을 클릭하세요", "② 오른쪽 눈을 클릭하세요",
1176
+ "③ 입 중심을 클릭하세요", "④ 입 전체를 드래그로 감싸세요"];
1177
+
1178
+ function startAnnot(img, dataUrl) {
1179
+ const s = Math.min(512 / img.width, 512 / img.height);
1180
+ annot = { img, iw: img.width, ih: img.height, s,
1181
+ ox: (512 - img.width * s) / 2, oy: (512 - img.height * s) / 2, step: 0, dataUrl };
1182
+ setStatus(STEPS[0] + " (ESC로 취소)");
1183
+ }
1184
+ function toOrig(e) {
1185
+ const r = cv.getBoundingClientRect();
1186
+ return [((e.clientX - r.left) * 512 / r.width - annot.ox) / annot.s,
1187
+ ((e.clientY - r.top) * 512 / r.height - annot.oy) / annot.s];
1188
+ }
1189
+ function draw() {
1190
+ ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, 512, 512);
1191
+ ctx.drawImage(annot.img, annot.ox, annot.oy, annot.iw * annot.s, annot.ih * annot.s);
1192
+ const dot = (p, color) => {
1193
+ if (!p) return;
1194
+ ctx.beginPath();
1195
+ ctx.arc(annot.ox + p[0] * annot.s, annot.oy + p[1] * annot.s, 6, 0, Math.PI * 2);
1196
+ ctx.strokeStyle = color; ctx.lineWidth = 3; ctx.stroke();
1197
+ };
1198
+ dot(annot.eyeL, "#00a2ff"); dot(annot.eyeR, "#00a2ff"); dot(annot.mouthC, "#ff5b5b");
1199
+ if (annot.box) {
1200
+ const [x0, y0, x1, y1] = annot.box;
1201
+ ctx.strokeStyle = "#ff5b5b"; ctx.lineWidth = 2;
1202
+ ctx.strokeRect(annot.ox + x0 * annot.s, annot.oy + y0 * annot.s,
1203
+ (x1 - x0) * annot.s, (y1 - y0) * annot.s);
1204
+ }
1205
+ }
1206
+ cv.addEventListener("pointerdown", e => {
1207
+ if (!annot) return;
1208
+ const p = toOrig(e);
1209
+ if (annot.step === 0) { annot.eyeL = p; annot.step = 1; }
1210
+ else if (annot.step === 1) { annot.eyeR = p; annot.step = 2; }
1211
+ else if (annot.step === 2) { annot.mouthC = p; annot.step = 3; }
1212
+ else if (annot.step === 3) { annot.dragStart = p; }
1213
+ setStatus(STEPS[Math.min(annot.step, 3)] + " (ESC로 취소)");
1214
+ });
1215
+ cv.addEventListener("pointermove", e => {
1216
+ if (!annot || annot.step !== 3 || !annot.dragStart) return;
1217
+ const p = toOrig(e), s = annot.dragStart;
1218
+ annot.box = [Math.min(s[0], p[0]), Math.min(s[1], p[1]), Math.max(s[0], p[0]), Math.max(s[1], p[1])];
1219
+ });
1220
+ cv.addEventListener("pointerup", async () => {
1221
+ if (!annot || annot.step !== 3 || !annot.box) return;
1222
+ if (annot.box[2] - annot.box[0] < 5) { annot.dragStart = null; return; }
1223
+ const name = prompt("캐릭터 이름:", "내 캐릭터");
1224
+ if (name === null) { annot.dragStart = null; annot.box = null; return; }
1225
+ await onCreate(annot, name, () => { annot = null; });
1226
+ });
1227
+ document.addEventListener("keydown", e => {
1228
+ if (e.key === "Escape" && annot) { annot = null; setStatus(""); }
1229
+ });
1230
+ function acceptFile(file) {
1231
+ if (!file || !file.type.startsWith("image/")) return;
1232
+ if (needDataUrl) { // b64 dataURL 로 읽어 annot.dataUrl 에 보존 (서버 POST용)
1233
+ const reader = new FileReader();
1234
+ reader.onload = () => {
1235
+ const img = new Image();
1236
+ img.onload = () => startAnnot(img, reader.result);
1237
+ img.src = reader.result;
1238
+ };
1239
+ reader.readAsDataURL(file);
1240
+ } else { // objectURL 로 바로 로드 (클라이언트 빌드는 dataURL 불필요)
1241
+ const img = new Image();
1242
+ img.onload = () => startAnnot(img, null);
1243
+ img.src = URL.createObjectURL(file);
1244
+ }
1245
+ }
1246
+ return { active: () => !!annot, draw, acceptFile };
1247
+ }
1248
+
1249
+ return {
1250
+ norm, inferEmotion, classifyEmotion, voiceProsody, smoothStep, weightsFromAnim, shapeAnim, SHAPE,
1251
+ EMOTIONS, makeEmotion, makeBlink, makeCursorTracker, makeGaze, makeHeadWander,
1252
+ makeMouthPicker, drawVectorMouth, drawSpriteMouth, makeWarp, speakFlow, speakWithEmotion,
1253
+ bindStatus, makeAnnotator, makeMic, chat, makeChat, makeShowcase, pickReaction, makeMirror, irisGaze, makeMirrorPanel,
1254
+ mountHead3D, applyMorphs, drawChar2D,
1255
+ };
1256
+ })();
boy.html ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <!-- 소년 미러링은 메인 스튜디오에 흡수됨 (렌더 코드 중복 제거) — 이 파일은 링크 호환용 리다이렉트 -->
3
+ <meta charset="utf-8">
4
+ <meta http-equiv="refresh" content="0; url=./?char=boy">
5
+ <a href="./?char=boy">소년 미러링으로 이동…</a>
characters/boy/base.png ADDED
characters/boy/brow_L.png ADDED
characters/boy/brow_R.png ADDED
characters/boy/eye_L_closed.png ADDED
characters/boy/eye_L_open.png ADDED
characters/boy/eye_R_closed.png ADDED
characters/boy/eye_R_open.png ADDED
characters/boy/manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "소년",
3
+ "pupilRange": 8,
4
+ "browRange": 12,
5
+ "jawDrop": 9,
6
+ "mouthCenter": [
7
+ 256,
8
+ 336
9
+ ],
10
+ "proceduralMouth": true,
11
+ "mouthStyle": {
12
+ "line": "#4a3626",
13
+ "fill": "#a2453b",
14
+ "tongue": "#e08a8a",
15
+ "teeth": "#ffffff",
16
+ "width": 30
17
+ }
18
+ }
characters/boy/pupil_L.png ADDED
characters/boy/pupil_R.png ADDED
characters/girl/base.png ADDED
characters/girl/brow_L.png ADDED
characters/girl/brow_R.png ADDED
characters/girl/eye_L_closed.png ADDED
characters/girl/eye_L_open.png ADDED
characters/girl/eye_R_closed.png ADDED
characters/girl/eye_R_open.png ADDED
characters/girl/manifest.json ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "소녀",
3
+ "persona": "너는 사용자가 그린 그림에서 태어난 밝고 다정한 아바타야. 호기심 많고 친구처럼 편하게 대화한다.",
4
+ "pupilRange": 8,
5
+ "browRange": 12,
6
+ "jawDrop": 9,
7
+ "mouthCenter": [
8
+ 256,
9
+ 336
10
+ ],
11
+ "proceduralMouth": true,
12
+ "mouthStyle": {
13
+ "line": "#5a3a2e",
14
+ "fill": "#b5504a",
15
+ "tongue": "#e79aa0",
16
+ "teeth": "#ffffff",
17
+ "width": 30
18
+ }
19
+ }
characters/girl/pupil_L.png ADDED
characters/girl/pupil_R.png ADDED
combo.html ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>DrawFace Live — 5인 동시 미러링</title>
7
+ <!-- 내 얼굴(MediaPipe) + 석고 남/여(mark·claire) + 소년/소녀를 한 makeMirror 로 동시 구동.
8
+ README 히어로용 5칸 합성 녹화 페이지 (레이아웃: 나 | 석고 위아래 | 캐릭터 위아래). -->
9
+ <style>
10
+ :root { color-scheme: dark; }
11
+ * { box-sizing: border-box; }
12
+ body { margin: 0; min-height: 100vh; display: flex; flex-direction: column; align-items: center;
13
+ font-family: 'Pretendard','Noto Sans KR',sans-serif; background: #16161d; color: #e8e8ef; }
14
+ main { padding: 20px 0 40px; width: min(1000px, 97vw); }
15
+ h1 { font-size: 1.1rem; font-weight: 700; }
16
+ .grid { display: grid; grid-template-columns: 1.05fr 1fr 1fr; gap: 10px; align-items: center; }
17
+ .cell { position: relative; aspect-ratio: 1; border-radius: 12px; overflow: hidden; border: 1px solid #2a2a35; }
18
+ .cell.d3 { background: #0d0d12; } .cell.d2 { background: #fff; }
19
+ .cell .tag { position: absolute; left: 8px; top: 6px; font-size: .72rem; color: #9a9ab0; z-index: 2; }
20
+ #meCell { grid-row: span 2; background: #0d0d12; }
21
+ canvas { width: 100%; height: 100%; display: block; }
22
+ #meCell canvas { object-fit: contain; }
23
+ .row { display: flex; gap: 8px; margin-top: 14px; align-items: center; flex-wrap: wrap; }
24
+ button { padding: 10px 16px; border-radius: 6px; border: 1px solid transparent; background: #e8e8ef; color: #16161d; font-weight: 600; cursor: pointer; }
25
+ button.alt { background: transparent; color: #c9c9d4; border-color: #3a3f47; }
26
+ #mirrorBtn.on, #recBtn.on { background: #e2647e; color: #fff; border-color: transparent; }
27
+ #status { margin-top: 10px; color: #9a9ab0; font-size: .9rem; min-height: 1.2em; }
28
+ a.link { color: #9a9ab0; font-size: .85rem; margin-left: auto; text-decoration: none; }
29
+ </style>
30
+ <script type="importmap">{ "imports": { "three": "./vendor/three.module.js" } }</script>
31
+ </head>
32
+ <body>
33
+ <main>
34
+ <h1>5인 동시 미러링 — 내 표정 → 석고 남/여 + 소년/소녀</h1>
35
+ <div class="grid">
36
+ <div class="cell" id="meCell"><span class="tag">나 (MediaPipe)</span><canvas id="meCv" width="360" height="360"></canvas></div>
37
+ <div class="cell d3"><span class="tag">석고 남</span><canvas id="markCv"></canvas></div>
38
+ <div class="cell d2"><span class="tag">소년</span><canvas id="boyCv" width="400" height="400"></canvas></div>
39
+ <div class="cell d3"><span class="tag">석고 여</span><canvas id="claireCv"></canvas></div>
40
+ <div class="cell d2"><span class="tag">소녀</span><canvas id="girlCv" width="400" height="400"></canvas></div>
41
+ </div>
42
+ <div class="row">
43
+ <button id="mirrorBtn">미러링 시작</button>
44
+ <button class="alt" id="recBtn">5칸 녹화</button>
45
+ <label style="display:flex;align-items:center;gap:6px;font-size:.9rem;color:#9a9ab0;cursor:pointer">
46
+ <input type="checkbox" id="headMove"> 머리 움직임
47
+ </label>
48
+ <a class="link" href="./">← 스튜디오</a>
49
+ </div>
50
+ <div id="status">로딩 중…</div>
51
+ </main>
52
+
53
+ <script src="avatar_core.js"></script>
54
+ <script type="module">
55
+ import * as THREE from "three";
56
+ import { GLTFLoader } from "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/loaders/GLTFLoader.js";
57
+ import { MeshoptDecoder } from "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/libs/meshopt_decoder.module.js";
58
+
59
+ const $ = id => document.getElementById(id);
60
+ const setStatus = AvatarCore.bindStatus($("status"));
61
+ let smooth = {};
62
+ const W = k => smooth[k] || 0;
63
+ const blinker = AvatarCore.makeBlink({ autoBlink: () => true, intervalMs: () => 3800, duration: 145, jitter: 0.7 });
64
+ const mirror = AvatarCore.makeMirror({ onStatus: setStatus });
65
+ // 내 얼굴 패널: 웹캠(거울) + MediaPipe 478점(홍채 주황)을 meCv 에 직접 그린다.
66
+ // makeMirrorPanel 은 DOM 마운트 가시성(offsetParent)에 의존해 여기선 못 쓰고, 같은 그림을 직접.
67
+ const meCv = $("meCv"), meCtx = meCv.getContext("2d");
68
+ function drawMe() {
69
+ const w = meCv.width, h = meCv.height;
70
+ const d = mirror.debug();
71
+ meCtx.fillStyle = "#0d0d12"; meCtx.fillRect(0, 0, w, h);
72
+ if (!mirror.on || !d.video || d.video.readyState < 2) {
73
+ meCtx.fillStyle = "#7a7a90"; meCtx.font = "15px sans-serif"; meCtx.textAlign = "center";
74
+ meCtx.fillText("미러링을 시작하면", w / 2, h / 2 - 12);
75
+ meCtx.fillText("내 얼굴이 여기 표시됩니다", w / 2, h / 2 + 14);
76
+ return;
77
+ }
78
+ meCtx.save(); meCtx.scale(-1, 1); meCtx.drawImage(d.video, -w, 0, w, h); meCtx.restore(); // 거울
79
+ if (d.lm) {
80
+ meCtx.fillStyle = "rgba(91,140,255,.85)";
81
+ for (let i = 0; i < 468; i++) meCtx.fillRect((1 - d.lm[i].x) * w - 1, d.lm[i].y * h - 1, 2, 2);
82
+ meCtx.fillStyle = "#ffb03a"; // 홍채 10점 = 시선 입력
83
+ for (let i = 468; i < d.lm.length; i++) meCtx.fillRect((1 - d.lm[i].x) * w - 2, d.lm[i].y * h - 2, 4, 4);
84
+ }
85
+ }
86
+
87
+ // ---------- 3D 헤드 하나 (mark/claire 각자 씬·렌더러) ----------
88
+ const loader = new GLTFLoader(); loader.setMeshoptDecoder(MeshoptDecoder);
89
+ function make3D(canvas, glbName) {
90
+ const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
91
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
92
+ const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0d0d12);
93
+ const camera = new THREE.PerspectiveCamera(28, 1, 0.1, 1000);
94
+ scene.add(new THREE.HemisphereLight(0xdfe6ff, 0x2a2018, 1.1));
95
+ const key = new THREE.DirectionalLight(0xffffff, 2.2); key.position.set(1.5, 2, 3); scene.add(key);
96
+ const rim = new THREE.DirectionalLight(0x77aaff, 1.0); rim.position.set(-2, 1, -2); scene.add(rim);
97
+ const group = new THREE.Group(); scene.add(group);
98
+ let mounted = null, nod = 0;
99
+ loader.load(`assets3d/${glbName}.glb?v=3`, g => {
100
+ // 석고 헤드 2종만 쓰므로 eyeSpheres·headFrac 고정 (코어가 모프 수집·눈알·프레이밍까지)
101
+ mounted = AvatarCore.mountHead3D(THREE, { group, camera, gltf: g,
102
+ cfg: { eyeSpheres: true, headFrac: 1 } });
103
+ if (!mounted) { setStatus(glbName + ": 모프타깃 메시 없음", true); return; }
104
+ ready();
105
+ }, undefined, e => setStatus(glbName + " 로드 실패: " + e, true));
106
+ const gaze = AvatarCore.makeGaze({ gx: 0, gy: 0 }, { mulX: 0.8, mulY: 0.5 });
107
+ function resize() { const r = canvas.getBoundingClientRect(); renderer.setSize(r.width, r.height, false); camera.aspect = 1; camera.updateProjectionMatrix(); }
108
+ new ResizeObserver(resize).observe(canvas);
109
+ return { render(blink, h) {
110
+ if (!mounted) return;
111
+ AvatarCore.applyMorphs(mounted.morphMeshes, smooth, blink);
112
+ const [gx, gy] = gaze(0, W);
113
+ for (const e of mounted.eyes) e.rotation.set(gy * 0.3, gx * 0.4, 0);
114
+ nod = 0.85 * nod + 0.15 * W("jawopen");
115
+ // 머리 회전: h 가 오면 웹캠 머리 자세를 얹고, 없으면 끄덕임만 (기존 동작)
116
+ if (h) group.rotation.set(h[1] + nod * 0.04, h[0], h[2]);
117
+ else group.rotation.set(nod * 0.04, 0, 0);
118
+ renderer.render(scene, camera);
119
+ } };
120
+ }
121
+
122
+ // ---------- 2D 캐릭터 하나 (벡터 입, 워프 없음 — 가벼움) ----------
123
+ const PARTS = ["base", "brow_L", "brow_R", "eye_L_open", "eye_R_open", "eye_L_closed", "eye_R_closed", "pupil_L", "pupil_R"];
124
+ function make2D(canvas, name) {
125
+ const ctx = canvas.getContext("2d");
126
+ const gaze = AvatarCore.makeGaze({ gx: 0, gy: 0 }, { mulX: 0.9, mulY: 0.6 });
127
+ let parts = {}, manifest = null;
128
+ (async () => {
129
+ manifest = await fetch(`characters/${name}/manifest.json`).then(r => r.json());
130
+ await Promise.all(PARTS.map(f => new Promise(res => { const im = new Image(); im.onload = () => { parts[f] = im; res(); }; im.onerror = res; im.src = `characters/${name}/${f}.png`; })));
131
+ ready();
132
+ })();
133
+ // 워프 없이(경량) 코어로 그린다 — 눈썹 내림·턱 오프셋까지 스튜디오와 동일 규칙.
134
+ // 머리 자세는 CSS 가 아니라 캔버스 변환으로 넘긴다 — 5칸 녹화가 drawImage 로 합성하는데
135
+ // CSS 변환은 비트맵에 안 담기기 때문(스튜디오는 shake 래퍼가 있어 CSS 경로를 쓴다).
136
+ return { render(blink, h) {
137
+ AvatarCore.drawChar2D(ctx, { parts, manifest, W, blink, gaze: gaze(0, W), head: h });
138
+ } };
139
+ }
140
+
141
+ // canvas 크기(3D)는 CSS 크기에 맞춰야 함 → 초기 400
142
+ ["markCv", "claireCv"].forEach(id => { $(id).width = 400; $(id).height = 400; });
143
+ const heads = { mark: make3D($("markCv"), "mark"), claire: make3D($("claireCv"), "claire") };
144
+ const chars = { boy: make2D($("boyCv"), "boy"), girl: make2D($("girlCv"), "girl") };
145
+
146
+ let readyN = 0;
147
+ function ready() { if (++readyN >= 4) setStatus("미러링 시작을 누르고 카메라를 허용하세요 (또는 자동 시연 대기)"); }
148
+
149
+ // ---------- 자동 쇼케이스 (미러링 전 시연) ----------
150
+ const POSES = [
151
+ { mouthsmileleft: .9, mouthsmileright: .9 }, { jawopen: .75 }, { mouthpucker: .95, jawopen: .12 },
152
+ { jawopen: .45, browinnerup: 1, eyewideleft: .7, eyewideright: .7 }, { eyeblinkleft: 1, mouthsmileright: .5 },
153
+ { eyelookoutleft: .9, eyelookinright: .9 }, { eyelookoutright: .9, eyelookinleft: .9 },
154
+ ];
155
+ let demoTarget = null, demoTimer = null; const demoW = {};
156
+ const showcase = AvatarCore.makeShowcase(pose => new Promise(res => { demoTarget = pose; setTimeout(() => { demoTarget = null; setTimeout(res, 320); }, 1300); }), POSES);
157
+ function scheduleDemo(d) { clearTimeout(demoTimer); demoTimer = setTimeout(() => { if (mirror.on || showcase.running) return; setStatus("자동 시연 중 — 미러링으로 직접 조종"); showcase.play().then(() => { if (!mirror.on) scheduleDemo(4000); }); }, d); }
158
+ function demoApply() {
159
+ if (mirror.on) { for (const k in demoW) delete demoW[k]; return; }
160
+ for (const k in demoW) if (!demoTarget || !(k in demoTarget)) demoW[k] *= 0.85;
161
+ if (demoTarget) for (const k in demoTarget) demoW[k] = 0.85 * (demoW[k] || 0) + 0.15 * demoTarget[k];
162
+ for (const k in demoW) { if (demoW[k] < 0.01) delete demoW[k]; else smooth[k] = Math.max(smooth[k] || 0, demoW[k]); }
163
+ }
164
+ scheduleDemo(2200);
165
+
166
+ $("mirrorBtn").onclick = () => {
167
+ if (mirror.on) { mirror.stop(); $("mirrorBtn").classList.remove("on"); $("mirrorBtn").textContent = "미러링 시작"; scheduleDemo(2000); return; }
168
+ showcase.stop(); clearTimeout(demoTimer);
169
+ mirror.start().then(() => { $("mirrorBtn").classList.add("on"); $("mirrorBtn").textContent = "미러링 종료"; })
170
+ .catch(e => { mirror.stop(); setStatus("미러링 실패: " + e.message, true); });
171
+ };
172
+
173
+ // ---------- 렌더 루프 ----------
174
+ let lastFrame = 0;
175
+ function frame(now) {
176
+ requestAnimationFrame(frame);
177
+ if (now - lastFrame < 30) return; // ~30fps 캡 — 5칸 렌더는 무거운데 데이터·녹화 다 30fps
178
+ lastFrame = now;
179
+ smooth = {};
180
+ demoApply();
181
+ mirror.apply(smooth, now);
182
+ const blink = Math.max(blinker.value(now), W("eyeblinkleft"), W("eyeblinkright"));
183
+ drawMe(); // 내 얼굴 + 랜드마크
184
+ const head = $("headMove").checked ? mirror.head() : null; // 네 캐릭터 공통 머리 자세
185
+ heads.mark.render(blink, head); heads.claire.render(blink, head);
186
+ chars.boy.render(blink, head); chars.girl.render(blink, head);
187
+ if (recCtx) compositeRec();
188
+ }
189
+ requestAnimationFrame(frame);
190
+
191
+ // ---------- 5칸 합성 녹화 (1200×800): [나 | 석고남/여 | 소년/녀] ----------
192
+ let recorder = null, recCanvas = null, recCtx = null;
193
+ function compositeRec() {
194
+ const c = recCtx;
195
+ c.fillStyle = "#16161d"; c.fillRect(0, 0, 1200, 800);
196
+ c.drawImage($("meCv"), 20, 220, 360, 360); // 나 (세로 가운데)
197
+ c.drawImage($("markCv"), 400, 0, 400, 400); // 석고 남
198
+ c.drawImage($("claireCv"), 400, 400, 400, 400); // 석고 여
199
+ c.fillStyle = "#fff"; c.fillRect(800, 0, 400, 800);
200
+ c.drawImage($("boyCv"), 800, 0, 400, 400); // 소년
201
+ c.drawImage($("girlCv"), 800, 400, 400, 400); // 소녀
202
+ }
203
+ $("recBtn").onclick = () => {
204
+ if (recorder) { recorder.stop(); return; }
205
+ const mime = ["video/webm;codecs=vp9", "video/webm"].find(t => window.MediaRecorder && MediaRecorder.isTypeSupported(t));
206
+ if (!mime) { setStatus("이 브라우저는 WebM 녹화 미지원", true); return; }
207
+ recCanvas = document.createElement("canvas"); recCanvas.width = 1200; recCanvas.height = 800;
208
+ recCtx = recCanvas.getContext("2d");
209
+ const chunks = [];
210
+ recorder = new MediaRecorder(recCanvas.captureStream(30), { mimeType: mime });
211
+ recorder.ondataavailable = e => e.data.size && chunks.push(e.data);
212
+ recorder.onstop = () => {
213
+ const a = document.createElement("a");
214
+ a.href = URL.createObjectURL(new Blob(chunks, { type: "video/webm" })); a.download = "drawface-5.webm"; a.click();
215
+ URL.revokeObjectURL(a.href); recorder = null; recCtx = null; recCanvas = null;
216
+ $("recBtn").classList.remove("on"); $("recBtn").textContent = "5칸 녹화"; setStatus("녹화 완료 — WebM 저장됨");
217
+ };
218
+ recorder.start(); $("recBtn").classList.add("on"); $("recBtn").textContent = "녹화 종료";
219
+ setStatus("녹화 중 — 5칸 합성 저장 (웹캠 원본은 미포함)");
220
+ };
221
+ </script>
222
+ </body>
223
+ </html>
dev/authoring_test.html ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>authoring test</title>
6
+ <style>
7
+ body { font: 13px system-ui, sans-serif; margin: 16px; background: #f4f4f4; }
8
+ #result { font-weight: bold; margin: 8px 0; }
9
+ #grid { display: flex; flex-wrap: wrap; gap: 10px; }
10
+ figure { margin: 0; background: #fff; border: 1px solid #ccc; padding: 4px; }
11
+ figure canvas { display: block; image-rendering: pixelated; }
12
+ figcaption { font-size: 11px; text-align: center; margin-top: 2px; }
13
+ pre { background: #fff; border: 1px solid #ccc; padding: 8px; max-width: 640px; overflow: auto; }
14
+ /* checkerboard so sprite transparency is visible */
15
+ .cell { background-image: linear-gradient(45deg, #ddd 25%, transparent 25%),
16
+ linear-gradient(-45deg, #ddd 25%, transparent 25%),
17
+ linear-gradient(45deg, transparent 75%, #ddd 75%),
18
+ linear-gradient(-45deg, transparent 75%, #ddd 75%);
19
+ background-size: 16px 16px; background-position: 0 0, 0 8px, 8px -8px, -8px 0; }
20
+ </style>
21
+ </head>
22
+ <body>
23
+ <h3>authoring pipeline test</h3>
24
+ <div id="result">running…</div>
25
+ <div id="grid"></div>
26
+ <pre id="manifest"></pre>
27
+
28
+ <script type="module">
29
+ import { fit512, newCanvas, rad } from "../js/imageops.js";
30
+ import { buildCharacter } from "../js/onboard.js";
31
+ import { deriveAll } from "../js/derive.js";
32
+
33
+
34
+ // (a) synthetic 200x200 drawing: white bg, two dark eye dots, a dark mouth arc.
35
+ function syntheticDrawing() {
36
+ const c = newCanvas(200, 200);
37
+ const g = c.getContext("2d");
38
+ g.fillStyle = "#ffffff";
39
+ g.fillRect(0, 0, 200, 200);
40
+ g.fillStyle = "#202020";
41
+ for (const cx of [72, 128]) { g.beginPath(); g.arc(cx, 78, 7, 0, 2 * Math.PI); g.fill(); }
42
+ g.strokeStyle = "#202020";
43
+ g.lineWidth = 3;
44
+ g.beginPath();
45
+ g.ellipse(100, 136, 22, 10, 0, rad(20), rad(160), false);
46
+ g.stroke();
47
+ return c;
48
+ }
49
+
50
+ function cell(label, base, sprite) {
51
+ const fig = document.createElement("figure");
52
+ const cv = newCanvas(140, 140);
53
+ cv.className = "cell";
54
+ const ctx = cv.getContext("2d");
55
+ ctx.imageSmoothingEnabled = false;
56
+ ctx.drawImage(base, 0, 0, 140, 140);
57
+ if (sprite !== base) ctx.drawImage(sprite, 0, 0, 140, 140);
58
+ fig.appendChild(cv);
59
+ const cap = document.createElement("figcaption");
60
+ cap.textContent = label;
61
+ fig.appendChild(cap);
62
+ return fig;
63
+ }
64
+
65
+ try {
66
+ // 200x200 square maps to the full 512 canvas (scale 2.56, no offset), so
67
+ // feature coords below are the 200-space dots/arc times 2.56.
68
+ const drawing = syntheticDrawing();
69
+ const canvas512 = fit512(drawing);
70
+ const eyes = { L: [184, 200], R: [328, 200] };
71
+ const mouthBox = [205, 328, 307, 371];
72
+ const { manifest, canvases } = buildCharacter(canvas512, "테스트", eyes, 20, mouthBox);
73
+ deriveAll(canvases, manifest);
74
+
75
+ const base = canvases["base.png"];
76
+ const grid = document.getElementById("grid");
77
+ for (const [name, sprite] of Object.entries(canvases)) {
78
+ grid.appendChild(cell(name, base, sprite));
79
+ }
80
+ document.getElementById("manifest").textContent = JSON.stringify(manifest, null, 2);
81
+
82
+ const n = Object.keys(canvases).length;
83
+ const msg = `AUTHORING OK ${n}`;
84
+ document.title = msg;
85
+ document.getElementById("result").textContent = msg;
86
+ } catch (err) {
87
+ const msg = `AUTHORING FAIL ${err && err.message ? err.message : err}`;
88
+ document.title = msg;
89
+ document.getElementById("result").textContent = msg;
90
+ console.error(err);
91
+ }
92
+ </script>
93
+ </body>
94
+ </html>
dev/runtime_test.html ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>runtime tests</title>
6
+ </head>
7
+ <body>
8
+ <h1>DrawFace runtime unit tests</h1>
9
+ <pre id="result">running…</pre>
10
+
11
+ <!-- Standalone: imports only DOM/pure modules (no tracker.js, no network). -->
12
+ <script type="module">
13
+ import {
14
+ Hysteresis, TriStateEye, eyeKeyForUserSide, pickMouth,
15
+ } from "../js/pipeline.js";
16
+ import { prepareCharacter, composeCharacter } from "../js/compositor.js";
17
+
18
+ // Match tests/test_camera_mapping.py fixtures (camelCase for the web config).
19
+ const EYES_CFG = {
20
+ closeThreshold: 0.45, openThreshold: 0.30,
21
+ halfCloseThreshold: 0.20, halfOpenThreshold: 0.12,
22
+ };
23
+ const MOUTH_CFG = {
24
+ jawClosed: 0.06, jawMid: 0.16, jawLarge: 0.32,
25
+ puckerThreshold: 0.40, funnelThreshold: 0.35, smileThreshold: 0.30,
26
+ };
27
+
28
+ let n = 0;
29
+ const eq = (got, want, msg) => {
30
+ n++;
31
+ const a = JSON.stringify(got), b = JSON.stringify(want);
32
+ if (a !== b) throw new Error(`${msg}: got ${a}, want ${b}`);
33
+ };
34
+ const ok = (cond, msg) => { n++; if (!cond) throw new Error(msg); };
35
+ const throws = (fn, msg) => {
36
+ n++;
37
+ try { fn(); } catch { return; }
38
+ throw new Error(`${msg}: expected throw`);
39
+ };
40
+
41
+ function testTriStateBands() {
42
+ const e = new TriStateEye(EYES_CFG);
43
+ eq(e.update(0.05), "open", "tristate start open");
44
+ eq(e.update(0.25), "half", "tristate crossed half_close");
45
+ eq(e.update(0.15), "half", "tristate inside half gap stays half");
46
+ eq(e.update(0.50), "closed", "tristate crossed full close");
47
+ eq(e.update(0.35), "closed", "tristate inside full gap stays closed");
48
+ eq(e.update(0.25), "half", "tristate below open_threshold -> half");
49
+ eq(e.update(0.10), "open", "tristate below half_open -> open");
50
+ }
51
+
52
+ function testHysteresisFlicker() {
53
+ const h = new Hysteresis(0.45, 0.30);
54
+ eq(h.update(0.40), false, "hyst below close stays open");
55
+ eq(h.update(0.50), true, "hyst crosses close");
56
+ eq(h.update(0.35), true, "hyst in gap stays closed");
57
+ eq(h.update(0.29), false, "hyst below open reopens");
58
+ eq(h.update(0.31), false, "hyst in gap stays open");
59
+ }
60
+
61
+ function testMouthLadder() {
62
+ eq(pickMouth({ jawOpen: 0.0 }, MOUTH_CFG), "closed", "mouth closed");
63
+ eq(pickMouth({ jawOpen: 0.02, mouthSmileLeft: 0.5, mouthSmileRight: 0.5 }, MOUTH_CFG), "smile", "mouth smile");
64
+ eq(pickMouth({ jawOpen: 0.10 }, MOUTH_CFG), "I", "mouth I");
65
+ eq(pickMouth({ jawOpen: 0.20 }, MOUTH_CFG), "E", "mouth E");
66
+ eq(pickMouth({ jawOpen: 0.40 }, MOUTH_CFG), "A", "mouth A");
67
+ eq(pickMouth({ jawOpen: 0.10, mouthPucker: 0.5 }, MOUTH_CFG), "U", "mouth U");
68
+ eq(pickMouth({ jawOpen: 0.20, mouthPucker: 0.5 }, MOUTH_CFG), "O", "mouth O (pucker)");
69
+ eq(pickMouth({ jawOpen: 0.20, mouthFunnel: 0.5 }, MOUTH_CFG), "O", "mouth O (funnel)");
70
+ }
71
+
72
+ function testMapping() {
73
+ eq(eyeKeyForUserSide("left", true), "L", "mirror user-left -> L");
74
+ eq(eyeKeyForUserSide("right", true), "R", "mirror user-right -> R");
75
+ eq(eyeKeyForUserSide("left", false), "R", "anatomical user-left -> R");
76
+ eq(eyeKeyForUserSide("right", false), "L", "anatomical user-right -> L");
77
+ for (const mirror of [true, false]) {
78
+ const sides = new Set([eyeKeyForUserSide("left", mirror), eyeKeyForUserSide("right", mirror)]);
79
+ ok(sides.size === 2 && sides.has("L") && sides.has("R"), `mapping exclusive (mirror=${mirror})`);
80
+ }
81
+ throws(() => eyeKeyForUserSide("up", true), "invalid side rejected");
82
+ }
83
+
84
+ function coloredCanvas(color) {
85
+ const c = document.createElement("canvas");
86
+ c.width = 512; c.height = 512;
87
+ const x = c.getContext("2d");
88
+ x.fillStyle = color; x.fillRect(0, 0, 512, 512);
89
+ return c;
90
+ }
91
+
92
+ function testCompositor() {
93
+ const canvases = { "base.png": coloredCanvas("#ffffff") };
94
+ for (const side of ["L", "R"]) {
95
+ canvases[`eye_${side}_open.png`] = coloredCanvas("#00ff00");
96
+ canvases[`eye_${side}_closed.png`] = coloredCanvas("#ff0000");
97
+ }
98
+ for (const k of ["closed", "A", "E", "I", "O", "U"]) canvases[`mouth_${k}.png`] = coloredCanvas("#0000ff");
99
+ // eye_*_half and mouth_smile intentionally absent -> optional fallback.
100
+
101
+ const char = prepareCharacter({ name: "t", manifest: {}, canvases });
102
+ ok(char.eyes.L.half === char.eyes.L.open, "half falls back to open");
103
+ ok(char.mouths.smile === char.mouths.closed, "smile falls back to closed");
104
+
105
+ const a = composeCharacter(char, "open", "open", "closed");
106
+ const b = composeCharacter(char, "open", "open", "closed");
107
+ ok(a === b, "compose memoized: same state -> identical object");
108
+ const c = composeCharacter(char, "closed", "open", "closed");
109
+ ok(c !== a, "compose: different state -> different object");
110
+ }
111
+
112
+ try {
113
+ testTriStateBands();
114
+ testHysteresisFlicker();
115
+ testMouthLadder();
116
+ testMapping();
117
+ testCompositor();
118
+ document.title = `RUNTIME OK ${n} assertions`;
119
+ document.getElementById("result").textContent = `RUNTIME OK — ${n} assertions passed`;
120
+ } catch (err) {
121
+ document.title = `RUNTIME FAIL ${err.message}`;
122
+ document.getElementById("result").textContent = `RUNTIME FAIL — ${err.message}`;
123
+ throw err;
124
+ }
125
+ </script>
126
+ </body>
127
+ </html>
draw.html ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>DrawFace Live — 내 손그림</title>
7
+ <link rel="stylesheet" href="style.css?v=20260729.6">
8
+ </head>
9
+ <body>
10
+ <header>
11
+ <h1>DrawFace Live — 내 손그림 <a href="./" style="font-size:.6em;font-weight:400;text-decoration:none;margin-left:8px">미러링 스튜디오 →</a></h1>
12
+ <p>손그림 한 장이 웹캠 표정을 실시간으로 따라 합니다 — 모든 처리는 브라우저 안에서만 이뤄집니다.</p>
13
+ </header>
14
+
15
+ <main>
16
+ <aside id="panel">
17
+ <label>캐릭터
18
+ <select id="charSelect"></select>
19
+ </label>
20
+ <div class="row">
21
+ <button id="deleteBtn" title="선택한 캐릭터 삭제">삭제</button>
22
+ </div>
23
+
24
+ <div id="dropZone" tabindex="0">
25
+ 그림을 끌어다 놓거나<br>클릭해서 선택
26
+ <input id="fileInput" type="file" accept="image/*" hidden>
27
+ </div>
28
+ <button id="exampleBtn">예시 캐릭터로 체험</button>
29
+ <button id="boyBtn">소년 캐릭터로 체험</button>
30
+
31
+ <label>카메라
32
+ <select id="camSelect"></select>
33
+ </label>
34
+
35
+ <label class="check"><input id="mirrorChk" type="checkbox" checked> 미러 컨트롤</label>
36
+ <label class="check"><input id="vizChk" type="checkbox" checked> 추적 시각화</label>
37
+ <label class="check"><input id="fxChk" type="checkbox" checked> 스티커 이펙트</label>
38
+ <label class="check"><input id="warpChk" type="checkbox" checked> 워프 엔진</label>
39
+ <label>성능 모드
40
+ <select id="perfMode">
41
+ <option value="auto">자동 (권장)</option>
42
+ <option value="full">고품질 고정</option>
43
+ <option value="economy">절전 고정</option>
44
+ </select>
45
+ </label>
46
+
47
+ <button id="startBtn" class="primary">시작</button>
48
+ <button id="trackerRetryBtn" hidden>추적 모델 다시 시도</button>
49
+ <button id="calibBtn" disabled>재캘리브레이션</button>
50
+ <button id="recordBtn" disabled title="결과 캔버스를 WebM 영상으로 저장">녹화 시작</button>
51
+
52
+ <div id="status" role="status" aria-live="polite">캐릭터를 온보딩하거나 선택하세요</div>
53
+ </aside>
54
+
55
+ <section id="stage">
56
+ <canvas id="preview" width="640" height="480"></canvas>
57
+ <canvas id="output" width="512" height="512"></canvas>
58
+ </section>
59
+ </main>
60
+
61
+ <dialog id="onboardDlg">
62
+ <h2>캐릭터 온보딩</h2>
63
+ <canvas id="onboardCanvas" width="512" height="512"></canvas>
64
+ <div class="row">
65
+ <label>이름 <input id="charName" size="10" placeholder="my-char"></label>
66
+ <label>눈 반박스 <input id="eyeHalf" type="number" value="16" min="6" max="64" size="3">px</label>
67
+ </div>
68
+ <div id="onboardStatus"></div>
69
+ <div class="row">
70
+ <button id="obReset">다시 클릭</button>
71
+ <button id="obGenerate" class="primary" disabled>미리보기</button>
72
+ <button id="obCancel">취소</button>
73
+ </div>
74
+ <section id="onboardReview" hidden>
75
+ <h3>생성 결과 미리보기</h3>
76
+ <canvas id="reviewCanvas" width="512" height="512"></canvas>
77
+ <label>표정
78
+ <select id="reviewState">
79
+ <option value="neutral">기본</option>
80
+ <option value="blink">눈 감기</option>
81
+ <option value="smile">미소</option>
82
+ <option value="A">입 크게 벌리기</option>
83
+ </select>
84
+ </label>
85
+ <div class="row">
86
+ <button id="obEdit">위치 수정</button>
87
+ <button id="obSave" class="primary">이 캐릭터 저장</button>
88
+ </div>
89
+ </section>
90
+ </dialog>
91
+
92
+ <footer>
93
+ <span>키: <b>c</b> 재캘리브레이션 · <b>m</b> 미러 전환</span>
94
+ <a href="https://github.com/ingon1026/drawface-live">GitHub</a>
95
+ </footer>
96
+
97
+ <script type="module" src="js/main.js?v=20260729.6"></script>
98
+ </body>
99
+ </html>
illust.html ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>일러스트 트랙 — DrawFace Live</title>
7
+ <!-- 정적 쇼케이스. 이 페이지는 추론을 돌리지 않는다 — 로컬 GPU 에서 미리 렌더한 결과만 재생한다.
8
+ 실시간 트랙(index.html)과 달리 LivePortrait 는 TensorRT + GPU 가 필요해 웹에 못 올린다.
9
+ 핵심 메시지는 "되는 그림 / 안 되는 그림" 대조다. 성공 사례만 걸면 다음 사람이
10
+ 손그림에 이 트랙을 붙였다가 같은 실패를 반복한다. -->
11
+ <style>
12
+ :root { color-scheme: dark; }
13
+ * { box-sizing: border-box; }
14
+ body {
15
+ margin: 0; min-height: 100vh; display: flex; flex-direction: column; align-items: center;
16
+ font-family: 'Pretendard', 'Noto Sans KR', sans-serif; background: #16161d; color: #e8e8ef;
17
+ }
18
+ main { width: min(900px, 96vw); padding: 24px 0 48px; }
19
+ h1 { font-size: 1.25rem; font-weight: 700; margin: 12px 0 4px; }
20
+ h2 { font-size: 1.02rem; font-weight: 700; margin: 34px 0 10px; }
21
+ p { line-height: 1.7; color: #c3c3d2; font-size: .93rem; margin: 8px 0; }
22
+ .lede { color: #9a9ab0; font-size: .9rem; margin-bottom: 6px; }
23
+ .tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: .74rem; font-weight: 700; letter-spacing: .02em; }
24
+ .tag.ok { background: #17402c; color: #7ee0a8; }
25
+ .tag.no { background: #431c22; color: #ff9aa5; }
26
+ .duo { display: flex; gap: 14px; flex-wrap: wrap; }
27
+ figure { flex: 1 1 300px; margin: 0; }
28
+ figure video, figure img {
29
+ width: 100%; display: block; border-radius: 14px; border: 1px solid #2a2a35; background: #0d0d12;
30
+ }
31
+ figcaption { color: #9a9ab0; font-size: .82rem; margin-top: 7px; line-height: 1.55; }
32
+ .card { border: 1px solid #2a2a35; border-radius: 14px; padding: 14px 16px; background: #1b1b23; }
33
+ table { border-collapse: collapse; width: 100%; font-size: .88rem; margin-top: 8px; }
34
+ th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #2a2a35; }
35
+ th { color: #9a9ab0; font-weight: 600; }
36
+ td strong { color: #7ee0a8; }
37
+ a { color: inherit; text-decoration: underline; text-underline-offset: 3px; }
38
+ .row { display: flex; gap: 8px; margin-top: 18px; align-items: center; flex-wrap: wrap; }
39
+ .btn { padding: 10px 16px; border-radius: 6px; border: 1px solid transparent; background: #e8e8ef; color: #16161d;
40
+ font-size: .93rem; font-weight: 600; text-decoration: none; }
41
+ .btn.alt { background: transparent; color: #c9c9d4; border-color: #3a3f47; }
42
+ footer { margin-top: 30px; font-size: .78rem; color: #66667a; line-height: 1.7; }
43
+ </style>
44
+ </head>
45
+ <body>
46
+ <main>
47
+ <h1>일러스트 트랙 — 그림이 내 표정을 따라 한다</h1>
48
+ <p class="lede">FasterLivePortrait · 로컬 GPU 전용 · 아래는 미리 렌더한 결과입니다</p>
49
+
50
+ <p>
51
+ 이 저장소에는 얼굴을 움직이는 방법이 <strong>두 가지</strong> 있습니다. 대상에 따라 되는 쪽이 갈립니다 —
52
+ 같은 모델인데 그림 종류에 따라 결과가 정반대라, 실측으로 확인한 뒤 트랙을 나눴습니다.
53
+ </p>
54
+
55
+ <h2><span class="tag ok">되는 것</span> &nbsp;표준 비율 일러스트 · 명화</h2>
56
+ <div class="duo">
57
+ <figure>
58
+ <video src="img/flp/vermeer.mp4" autoplay loop muted playsinline preload="metadata"></video>
59
+ <figcaption>
60
+ 베르메르 〈진주 귀걸이를 한 소녀〉(1665, 퍼블릭 도메인).
61
+ 붓질과 캔버스 균열이 그대로 남은 채 고개와 표정만 움직입니다.
62
+ </figcaption>
63
+ </figure>
64
+ <figure>
65
+ <img src="img/flp_illust_ok.png" alt="벡터 일러스트를 구동한 6프레임 — 선과 채색이 유지된 채 표정만 바뀐다">
66
+ <figcaption>
67
+ 벡터 일러스트 6프레임. 선과 채색이 재생성되지 않아 원래 화풍이 유지됩니다.
68
+ </figcaption>
69
+ </figure>
70
+ </div>
71
+
72
+ <h2><span class="tag no">안 되는 것</span> &nbsp;손그림 · 낙서 · 치비</h2>
73
+ <figure style="max-width:660px">
74
+ <img src="img/flp/fail_handdrawn.png" alt="손그림에 LivePortrait 를 적용한 결과 — 얼굴이 검출되지 않아 세 프레임 모두 변화가 없다">
75
+ <figcaption>
76
+ 같은 파이프라인, 같은 웹캠 입력. 손그림은 <strong>얼굴 검출 자체가 실패</strong>해 아무 일도 일어나지 않고,
77
+ 검출을 강제하면 머리 전체가 뭉개집니다(스미어링). 흰 종이 위 선화가 학습 분포 밖이기 때문입니다.
78
+ </figcaption>
79
+ </figure>
80
+ <p>
81
+ 그래서 손그림은 이 트랙이 아니라 <a href="./">ARAP 워프 미러링</a>이 담당합니다.
82
+ 원본 획을 삼각형 그물로 변형하는 방식이라 없는 픽셀을 만들지 않고, 브라우저에서 실시간으로 돕니다.
83
+ </p>
84
+
85
+ <h2>왜 웹에서 직접 못 돌리나</h2>
86
+ <div class="card">
87
+ <table>
88
+ <tr><th>백엔드</th><th>프레임당</th><th>체감</th></tr>
89
+ <tr><td>TensorRT (RTX 4070 Ti)</td><td><strong>27–37 ms</strong></td><td>실시간 (약 30fps)</td></tr>
90
+ <tr><td>ONNX GPU (같은 카드)</td><td>118–124 ms</td><td>약 8fps — 실시간 아님</td></tr>
91
+ </table>
92
+ <p style="margin-top:12px">
93
+ 실시간은 <strong>TensorRT 전용 숫자</strong>입니다. 그런데 TensorRT 엔진은 빌드한 GPU와 버전에 묶여
94
+ 다른 장비에서 로드되지 않습니다. 무료 호스팅은 CPU라 더 느리고, 웹캠 프레임을 서버까지 왕복시키면
95
+ "따라 한다"는 체감도 무너집니다. 그래서 이 페이지는 <strong>미리 렌더한 결과</strong>만 보여줍니다.
96
+ </p>
97
+ </div>
98
+
99
+ <div class="row">
100
+ <a class="btn" href="./">실시간 미러링 해보기</a>
101
+ <a class="btn alt" href="https://github.com/ingon1026/drawface-live#일러스트-트랙--liveportrait-실시간-2026-07-23-재실측">직접 돌려보는 법</a>
102
+ </div>
103
+
104
+ <footer>
105
+ 영상은 로컬 GPU(RTX 4070 Ti, TensorRT)에서 미리 렌더한 것으로, 이 페이지는 추론을 돌리지 않습니다.
106
+ 소리는 담기지 않았습니다.<br>
107
+ 원본 그림 — 베르메르 〈진주 귀걸이를 한 소녀〉는 퍼블릭 도메인이며
108
+ <a href="https://github.com/warmshao/FasterLivePortrait">FasterLivePortrait</a>(MIT)의 예제 이미지입니다.
109
+ 돼지 그림은 직접 그린 것입니다.<br>
110
+ 재현 방법과 로컬 패치는 <a href="https://github.com/ingon1026/drawface-live/blob/main/THIRD_PARTY.md">THIRD_PARTY.md</a>에 있습니다.
111
+ </footer>
112
+ </main>
113
+ </body>
114
+ </html>
img/auto_visemes.png ADDED

Git LFS Details

  • SHA256: 8fd6d6cffc747078ae50bd35a7f39ead6344d3e203b1a11ff2d8e8bd9f661c10
  • Pointer size: 131 Bytes
  • Size of remote file: 572 kB
img/demo.gif ADDED
img/flp/fail_handdrawn.png ADDED

Git LFS Details

  • SHA256: ab9818119517d7a8538511f3e873d9caeffcbe54ae620daca2348e996b156ed7
  • Pointer size: 131 Bytes
  • Size of remote file: 124 kB
img/flp/vermeer.mp4 ADDED
Binary file (71.5 kB). View file
 
img/flp_illust_ok.png ADDED

Git LFS Details

  • SHA256: daaab47ca73cb26fa6ba24d59660899fcdcd93d985c62058cb0b0ca54175be78
  • Pointer size: 131 Bytes
  • Size of remote file: 186 kB
img/flp_painting_ok.png ADDED

Git LFS Details

  • SHA256: fd5e9593263bd898ad8a38101f88f79b4fcf6e4d65da749e4fc939fd454a9b94
  • Pointer size: 131 Bytes
  • Size of remote file: 118 kB
img/pig_states.png ADDED

Git LFS Details

  • SHA256: df5594634dc3c0c65afa590dae5176c7bb25a21fd1bfd62b0306b3ce8fcd8d2e
  • Pointer size: 131 Bytes
  • Size of remote file: 346 kB
img/sprite_vs_flp.gif ADDED

Git LFS Details

  • SHA256: e4b1fd2d3b5958bc66bfa369b7846e64cf277b4e1296992351ac4cfac5e62ac1
  • Pointer size: 132 Bytes
  • Size of remote file: 2.39 MB
img/stick_auto.png ADDED

Git LFS Details

  • SHA256: 875e67fed5809f86d734c7a12b0a7f6d1d94f811fa60fe485ace2af52e85306a
  • Pointer size: 131 Bytes
  • Size of remote file: 376 kB
img/studio_5.gif ADDED

Git LFS Details

  • SHA256: d0ccbeeaf039b8c5028c3589ef855a198e234bb0b382d2c3ff912bb6df483d4b
  • Pointer size: 132 Bytes
  • Size of remote file: 2.31 MB
img/studio_live.gif ADDED

Git LFS Details

  • SHA256: 08a3a73e9b6d1bbb75a903eb5e4185d252aca7308d327c1f4b0426de79bafec2
  • Pointer size: 132 Bytes
  • Size of remote file: 2.22 MB
img/studio_sync.gif ADDED

Git LFS Details

  • SHA256: 736af3abdd7abd7b3ced7fca58c0d3c808d27c20710a3b1aed0706c3d0e789a9
  • Pointer size: 132 Bytes
  • Size of remote file: 3.23 MB
index.html CHANGED
@@ -1,19 +1,412 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>DrawFace Live — 표정 미러링</title>
7
+ <!-- 메인 = 미러링 스튜디오: 들어가자마자 시연. 캐릭터 선택(석고상 3D 기본 / 소년 2D),
8
+ 내 손그림은 draw.html(기존 앱 보존). mark 헤드는 NVIDIA Maya-ACE(MIT) 파생 —
9
+ assets3d/LICENSE-mark.txt 고지 포함으로 재배포 조건 충족. -->
10
+ <style>
11
+ :root { color-scheme: dark; }
12
+ * { box-sizing: border-box; }
13
+ body {
14
+ margin: 0; min-height: 100vh; display: flex; flex-direction: column; align-items: center;
15
+ font-family: 'Pretendard', 'Noto Sans KR', sans-serif; background: #16161d; color: #e8e8ef;
16
+ }
17
+ main { width: min(900px, 96vw); padding: 24px 0 48px; }
18
+ h1 { font-size: 1.25rem; font-weight: 700; margin: 12px 0; }
19
+ .duo { display: flex; gap: 14px; flex-wrap: wrap; justify-content: center; align-items: flex-start; }
20
+ .stage { flex: 1 1 380px; max-width: 560px; aspect-ratio: 1; border-radius: 16px; overflow: hidden; border: 1px solid #2a2a35; position: relative; }
21
+ /* 분석 패널: 캐릭터 옆에서 내 얼굴 + MediaPipe 랜드마크 + 표정 채널값을 비교군으로 보여준다
22
+ (내용물은 코어 AvatarCore.makeMirrorPanel 이 인라인 스타일로 생성 — 여긴 배치만) */
23
+ #panel { flex: 0 0 280px; display: none; }
24
+ #panel.on { display: block; }
25
+ #recBtn.on { background: #e2647e; color: #fff; border-color: transparent; }
26
+ @media (max-width: 620px) {
27
+ main { padding: 12px 0 32px; }
28
+ #panel { flex: 1 1 100%; max-width: 560px; }
29
+ }
30
+ #stage3d { background: #0d0d12; cursor: grab; }
31
+ #stage2d { background: #fff; }
32
+ .warp-shake { position: absolute; inset: 0; will-change: transform; }
33
+ .warp-shake canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
34
+ #gl { z-index: 0; } #cv { z-index: 1; }
35
+ #cv3d { width: 100%; height: 100%; display: block; }
36
+ .row { display: flex; gap: 8px; margin-top: 14px; align-items: center; flex-wrap: wrap; }
37
+ button { padding: 10px 16px; border-radius: 6px; border: 1px solid transparent; background: #e8e8ef; color: #16161d; font-size: .95rem; font-weight: 600; cursor: pointer; }
38
+ button.alt { background: transparent; color: #c9c9d4; border-color: #3a3f47; }
39
+ #mirrorBtn.on { background: #e2647e; color: #fff; }
40
+ select { padding: 10px; border-radius: 6px; background: #0d0d12; color: inherit; border: 1px solid #3a3f47; font-size: .95rem; }
41
+ a.link { color: #9a9ab0; font-size: .88rem; text-decoration: none; margin-left: auto; }
42
+ #status { margin-top: 12px; color: #9a9ab0; font-size: .9rem; min-height: 1.2em; }
43
+ #status.error { color: #ff7a7a; }
44
+ footer { margin-top: 22px; font-size: .78rem; color: #66667a; line-height: 1.6; }
45
+ footer a { color: inherit; text-decoration: underline; text-underline-offset: 3px; }
46
+ /* ---- OBS 브라우저 소스용 클린 모드 (?clean=1) ----
47
+ UI 를 전부 숨기고 배경을 투명하게 만들어, 캐릭터만 방송 화면 위에 얹히게 한다. */
48
+ body.clean { background: transparent; }
49
+ body.clean h1, body.clean .row, body.clean #status, body.clean footer, body.clean #panel { display: none !important; }
50
+ body.clean main { width: 100vw; padding: 0; }
51
+ body.clean .duo { gap: 0; }
52
+ body.clean .stage { flex: 1 1 100vmin; max-width: 100vmin; border: 0; border-radius: 0; }
53
+ /* #stage3d/#stage2d 는 ID 로 배경이 지정돼 있어 같은 특이도(ID+클래스)로 덮어야 한다 */
54
+ body.clean #stage3d, body.clean #stage2d { background: transparent; }
55
+ </style>
56
+ <script type="importmap">{ "imports": { "three": "./vendor/three.module.js" } }</script>
57
+ </head>
58
+ <body>
59
+ <main>
60
+ <h1>DrawFace Live — 웹캠 표정 미러링</h1>
61
+
62
+ <div class="duo">
63
+ <div class="stage" id="stage3d"><canvas id="cv3d"></canvas></div>
64
+ <div class="stage" id="stage2d" style="display:none"><div class="warp-shake" id="shake">
65
+ <canvas id="gl" width="512" height="512"></canvas>
66
+ <canvas id="cv" width="512" height="512"></canvas>
67
+ </div></div>
68
+ <div id="panel"></div>
69
+ </div>
70
+
71
+ <div class="row">
72
+ <button id="mirrorBtn">미러링 시작</button>
73
+ <button class="alt" id="panelBtn">분석</button>
74
+ <button class="alt" id="recBtn" title="캐릭터 캔버스만 WebM 영상으로 저장">녹화</button>
75
+ <select id="charSel">
76
+ <option value="mark">석고상 남성 (NVIDIA mark · 3D)</option>
77
+ <option value="claire">석고상 여성 (NVIDIA claire · 3D)</option>
78
+ <option value="rpmA">아바타 A (Ready Player Me · 3D)</option>
79
+ <option value="rpmB">아바타 B (Ready Player Me · 3D)</option>
80
+ <option value="boy">소년 (그래픽 · 2D)</option>
81
+ <option value="girl">소녀 (그래픽 · 2D)</option>
82
+ </select>
83
+ <label style="display:flex;align-items:center;gap:6px;font-size:.9rem;color:#9a9ab0;cursor:pointer">
84
+ <input type="checkbox" id="headMove"> 머리 움직임
85
+ </label>
86
+ <a class="link" href="combo.html" style="margin-left:auto">5인 동시</a>
87
+ <a class="link" href="draw.html" style="margin-left:12px">내 손그림 →</a>
88
+ </div>
89
+
90
+ <div id="status">모델 로딩 중…</div>
91
+
92
+ <footer>
93
+ 전부 브라우저 안에서 처리 — 영상은 어디로도 전송되지 않습니다.
94
+ 초상화·실사급 일러스트는 로컬 GPU 트랙(LivePortrait 30fps) —
95
+ <a href="illust.html">결과 보기</a> ·
96
+ <a href="https://github.com/ingon1026/drawface-live#일러스트-트랙--liveportrait-실시간-2026-07-23-재실측">실측 근거</a>.
97
+ mark·claire 헤드 © NVIDIA, <a href="assets3d/LICENSE-mark.txt">MIT (Maya-ACE)</a> 파생.
98
+ 말하는 아바타는 <a href="https://ingon1026.github.io/talking-drawing-avatar/">talking-drawing-avatar</a>.
99
+ </footer>
100
+ </main>
101
+
102
+ <script src="avatar_core.js"></script>
103
+ <script type="module">
104
+ let THREE, GLTFLoader, MeshoptDecoder;
105
+ try {
106
+ THREE = await import("three");
107
+ // GLTFLoader·meshopt 디코더는 CDN(jsDelivr, three r160 동일 버전) 우선 — Pages 신규 경로의
108
+ // 엣지 404 캐시를 회피한다(MediaPipe 도 같은 CDN 으로 로드 중이라 도달성 검증됨). 실패 시 로컬 폴백.
109
+ try {
110
+ ({ GLTFLoader } = await import("https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/loaders/GLTFLoader.js"));
111
+ } catch {
112
+ ({ GLTFLoader } = await import("./vendor/GLTFLoader.js?v=2"));
113
+ }
114
+ // mark.glb 는 EXT_meshopt_compression (41MB → 1.8MB) — 디코더 필수
115
+ try {
116
+ ({ MeshoptDecoder } = await import("https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/libs/meshopt_decoder.module.js"));
117
+ } catch {
118
+ ({ MeshoptDecoder } = await import("./vendor/meshopt_decoder.module.js"));
119
+ }
120
+ } catch (e) {
121
+ // 진짜 원인 추적용: 실패한 리소스의 실제 HTTP 상태를 상태줄에 노출
122
+ let detail = e.message;
123
+ try {
124
+ const r = await fetch("./vendor/GLTFLoader.js?v=2", { cache: "reload" });
125
+ detail += ` (로컬 fetch: HTTP ${r.status})`;
126
+ } catch (fe) { detail += ` (로컬 fetch: ${fe.message})`; }
127
+ document.getElementById("status").textContent = "3D 모듈 로드 실패: " + detail;
128
+ throw e;
129
+ }
130
+
131
+ const $ = id => document.getElementById(id);
132
+ const setStatus = AvatarCore.bindStatus($("status"));
133
+
134
+ // ---- OBS 브라우저 소스 모드 (?clean=1) ----
135
+ // UI 숨김 + 투명 배경으로 캐릭터만 남긴다. ?auto=1 이면 로드 직후 미러링 자동 시작
136
+ // (OBS 브라우저 소스는 클릭이 없으므로 — 카메라 권한은 OBS 가 자동 허용).
137
+ const Q = new URLSearchParams(location.search);
138
+ const CLEAN = Q.get("clean") === "1";
139
+ if (CLEAN) document.body.classList.add("clean");
140
+ if (Q.get("head") === "1") $("headMove").checked = true; // 쿼리는 체크박스 상태로 반영(단일 진실)
141
+
142
+
143
+ // ================= 공유: 미러링 + 깜빡임 =================
144
+ let smooth = {};
145
+ const W = k => smooth[k] || 0;
146
+ const blinker = AvatarCore.makeBlink({ autoBlink: () => true, intervalMs: () => 3800, duration: 145, jitter: 0.7 });
147
+ const mirror = AvatarCore.makeMirror({ onStatus: setStatus });
148
+ $("mirrorBtn").onclick = () => {
149
+ if (mirror.on) {
150
+ mirror.stop(); $("mirrorBtn").classList.remove("on"); $("mirrorBtn").textContent = "미러링 시작";
151
+ scheduleDemo(3000); // 미러링 끝나면 유휴 시연 재개
152
+ return;
153
+ }
154
+ showcase.stop(); clearTimeout(demoTimer); // 직접 조종 시작 — 시연 즉시 중단
155
+ mirror.start().then(() => { $("mirrorBtn").classList.add("on"); $("mirrorBtn").textContent = "미러링 종료"; panel.classList.add("on"); })
156
+ .catch(e => { mirror.stop(); $("mirrorBtn").classList.remove("on"); setStatus("미러링 실패: " + e.message, true); });
157
+ };
158
+
159
+ // ================= 자동 쇼케이스: 카메라 허용 전에도 살아있는 데모 =================
160
+ // 포즈(채널값) 시퀀스를 EMA 로 부드럽게 수렴시켜 시연 — 시퀀서는 코어 makeShowcase 재사용.
161
+ // 3D/2D 렌더러가 같은 채널을 소비하므로 석고상·소년 모두 자동 적용. 미러링 중엔 완전히 꺼짐.
162
+ const POSES = [
163
+ { mouthsmileleft: .9, mouthsmileright: .9, eyesquintleft: .4, eyesquintright: .4 }, // 활짝 미소
164
+ { jawopen: .75 }, // 아
165
+ { mouthpucker: .95, mouthfunnel: .4, jawopen: .12 }, // 우 (오므림)
166
+ { jawopen: .45, browinnerup: 1, eyewideleft: .7, eyewideright: .7 }, // 놀람
167
+ { eyeblinkleft: 1, mouthsmileleft: .6, mouthsmileright: .3 }, // 윙크
168
+ { eyelookoutleft: .9, eyelookinright: .9 }, // 시선 왼쪽
169
+ { eyelookoutright: .9, eyelookinleft: .9 }, // 시선 오른쪽
170
+ ];
171
+ let demoTarget = null, demoTimer = null;
172
+ const demoW = {};
173
+ const showcase = AvatarCore.makeShowcase(pose => new Promise(res => {
174
+ demoTarget = pose;
175
+ setTimeout(() => { demoTarget = null; setTimeout(res, 320); }, 1300);
176
+ }), POSES);
177
+ function scheduleDemo(delay) {
178
+ clearTimeout(demoTimer);
179
+ demoTimer = setTimeout(() => {
180
+ if (mirror.on || showcase.running) return;
181
+ setStatus("자동 시연 중 — 미러링 시작을 누르면 직접 조종할 수 있어요");
182
+ showcase.play().then(() => {
183
+ if (!mirror.on) { setStatus("미러링 시작을 누르고 카메라를 허용해주세요"); scheduleDemo(5000); }
184
+ });
185
+ }, delay);
186
+ }
187
+ function demoApply() {
188
+ if (mirror.on) { for (const k in demoW) delete demoW[k]; return; }
189
+ for (const k in demoW) if (!demoTarget || !(k in demoTarget)) demoW[k] *= 0.85; // 지난 포즈 채널 감쇠
190
+ if (demoTarget) for (const k in demoTarget) demoW[k] = 0.85 * (demoW[k] || 0) + 0.15 * demoTarget[k];
191
+ for (const k in demoW) {
192
+ if (demoW[k] < 0.01) delete demoW[k];
193
+ else smooth[k] = Math.max(smooth[k] || 0, demoW[k]);
194
+ }
195
+ }
196
+ scheduleDemo(2200); // 첫 진입 — 모델 로드될 즈음 시연 시작
197
+
198
+ // ================= 분석 패널 (코어 makeMirrorPanel — face 페이지들과 공용) =================
199
+ const panel = $("panel");
200
+ const mpanel = AvatarCore.makeMirrorPanel(mirror, panel);
201
+ $("panelBtn").onclick = () => panel.classList.toggle("on");
202
+
203
+ // ================= 녹화: 캐릭터 캔버스만 WebM 저장 (draw.html 과 같은 방식) =================
204
+ // 3D 는 cv3d 직접, 2D 는 gl+cv 두 레이어라 오프스크린 합성 캔버스를 매 프레임 채워 녹화.
205
+ let recorder = null, recCv = null;
206
+ function stopRec() {
207
+ if (recorder && recorder.state !== "inactive") recorder.stop();
208
+ }
209
+ $("recBtn").onclick = () => {
210
+ if (recorder) { stopRec(); return; }
211
+ const mime = ["video/webm;codecs=vp9", "video/webm"].find(t => window.MediaRecorder && MediaRecorder.isTypeSupported(t));
212
+ if (!mime) { setStatus("이 브라우저는 WebM 녹화를 지원하지 않습니다", true); return; }
213
+ let stream;
214
+ if (is3D()) stream = canvas3d.captureStream(30); // mark·claire 둘 다 canvas3d 에 렌더
215
+ else {
216
+ recCv = document.createElement("canvas");
217
+ recCv.width = 512; recCv.height = 512;
218
+ stream = recCv.captureStream(30);
219
+ }
220
+ const chunks = [];
221
+ recorder = new MediaRecorder(stream, { mimeType: mime });
222
+ recorder.ondataavailable = e => e.data.size && chunks.push(e.data);
223
+ recorder.onstop = () => {
224
+ const a = document.createElement("a");
225
+ a.href = URL.createObjectURL(new Blob(chunks, { type: "video/webm" }));
226
+ a.download = "drawface-live.webm";
227
+ a.click();
228
+ URL.revokeObjectURL(a.href);
229
+ recorder = null; recCv = null;
230
+ $("recBtn").classList.remove("on"); $("recBtn").textContent = "녹화";
231
+ setStatus("녹화 완료 — WebM 다운로드를 시작했습니다");
232
+ };
233
+ recorder.start();
234
+ $("recBtn").classList.add("on"); $("recBtn").textContent = "녹화 종료";
235
+ setStatus("녹화 중 — 캐릭터 캔버스만 저장됩니다 (웹캠 화면은 포함 안 됨)");
236
+ };
237
+
238
+ // ================= 3D: 석고상 (mark) =================
239
+ const canvas3d = $("cv3d");
240
+ const renderer = new THREE.WebGLRenderer({ canvas: canvas3d, antialias: true, alpha: CLEAN });
241
+ renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
242
+ if (CLEAN) renderer.setClearAlpha(0);
243
+ const scene = new THREE.Scene();
244
+ scene.background = CLEAN ? null : new THREE.Color(0x0d0d12); // 클린 모드는 투명(OBS 합성용)
245
+ const camera = new THREE.PerspectiveCamera(28, 1, 0.1, 1000);
246
+ scene.add(new THREE.HemisphereLight(0xdfe6ff, 0x2a2018, 1.1));
247
+ const key = new THREE.DirectionalLight(0xffffff, 2.2); key.position.set(1.5, 2, 3); scene.add(key);
248
+ const rim = new THREE.DirectionalLight(0x77aaff, 1.0); rim.position.set(-2, 1, -2); scene.add(rim);
249
+ const group = new THREE.Group();
250
+ scene.add(group);
251
+ let mounted = null; // mountHead3D 결과 { sceneNode, morphMeshes, eyes }
252
+
253
+ const gltfLoader = new GLTFLoader();
254
+ gltfLoader.setMeshoptDecoder(MeshoptDecoder);
255
+ // 3D 헤드 정의 — 전부 ARKit 52 모프타깃이라 구동 코드는 공용, 개별 차이만 여기 선언.
256
+ // 석고(mark·claire): 우리 리포 자산, 머리만 있고 안구 메시가 없어 눈알 구체를 넣는다.
257
+ // RPM: Ready Player Me CDN 에서 런타임 로드(리포에 재배포하지 않음 — 원 제공자 CDN 이 정식 사용법).
258
+ // 반신이라 상단 일부만 머리로 잡아 프레이밍하고, 눈·머리카락·옷이 이미 있어 구체 불필요
259
+ // (시선도 eyeLook 모프로 직접 돌아간다).
260
+ // RPM 아바타는 jsDelivr(GitHub) 에 공개된 것을 런타임 로드한다 — 우리 ���포에 재배포하지 않고,
261
+ // 커밋 SHA 로 고정한다(@main 은 남의 브랜치라 force-push·삭제로 조용히 깨질 수 있음).
262
+ // 이미 three.js·디코더를 받아오는 CDN 이라 도달성·CORS 가 검증돼 있다(models.readyplayer.me 는 실패).
263
+ const GH = "https://cdn.jsdelivr.net/gh/";
264
+ const HEADS = {
265
+ mark: { url: "assets3d/mark.glb?v=3", label: "석고상 남성", eyeSpheres: true, headFrac: 1 },
266
+ claire: { url: "assets3d/claire.glb?v=3", label: "석고상 여성", eyeSpheres: true, headFrac: 1 },
267
+ rpmA: { url: GH + "AgoraIO-Community/agora-mediapipe-readyplayerme@c1aeedbd0501c1e1ad013947bf258639baf6085f/public/6644d711e53a2fb55928d72e.glb",
268
+ label: "아바타 A", eyeSpheres: false, headFrac: 0.22 },
269
+ rpmB: { url: GH + "met4citizen/TalkingHead@eed58d198076a7e1e825f804802921c4d3804d46/avatars/brunette.glb",
270
+ label: "아바타 B", eyeSpheres: false, headFrac: 0.22 },
271
+ };
272
+ const glbCache = {};
273
+ let loaded3D = null; // 현재 화면에 올라간 3D 헤드(로드 실패 시 되돌리기용)
274
+ function load3D(name) {
275
+ const cfg = HEADS[name];
276
+ const done = g => {
277
+ const r = AvatarCore.mountHead3D(THREE, { group, camera, gltf: g, cfg, prev: mounted });
278
+ if (!r) { setStatus(cfg.label + ": 표정 모프타깃이 없는 모델입니다", true); return; }
279
+ mounted = r;
280
+ loaded3D = name; // 캐시 히트 포함, 화면에 실제로 올라간 시점에 확정
281
+ setStatus("미러링 시작을 누르고 카메라를 허용해주세요");
282
+ };
283
+ if (glbCache[name]) return done(glbCache[name]);
284
+ setStatus(cfg.label + " 불러오는 중…");
285
+ gltfLoader.load(cfg.url, g => { glbCache[name] = g; done(g); },
286
+ undefined, () => {
287
+ // 실패 시 화면엔 직전 헤드가 남으므로 드롭다운도 되돌려 UI 와 화면을 일치시킨다
288
+ setStatus(cfg.label + " 를 불러오지 못했습니다 — 네트워크를 확인해주세요", true);
289
+ $("charSel").value = loaded3D || "mark";
290
+ });
291
+ }
292
+ // 초기 로드는 캐릭터 선택부에서 현재 selection 으로 한 번만 (mark/claire 이중로드 경쟁 방지)
293
+
294
+ function resize3d() {
295
+ const r = canvas3d.parentElement.getBoundingClientRect();
296
+ renderer.setSize(r.width, r.height, false);
297
+ camera.aspect = 1; camera.updateProjectionMatrix();
298
+ }
299
+ new ResizeObserver(resize3d).observe(canvas3d.parentElement);
300
+
301
+ let dragging = false, px = 0, py = 0, userYaw = 0, userPitch = 0, nod = 0;
302
+ canvas3d.addEventListener("pointerdown", e => { dragging = true; px = e.clientX; py = e.clientY; });
303
+ addEventListener("pointerup", () => dragging = false);
304
+ addEventListener("pointermove", e => {
305
+ if (!dragging) return;
306
+ userYaw += (e.clientX - px) * 0.005; userPitch += (e.clientY - py) * 0.003;
307
+ userPitch = Math.max(-0.4, Math.min(0.4, userPitch));
308
+ px = e.clientX; py = e.clientY;
309
+ });
310
+ const gaze3d = AvatarCore.makeGaze(AvatarCore.makeCursorTracker(canvas3d), { mulX: 0.8, mulY: 0.5 });
311
+
312
+ // ================= 2D: 소년·소녀 (벡터 입 + WebGL 워프 + 동공) =================
313
+ // 파츠 출처: talking-drawing-avatar 기본 캐릭터 규격 (base·눈썹·눈 뜸/감음·동공, 입은 벡터)
314
+ const cv = $("cv"), ctx2d = cv.getContext("2d");
315
+ const glCanvas = $("gl"), shakeEl = $("shake");
316
+ const VECTOR_PARTS = ["base", "brow_L", "brow_R", "eye_L_open", "eye_R_open",
317
+ "eye_L_closed", "eye_R_closed", "pupil_L", "pupil_R"];
318
+ let parts = {}, manifest = null;
319
+ const charCache = {};
320
+ const gaze2d = AvatarCore.makeGaze(AvatarCore.makeCursorTracker($("stage2d")), { mulX: 0.9, mulY: 0.6 });
321
+ const headWander = AvatarCore.makeHeadWander();
322
+ const warp = AvatarCore.makeWarp({
323
+ threeUrl: "./vendor/three.module.js", glCanvas,
324
+ getParts: () => parts, getManifest: () => manifest, W,
325
+ });
326
+
327
+ async function loadChar(name) {
328
+ if (!charCache[name]) {
329
+ const m = await fetch(`characters/${name}/manifest.json`).then(r => r.json());
330
+ const ps = {};
331
+ await Promise.all(VECTOR_PARTS.map(f => new Promise(res => {
332
+ const img = new Image();
333
+ img.onload = () => { ps[f] = img; res(); };
334
+ img.onerror = () => res();
335
+ img.src = `characters/${name}/${f}.png`;
336
+ })));
337
+ charCache[name] = { parts: ps, manifest: m };
338
+ }
339
+ ({ parts, manifest } = charCache[name]);
340
+ warp.setCharacter();
341
+ }
342
+
343
+ // ================= 캐릭터 선택 =================
344
+ // select 값이 단일 진실 — mark·claire=3D 헤드 / boy·girl=2D 그래픽
345
+ const mode = () => $("charSel").value;
346
+ const is3D = () => mode() in HEADS; // HEADS 에 선언된 것이 3D 헤드 (석고 2종 + RPM 2종)
347
+ $("charSel").onchange = () => {
348
+ stopRec(); // 녹화 스트림은 모드별 캔버스에 물려 있어 전환 시 종료
349
+ $("stage3d").style.display = is3D() ? "" : "none";
350
+ $("stage2d").style.display = is3D() ? "none" : "";
351
+ if (is3D()) load3D(mode());
352
+ else loadChar(mode()).catch(e => setStatus("캐릭터 로드 실패: " + e.message, true));
353
+ };
354
+ // 딥링크: ?char=mark|claire|boy|girl 로 진입 (draw.html·boy.html 이 사용)
355
+ const deep = Q.get("char");
356
+ if (deep && [...$("charSel").options].some(o => o.value === deep)) $("charSel").value = deep;
357
+ $("charSel").onchange(); // 현재 selection 을 한 번 로드 (기본 mark 또는 딥링크)
358
+ // ?auto=1 — OBS 처럼 클릭이 불가능한 환경에서 미러링 자동 시작 (권한 거부 시 조용히 무시)
359
+ if (Q.get("auto") === "1") {
360
+ showcase.stop(); clearTimeout(demoTimer);
361
+ mirror.start().then(() => { $("mirrorBtn").classList.add("on"); $("mirrorBtn").textContent = "미러링 종료"; })
362
+ .catch(() => setStatus("자동 미러링 실패 — 카메라 권한을 확인해주세요", true));
363
+ }
364
+
365
+ // ================= 렌더 루프 (활성 캐릭터만 그림) =================
366
+ let lastFrame = 0;
367
+ function render(now) {
368
+ requestAnimationFrame(render);
369
+ if (now - lastFrame < 30) return; // ~30fps 캡 — 데이터·녹화 모두 30fps라 고주사율에서 낭비 방지
370
+ lastFrame = now;
371
+ smooth = {};
372
+ demoApply(); // 자동 쇼케이스 (미러링 off 일 때만)
373
+ mirror.apply(smooth, now);
374
+ const blink = Math.max(blinker.value(now), W("eyeblinkleft"), W("eyeblinkright"));
375
+ mpanel.draw(W); // 트래킹 미리보기
376
+
377
+ if (is3D()) {
378
+ if (!mounted) return;
379
+ AvatarCore.applyMorphs(mounted.morphMeshes, smooth, blink);
380
+ const [gsx, gsy] = gaze3d(0, W);
381
+ for (const e of mounted.eyes) e.rotation.set(gsy * 0.3, gsx * 0.4, 0);
382
+ nod = 0.85 * nod + 0.15 * W("jawopen");
383
+ // 머리 회전: 토글 ON 이면 웹캠 머리 자세를 전이, OFF 면 표정만 (기존 동작 유지)
384
+ const h = $("headMove").checked ? mirror.head() : null;
385
+ if (h) group.rotation.set(userPitch + h[1] + nod * 0.04, userYaw + h[0], h[2]);
386
+ else group.rotation.set(userPitch + nod * 0.04, userYaw, 0);
387
+ renderer.render(scene, camera);
388
+ return;
389
+ }
390
+
391
+ // 2D 캐릭터 (소년·소녀) — 그리기는 코어, 워프 레이어 토글만 페이지
392
+ if (!parts.base || !manifest) return;
393
+ // 머리 움직임 토글 ON 이면 웹캠 고개 방향을 유휴 흔들림 위에 합성 (2D: roll=회전, yaw/pitch=이동)
394
+ headWander(shakeEl, now, W("jawopen"), 0.5, null, $("headMove").checked ? mirror.head() : null);
395
+ if (warp.ready) { glCanvas.style.visibility = "visible"; warp.render(); }
396
+ else { glCanvas.style.visibility = "hidden"; }
397
+ AvatarCore.drawChar2D(ctx2d, { parts, manifest, W, blink, gaze: gaze2d(0, W), warp, clearBg: !CLEAN });
398
+
399
+ if (recCv) { // 2D 녹화 합성 — 두 레이어(gl 워프 + cv 오버레이)
400
+ const c = recCv.getContext("2d");
401
+ c.fillStyle = "#fff"; c.fillRect(0, 0, 512, 512);
402
+ if (warp.ready) c.drawImage(glCanvas, 0, 0);
403
+ c.drawImage(cv, 0, 0);
404
+ }
405
+ }
406
+
407
+ warp.init();
408
+ loadChar(is3D() ? "boy" : mode()).catch(e => setStatus("캐릭터 로드 실패: " + e.message, true)); // 2D 프리로드(3D 진입 시 boy 워밍, 2D 진입 시 활성 캐릭터 유지)
409
+ requestAnimationFrame(render);
410
+ </script>
411
+ </body>
412
  </html>
js/compositor.js ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Canvas compositor — the web counterpart of app/sprite_backend.py's
2
+ // SpriteCharacter.compose + apply_head_transform. Sprites are 512x512 RGBA
3
+ // overlays aligned to a flattened base; canvas alpha compositing replaces the
4
+ // premultiplied numpy math. Sprite keys 'L'/'R' are VIEWER-left/right.
5
+ import { CANVAS } from "./config.js?v=20260729.6";
6
+ import { newCanvas } from "./imageops.js?v=20260729.6";
7
+ import { deriveAll } from "./derive.js?v=20260729.6";
8
+
9
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
10
+
11
+ function req(canvases, file) {
12
+ const c = canvases[file];
13
+ if (!c) throw new Error(`missing sprite: ${file} (artwork is never auto-generated)`);
14
+ return c;
15
+ }
16
+
17
+ // Flatten a possibly-transparent base onto white paper, once, into a 512 canvas.
18
+ function flattenBase(base) {
19
+ const c = newCanvas(CANVAS, CANVAS);
20
+ const ctx = c.getContext("2d");
21
+ ctx.fillStyle = "#ffffff";
22
+ ctx.fillRect(0, 0, CANVAS, CANVAS);
23
+ ctx.drawImage(base, 0, 0);
24
+ return c;
25
+ }
26
+
27
+ // From {name, manifest, canvases:{file:canvas}} (store.js loadCharacter shape) build
28
+ // the runtime character: flattened base, eye/mouth sprite lookup, memo cache. Absent
29
+ // optional sprites resolve once here (half->open, smile->closed), like sprite_backend.
30
+ export function prepareCharacter(loaded) {
31
+ const { canvases } = loaded;
32
+ // store.js persists only the core sprite set; derived ones (visemes, halfs,
33
+ // smile) are deterministic, so rebuild them here on load (~100 ms).
34
+ if (loaded.manifest?.proceduralMouth && !canvases["mouth_A.png"]) {
35
+ deriveAll(canvases, loaded.manifest);
36
+ }
37
+ const base = flattenBase(req(canvases, "base.png"));
38
+
39
+ const eyes = {};
40
+ for (const side of ["L", "R"]) {
41
+ const open = req(canvases, `eye_${side}_open.png`);
42
+ const closed = req(canvases, `eye_${side}_closed.png`);
43
+ const half = canvases[`eye_${side}_half.png`] ?? open;
44
+ eyes[side] = { open, half, closed };
45
+ }
46
+
47
+ const mouths = {};
48
+ for (const k of ["closed", "A", "E", "I", "O", "U"]) mouths[k] = req(canvases, `mouth_${k}.png`);
49
+ mouths.smile = canvases["mouth_smile.png"] ?? mouths.closed;
50
+
51
+ return { name: loaded.name, manifest: loaded.manifest, base, eyes, mouths,
52
+ memo: new Map() };
53
+ }
54
+
55
+ // Memoized per-state composite (base + eye L + eye R + mouth), <=72 combinations.
56
+ // Callers must NOT mutate the returned canvas — it is the cached instance.
57
+ export function composeCharacter(char, eyeL, eyeR, mouth) {
58
+ const key = `${eyeL}|${eyeR}|${mouth}`;
59
+ const cached = char.memo.get(key);
60
+ if (cached) return cached;
61
+
62
+ const c = newCanvas(CANVAS, CANVAS);
63
+ const ctx = c.getContext("2d");
64
+ ctx.drawImage(char.base, 0, 0);
65
+ ctx.drawImage(char.eyes.L[eyeL], 0, 0);
66
+ ctx.drawImage(char.eyes.R[eyeR], 0, 0);
67
+ ctx.drawImage(char.mouths[mouth], 0, 0);
68
+ char.memo.set(key, c);
69
+ return c;
70
+ }
71
+
72
+ // getImageData is a synchronous GPU->CPU readback — cache per composed canvas
73
+ // (composites are memoized and never mutated, so the corner pixel is invariant).
74
+ const bgColorCache = new WeakMap();
75
+ function topLeftColor(canvas) {
76
+ let color = bgColorCache.get(canvas);
77
+ if (!color) {
78
+ const px = canvas.getContext("2d").getImageData(0, 0, 1, 1).data;
79
+ color = `rgb(${px[0]},${px[1]},${px[2]})`;
80
+ bgColorCache.set(canvas, color);
81
+ }
82
+ return color;
83
+ }
84
+
85
+ // Draw a composed character onto ctx (512x512) with a cheap 2.5D head transform:
86
+ // translate for yaw/pitch, rotate for roll, both about the canvas center — matching
87
+ // app/sprite_backend.apply_head_transform. BORDER_REPLICATE is approximated by
88
+ // pre-filling with the composed top-left pixel color.
89
+ export function drawScene(ctx, composed, head, headCfg) {
90
+ // The target canvas may be a hi-res warp output (source-resolution rig) —
91
+ // scale the px-tuned head gains so motion stays proportional.
92
+ const S = ctx.canvas.width;
93
+ const k = S / CANVAS;
94
+ const dx = clamp(head.yaw * headCfg.yawGainPx, -headCfg.maxShiftPx, headCfg.maxShiftPx) * k;
95
+ const dy = clamp(head.pitch * headCfg.pitchGainPx, -headCfg.maxShiftPx, headCfg.maxShiftPx) * k;
96
+ const angleDeg = clamp(head.roll * headCfg.rollGain, -headCfg.maxRollDeg, headCfg.maxRollDeg);
97
+ const cx = S / 2, cy = S / 2;
98
+
99
+ ctx.save();
100
+ ctx.fillStyle = topLeftColor(composed);
101
+ ctx.fillRect(0, 0, S, S);
102
+ // Transform = Translate(dx,dy) ∘ RotateAboutCenter(angle); negative angle matches
103
+ // OpenCV getRotationMatrix2D's CCW-positive convention (canvas rotate is CW-positive).
104
+ ctx.translate(cx + dx, cy + dy);
105
+ ctx.rotate((-angleDeg * Math.PI) / 180);
106
+ ctx.translate(-cx, -cy);
107
+ ctx.drawImage(composed, 0, 0, S, S);
108
+ ctx.restore();
109
+ }
js/config.js ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Mirror of configs/app.yaml — keep thresholds in sync when tuning.
2
+ export const CONFIG = {
3
+ camera: { width: 640, height: 480 },
4
+ control: { mirror: true },
5
+ // Live loop uses the One Euro filter; the fixed alphas remain for reference
6
+ // parity with offline python tools.
7
+ smoothing: {
8
+ blendAlpha: 0.45, blinkAlpha: 0.70, headAlpha: 0.30,
9
+ minCutoff: 1.2, beta: 2.0, headMinCutoff: 1.0, headBeta: 0.02,
10
+ },
11
+ eyes: {
12
+ closeThreshold: 0.38, openThreshold: 0.25,
13
+ halfCloseThreshold: 0.20, halfOpenThreshold: 0.12,
14
+ },
15
+ mouth: {
16
+ jawClosed: 0.06, jawMid: 0.16, jawLarge: 0.32,
17
+ puckerThreshold: 0.40, funnelThreshold: 0.35, smileThreshold: 0.30,
18
+ },
19
+ head: { yawGainPx: -1.6, pitchGainPx: 1.2, rollGain: 1.0, maxShiftPx: 48, maxRollDeg: 20 },
20
+ lostFace: { holdMs: 300, decayMs: 600 },
21
+ calibration: { frames: 30 },
22
+ // jawGain 2.2: MediaPipe jawOpen 은 말하기 수준에서 0.1~0.25 라 1.6 으론 워프 램프를 못 넘어
23
+ // 크게 쩍 벌려야만 입이 열렸다 — 보통 벌림에도 반응하게 상향 (warp.js JAW_RAMP 하향과 세트).
24
+ warp: { blinkGain: 2.0, smileGain: 2.0, jawGain: 2.2, headParallax: 1.0 },
25
+ idle: { breathPeriodS: 3.6, breathAmp: 0.05, blinkMinS: 4.0, blinkMaxS: 7.0, blinkMs: 260 },
26
+ // Keep tracking responsive on sustained low-end CPU pressure by temporarily
27
+ // rendering the cached sprite path instead of the mesh.
28
+ performance: { frameBudgetMs: 28, degradeAfterMs: 1800, recoverAfterMs: 4500 },
29
+ };
30
+
31
+ export const CANVAS = 512;
32
+ // Hi-res source cap: onboarding keeps the original drawing up to this size.
33
+ // r6 부터 warp rig 는 사용하지 않음 — hi-res base 인페인트 재설계·신경망 트랙 대비 보존.
34
+ export const SOURCE_MAX = 1024;
35
+
36
+ // Sprite derivation parameters (mirror of scripts/derive_sprites.py)
37
+ export const DERIVE = {
38
+ squash: 0.45, // half-eye: kept fraction of eye height
39
+ smileAmpFrac: 0.10, // smile corner lift as fraction of mouth width
40
+ fillDarken: 0.72, // viseme interior darkening
41
+ visemes: {
42
+ A: { drop: 0.62, wx: 0.85, teeth: 0.00, tongue: 0.35 },
43
+ E: { drop: 0.30, wx: 1.10, teeth: 0.22, tongue: 0.00 },
44
+ I: { drop: 0.15, wx: 1.05, teeth: 0.30, tongue: 0.00 },
45
+ O: { drop: 0.45, wx: 0.70, teeth: 0.00, tongue: 0.30 },
46
+ U: { drop: 0.26, wx: 0.55, teeth: 0.00, tongue: 0.00 },
47
+ },
48
+ defaultStyle: { line: "#2b2b2b", fill: "#8a3535", tongue: "#d97b7b", teeth: "#ffffff" },
49
+ };
js/delaunator.js ADDED
@@ -0,0 +1,497 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Delaunator 4.0.1 — vendored unmodified from https://github.com/mapbox/delaunator (ISC).
2
+ // See THIRD_PARTY.md. Used by warp.js to triangulate the face mesh.
3
+
4
+ const EPSILON = Math.pow(2, -52);
5
+ const EDGE_STACK = new Uint32Array(512);
6
+
7
+ export default class Delaunator {
8
+
9
+ static from(points, getX = defaultGetX, getY = defaultGetY) {
10
+ const n = points.length;
11
+ const coords = new Float64Array(n * 2);
12
+
13
+ for (let i = 0; i < n; i++) {
14
+ const p = points[i];
15
+ coords[2 * i] = getX(p);
16
+ coords[2 * i + 1] = getY(p);
17
+ }
18
+
19
+ return new Delaunator(coords);
20
+ }
21
+
22
+ constructor(coords) {
23
+ const n = coords.length >> 1;
24
+ if (n > 0 && typeof coords[0] !== 'number') throw new Error('Expected coords to contain numbers.');
25
+
26
+ this.coords = coords;
27
+
28
+ // arrays that will store the triangulation graph
29
+ const maxTriangles = Math.max(2 * n - 5, 0);
30
+ this._triangles = new Uint32Array(maxTriangles * 3);
31
+ this._halfedges = new Int32Array(maxTriangles * 3);
32
+
33
+ // temporary arrays for tracking the edges of the advancing convex hull
34
+ this._hashSize = Math.ceil(Math.sqrt(n));
35
+ this._hullPrev = new Uint32Array(n); // edge to prev edge
36
+ this._hullNext = new Uint32Array(n); // edge to next edge
37
+ this._hullTri = new Uint32Array(n); // edge to adjacent triangle
38
+ this._hullHash = new Int32Array(this._hashSize).fill(-1); // angular edge hash
39
+
40
+ // temporary arrays for sorting points
41
+ this._ids = new Uint32Array(n);
42
+ this._dists = new Float64Array(n);
43
+
44
+ this.update();
45
+ }
46
+
47
+ update() {
48
+ const {coords, _hullPrev: hullPrev, _hullNext: hullNext, _hullTri: hullTri, _hullHash: hullHash} = this;
49
+ const n = coords.length >> 1;
50
+
51
+ // populate an array of point indices; calculate input data bbox
52
+ let minX = Infinity;
53
+ let minY = Infinity;
54
+ let maxX = -Infinity;
55
+ let maxY = -Infinity;
56
+
57
+ for (let i = 0; i < n; i++) {
58
+ const x = coords[2 * i];
59
+ const y = coords[2 * i + 1];
60
+ if (x < minX) minX = x;
61
+ if (y < minY) minY = y;
62
+ if (x > maxX) maxX = x;
63
+ if (y > maxY) maxY = y;
64
+ this._ids[i] = i;
65
+ }
66
+ const cx = (minX + maxX) / 2;
67
+ const cy = (minY + maxY) / 2;
68
+
69
+ let minDist = Infinity;
70
+ let i0, i1, i2;
71
+
72
+ // pick a seed point close to the center
73
+ for (let i = 0; i < n; i++) {
74
+ const d = dist(cx, cy, coords[2 * i], coords[2 * i + 1]);
75
+ if (d < minDist) {
76
+ i0 = i;
77
+ minDist = d;
78
+ }
79
+ }
80
+ const i0x = coords[2 * i0];
81
+ const i0y = coords[2 * i0 + 1];
82
+
83
+ minDist = Infinity;
84
+
85
+ // find the point closest to the seed
86
+ for (let i = 0; i < n; i++) {
87
+ if (i === i0) continue;
88
+ const d = dist(i0x, i0y, coords[2 * i], coords[2 * i + 1]);
89
+ if (d < minDist && d > 0) {
90
+ i1 = i;
91
+ minDist = d;
92
+ }
93
+ }
94
+ let i1x = coords[2 * i1];
95
+ let i1y = coords[2 * i1 + 1];
96
+
97
+ let minRadius = Infinity;
98
+
99
+ // find the third point which forms the smallest circumcircle with the first two
100
+ for (let i = 0; i < n; i++) {
101
+ if (i === i0 || i === i1) continue;
102
+ const r = circumradius(i0x, i0y, i1x, i1y, coords[2 * i], coords[2 * i + 1]);
103
+ if (r < minRadius) {
104
+ i2 = i;
105
+ minRadius = r;
106
+ }
107
+ }
108
+ let i2x = coords[2 * i2];
109
+ let i2y = coords[2 * i2 + 1];
110
+
111
+ if (minRadius === Infinity) {
112
+ // order collinear points by dx (or dy if all x are identical)
113
+ // and return the list as a hull
114
+ for (let i = 0; i < n; i++) {
115
+ this._dists[i] = (coords[2 * i] - coords[0]) || (coords[2 * i + 1] - coords[1]);
116
+ }
117
+ quicksort(this._ids, this._dists, 0, n - 1);
118
+ const hull = new Uint32Array(n);
119
+ let j = 0;
120
+ for (let i = 0, d0 = -Infinity; i < n; i++) {
121
+ const id = this._ids[i];
122
+ if (this._dists[id] > d0) {
123
+ hull[j++] = id;
124
+ d0 = this._dists[id];
125
+ }
126
+ }
127
+ this.hull = hull.subarray(0, j);
128
+ this.triangles = new Uint32Array(0);
129
+ this.halfedges = new Uint32Array(0);
130
+ return;
131
+ }
132
+
133
+ // swap the order of the seed points for counter-clockwise orientation
134
+ if (orient(i0x, i0y, i1x, i1y, i2x, i2y)) {
135
+ const i = i1;
136
+ const x = i1x;
137
+ const y = i1y;
138
+ i1 = i2;
139
+ i1x = i2x;
140
+ i1y = i2y;
141
+ i2 = i;
142
+ i2x = x;
143
+ i2y = y;
144
+ }
145
+
146
+ const center = circumcenter(i0x, i0y, i1x, i1y, i2x, i2y);
147
+ this._cx = center.x;
148
+ this._cy = center.y;
149
+
150
+ for (let i = 0; i < n; i++) {
151
+ this._dists[i] = dist(coords[2 * i], coords[2 * i + 1], center.x, center.y);
152
+ }
153
+
154
+ // sort the points by distance from the seed triangle circumcenter
155
+ quicksort(this._ids, this._dists, 0, n - 1);
156
+
157
+ // set up the seed triangle as the starting hull
158
+ this._hullStart = i0;
159
+ let hullSize = 3;
160
+
161
+ hullNext[i0] = hullPrev[i2] = i1;
162
+ hullNext[i1] = hullPrev[i0] = i2;
163
+ hullNext[i2] = hullPrev[i1] = i0;
164
+
165
+ hullTri[i0] = 0;
166
+ hullTri[i1] = 1;
167
+ hullTri[i2] = 2;
168
+
169
+ hullHash.fill(-1);
170
+ hullHash[this._hashKey(i0x, i0y)] = i0;
171
+ hullHash[this._hashKey(i1x, i1y)] = i1;
172
+ hullHash[this._hashKey(i2x, i2y)] = i2;
173
+
174
+ this.trianglesLen = 0;
175
+ this._addTriangle(i0, i1, i2, -1, -1, -1);
176
+
177
+ for (let k = 0, xp, yp; k < this._ids.length; k++) {
178
+ const i = this._ids[k];
179
+ const x = coords[2 * i];
180
+ const y = coords[2 * i + 1];
181
+
182
+ // skip near-duplicate points
183
+ if (k > 0 && Math.abs(x - xp) <= EPSILON && Math.abs(y - yp) <= EPSILON) continue;
184
+ xp = x;
185
+ yp = y;
186
+
187
+ // skip seed triangle points
188
+ if (i === i0 || i === i1 || i === i2) continue;
189
+
190
+ // find a visible edge on the convex hull using edge hash
191
+ let start = 0;
192
+ for (let j = 0, key = this._hashKey(x, y); j < this._hashSize; j++) {
193
+ start = hullHash[(key + j) % this._hashSize];
194
+ if (start !== -1 && start !== hullNext[start]) break;
195
+ }
196
+
197
+ start = hullPrev[start];
198
+ let e = start, q;
199
+ while (q = hullNext[e], !orient(x, y, coords[2 * e], coords[2 * e + 1], coords[2 * q], coords[2 * q + 1])) {
200
+ e = q;
201
+ if (e === start) {
202
+ e = -1;
203
+ break;
204
+ }
205
+ }
206
+ if (e === -1) continue; // likely a near-duplicate point; skip it
207
+
208
+ // add the first triangle from the point
209
+ let t = this._addTriangle(e, i, hullNext[e], -1, -1, hullTri[e]);
210
+
211
+ // recursively flip triangles from the point until they satisfy the Delaunay condition
212
+ hullTri[i] = this._legalize(t + 2);
213
+ hullTri[e] = t; // keep track of boundary triangles on the hull
214
+ hullSize++;
215
+
216
+ // walk forward through the hull, adding more triangles and flipping recursively
217
+ let n = hullNext[e];
218
+ while (q = hullNext[n], orient(x, y, coords[2 * n], coords[2 * n + 1], coords[2 * q], coords[2 * q + 1])) {
219
+ t = this._addTriangle(n, i, q, hullTri[i], -1, hullTri[n]);
220
+ hullTri[i] = this._legalize(t + 2);
221
+ hullNext[n] = n; // mark as removed
222
+ hullSize--;
223
+ n = q;
224
+ }
225
+
226
+ // walk backward from the other side, adding more triangles and flipping
227
+ if (e === start) {
228
+ while (q = hullPrev[e], orient(x, y, coords[2 * q], coords[2 * q + 1], coords[2 * e], coords[2 * e + 1])) {
229
+ t = this._addTriangle(q, i, e, -1, hullTri[e], hullTri[q]);
230
+ this._legalize(t + 2);
231
+ hullTri[q] = t;
232
+ hullNext[e] = e; // mark as removed
233
+ hullSize--;
234
+ e = q;
235
+ }
236
+ }
237
+
238
+ // update the hull indices
239
+ this._hullStart = hullPrev[i] = e;
240
+ hullNext[e] = hullPrev[n] = i;
241
+ hullNext[i] = n;
242
+
243
+ // save the two new edges in the hash table
244
+ hullHash[this._hashKey(x, y)] = i;
245
+ hullHash[this._hashKey(coords[2 * e], coords[2 * e + 1])] = e;
246
+ }
247
+
248
+ this.hull = new Uint32Array(hullSize);
249
+ for (let i = 0, e = this._hullStart; i < hullSize; i++) {
250
+ this.hull[i] = e;
251
+ e = hullNext[e];
252
+ }
253
+
254
+ // trim typed triangle mesh arrays
255
+ this.triangles = this._triangles.subarray(0, this.trianglesLen);
256
+ this.halfedges = this._halfedges.subarray(0, this.trianglesLen);
257
+ }
258
+
259
+ _hashKey(x, y) {
260
+ return Math.floor(pseudoAngle(x - this._cx, y - this._cy) * this._hashSize) % this._hashSize;
261
+ }
262
+
263
+ _legalize(a) {
264
+ const {_triangles: triangles, _halfedges: halfedges, coords} = this;
265
+
266
+ let i = 0;
267
+ let ar = 0;
268
+
269
+ // recursion eliminated with a fixed-size stack
270
+ while (true) {
271
+ const b = halfedges[a];
272
+
273
+ /* if the pair of triangles doesn't satisfy the Delaunay condition
274
+ * (p1 is inside the circumcircle of [p0, pl, pr]), flip them,
275
+ * then do the same check/flip recursively for the new pair of triangles
276
+ *
277
+ * pl pl
278
+ * /||\ / \
279
+ * al/ || \bl al/ \a
280
+ * / || \ / \
281
+ * / a||b \ flip /___ar___\
282
+ * p0\ || /p1 => p0\---bl---/p1
283
+ * \ || / \ /
284
+ * ar\ || /br b\ /br
285
+ * \||/ \ /
286
+ * pr pr
287
+ */
288
+ const a0 = a - a % 3;
289
+ ar = a0 + (a + 2) % 3;
290
+
291
+ if (b === -1) { // convex hull edge
292
+ if (i === 0) break;
293
+ a = EDGE_STACK[--i];
294
+ continue;
295
+ }
296
+
297
+ const b0 = b - b % 3;
298
+ const al = a0 + (a + 1) % 3;
299
+ const bl = b0 + (b + 2) % 3;
300
+
301
+ const p0 = triangles[ar];
302
+ const pr = triangles[a];
303
+ const pl = triangles[al];
304
+ const p1 = triangles[bl];
305
+
306
+ const illegal = inCircle(
307
+ coords[2 * p0], coords[2 * p0 + 1],
308
+ coords[2 * pr], coords[2 * pr + 1],
309
+ coords[2 * pl], coords[2 * pl + 1],
310
+ coords[2 * p1], coords[2 * p1 + 1]);
311
+
312
+ if (illegal) {
313
+ triangles[a] = p1;
314
+ triangles[b] = p0;
315
+
316
+ const hbl = halfedges[bl];
317
+
318
+ // edge swapped on the other side of the hull (rare); fix the halfedge reference
319
+ if (hbl === -1) {
320
+ let e = this._hullStart;
321
+ do {
322
+ if (this._hullTri[e] === bl) {
323
+ this._hullTri[e] = a;
324
+ break;
325
+ }
326
+ e = this._hullPrev[e];
327
+ } while (e !== this._hullStart);
328
+ }
329
+ this._link(a, hbl);
330
+ this._link(b, halfedges[ar]);
331
+ this._link(ar, bl);
332
+
333
+ const br = b0 + (b + 1) % 3;
334
+
335
+ // don't worry about hitting the cap: it can only happen on extremely degenerate input
336
+ if (i < EDGE_STACK.length) {
337
+ EDGE_STACK[i++] = br;
338
+ }
339
+ } else {
340
+ if (i === 0) break;
341
+ a = EDGE_STACK[--i];
342
+ }
343
+ }
344
+
345
+ return ar;
346
+ }
347
+
348
+ _link(a, b) {
349
+ this._halfedges[a] = b;
350
+ if (b !== -1) this._halfedges[b] = a;
351
+ }
352
+
353
+ // add a new triangle given vertex indices and adjacent half-edge ids
354
+ _addTriangle(i0, i1, i2, a, b, c) {
355
+ const t = this.trianglesLen;
356
+
357
+ this._triangles[t] = i0;
358
+ this._triangles[t + 1] = i1;
359
+ this._triangles[t + 2] = i2;
360
+
361
+ this._link(t, a);
362
+ this._link(t + 1, b);
363
+ this._link(t + 2, c);
364
+
365
+ this.trianglesLen += 3;
366
+
367
+ return t;
368
+ }
369
+ }
370
+
371
+ // monotonically increases with real angle, but doesn't need expensive trigonometry
372
+ function pseudoAngle(dx, dy) {
373
+ const p = dx / (Math.abs(dx) + Math.abs(dy));
374
+ return (dy > 0 ? 3 - p : 1 + p) / 4; // [0..1]
375
+ }
376
+
377
+ function dist(ax, ay, bx, by) {
378
+ const dx = ax - bx;
379
+ const dy = ay - by;
380
+ return dx * dx + dy * dy;
381
+ }
382
+
383
+ // return 2d orientation sign if we're confident in it through J. Shewchuk's error bound check
384
+ function orientIfSure(px, py, rx, ry, qx, qy) {
385
+ const l = (ry - py) * (qx - px);
386
+ const r = (rx - px) * (qy - py);
387
+ return Math.abs(l - r) >= 3.3306690738754716e-16 * Math.abs(l + r) ? l - r : 0;
388
+ }
389
+
390
+ // a more robust orientation test that's stable in a given triangle (to fix robustness issues)
391
+ function orient(rx, ry, qx, qy, px, py) {
392
+ const sign = orientIfSure(px, py, rx, ry, qx, qy) ||
393
+ orientIfSure(rx, ry, qx, qy, px, py) ||
394
+ orientIfSure(qx, qy, px, py, rx, ry);
395
+ return sign < 0;
396
+ }
397
+
398
+ function inCircle(ax, ay, bx, by, cx, cy, px, py) {
399
+ const dx = ax - px;
400
+ const dy = ay - py;
401
+ const ex = bx - px;
402
+ const ey = by - py;
403
+ const fx = cx - px;
404
+ const fy = cy - py;
405
+
406
+ const ap = dx * dx + dy * dy;
407
+ const bp = ex * ex + ey * ey;
408
+ const cp = fx * fx + fy * fy;
409
+
410
+ return dx * (ey * cp - bp * fy) -
411
+ dy * (ex * cp - bp * fx) +
412
+ ap * (ex * fy - ey * fx) < 0;
413
+ }
414
+
415
+ function circumradius(ax, ay, bx, by, cx, cy) {
416
+ const dx = bx - ax;
417
+ const dy = by - ay;
418
+ const ex = cx - ax;
419
+ const ey = cy - ay;
420
+
421
+ const bl = dx * dx + dy * dy;
422
+ const cl = ex * ex + ey * ey;
423
+ const d = 0.5 / (dx * ey - dy * ex);
424
+
425
+ const x = (ey * bl - dy * cl) * d;
426
+ const y = (dx * cl - ex * bl) * d;
427
+
428
+ return x * x + y * y;
429
+ }
430
+
431
+ function circumcenter(ax, ay, bx, by, cx, cy) {
432
+ const dx = bx - ax;
433
+ const dy = by - ay;
434
+ const ex = cx - ax;
435
+ const ey = cy - ay;
436
+
437
+ const bl = dx * dx + dy * dy;
438
+ const cl = ex * ex + ey * ey;
439
+ const d = 0.5 / (dx * ey - dy * ex);
440
+
441
+ const x = ax + (ey * bl - dy * cl) * d;
442
+ const y = ay + (dx * cl - ex * bl) * d;
443
+
444
+ return {x, y};
445
+ }
446
+
447
+ function quicksort(ids, dists, left, right) {
448
+ if (right - left <= 20) {
449
+ for (let i = left + 1; i <= right; i++) {
450
+ const temp = ids[i];
451
+ const tempDist = dists[temp];
452
+ let j = i - 1;
453
+ while (j >= left && dists[ids[j]] > tempDist) ids[j + 1] = ids[j--];
454
+ ids[j + 1] = temp;
455
+ }
456
+ } else {
457
+ const median = (left + right) >> 1;
458
+ let i = left + 1;
459
+ let j = right;
460
+ swap(ids, median, i);
461
+ if (dists[ids[left]] > dists[ids[right]]) swap(ids, left, right);
462
+ if (dists[ids[i]] > dists[ids[right]]) swap(ids, i, right);
463
+ if (dists[ids[left]] > dists[ids[i]]) swap(ids, left, i);
464
+
465
+ const temp = ids[i];
466
+ const tempDist = dists[temp];
467
+ while (true) {
468
+ do i++; while (dists[ids[i]] < tempDist);
469
+ do j--; while (dists[ids[j]] > tempDist);
470
+ if (j < i) break;
471
+ swap(ids, i, j);
472
+ }
473
+ ids[left + 1] = ids[j];
474
+ ids[j] = temp;
475
+
476
+ if (right - i + 1 >= j - left) {
477
+ quicksort(ids, dists, i, right);
478
+ quicksort(ids, dists, left, j - 1);
479
+ } else {
480
+ quicksort(ids, dists, left, j - 1);
481
+ quicksort(ids, dists, i, right);
482
+ }
483
+ }
484
+ }
485
+
486
+ function swap(arr, i, j) {
487
+ const tmp = arr[i];
488
+ arr[i] = arr[j];
489
+ arr[j] = tmp;
490
+ }
491
+
492
+ function defaultGetX(p) {
493
+ return p[0];
494
+ }
495
+ function defaultGetY(p) {
496
+ return p[1];
497
+ }
js/derive.js ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Port of scripts/derive_sprites.py: mechanical warps of existing sprites plus
2
+ // procedural visemes from a closed-mouth stroke. No new artwork. deriveAll never
3
+ // overwrites an existing canvas key (hand-made sprites win). Pixel work is on
4
+ // typed arrays; canvas API only for whole-image ops (arc/ellipse draw, resize).
5
+ import { CANVAS, DERIVE } from "./config.js?v=20260729.6";
6
+ import { newCanvas, getData, canvasFromData, hexToRgb, median, rad, bankersRound, bboxAlpha } from "./imageops.js?v=20260729.6";
7
+
8
+ const N = CANVAS * CANVAS;
9
+
10
+ /** Port of make_procedural_closed: lower-half ellipse arc from manifest params. */
11
+ function makeProceduralClosed(manifest) {
12
+ const [cx, cy] = manifest.mouthCenter;
13
+ const halfW = Math.trunc(manifest.mouthStyle?.width ?? 20);
14
+ const line = manifest.mouthStyle?.line ?? "#2e2e2e";
15
+ const c = newCanvas(CANVAS, CANVAS);
16
+ const ctx = c.getContext("2d");
17
+ ctx.strokeStyle = line;
18
+ ctx.lineWidth = 4;
19
+ ctx.beginPath();
20
+ ctx.ellipse(cx, cy, halfW, Math.trunc(halfW * 0.55), 0, rad(25), rad(155), false);
21
+ ctx.stroke();
22
+ return c;
23
+ }
24
+
25
+ /** Mark mask=1 where placed lip's raw alpha exceeds thr, clipped to canvas. */
26
+ function stampAlpha(mask, lip, xLeft, top, thr) {
27
+ const { data, width: lw, height: lh } = lip;
28
+ for (let ly = 0; ly < lh; ly++) {
29
+ const dy = top + ly;
30
+ if (dy < 0 || dy >= CANVAS) continue;
31
+ for (let lx = 0; lx < lw; lx++) {
32
+ const dx = xLeft + lx;
33
+ if (dx < 0 || dx >= CANVAS) continue;
34
+ if (data[(ly * lw + lx) * 4 + 3] > thr) mask[dy * CANVAS + dx] = 1;
35
+ }
36
+ }
37
+ }
38
+
39
+ /** Src-over lip onto out; blends all 4 channels like python _place. */
40
+ function compositeLip(out, lip, xLeft, top) {
41
+ const { data, width: lw, height: lh } = lip;
42
+ for (let ly = 0; ly < lh; ly++) {
43
+ const dy = top + ly;
44
+ if (dy < 0 || dy >= CANVAS) continue;
45
+ for (let lx = 0; lx < lw; lx++) {
46
+ const dx = xLeft + lx;
47
+ if (dx < 0 || dx >= CANVAS) continue;
48
+ const k = (ly * lw + lx) * 4;
49
+ const a = data[k + 3] / 255;
50
+ if (a === 0) continue;
51
+ const i = (dy * CANVAS + dx) * 4;
52
+ for (let ch = 0; ch < 4; ch++) out[i + ch] = data[k + ch] * a + out[i + ch] * (1 - a);
53
+ }
54
+ }
55
+ }
56
+
57
+ /** 3x3 max dilation, `iters` times. */
58
+ function dilate(mask, iters) {
59
+ let cur = mask;
60
+ for (let it = 0; it < iters; it++) {
61
+ const next = new Uint8Array(N);
62
+ for (let y = 0; y < CANVAS; y++) {
63
+ for (let x = 0; x < CANVAS; x++) {
64
+ let v = 0;
65
+ for (let dy = -1; dy <= 1 && !v; dy++) {
66
+ const ny = y + dy;
67
+ if (ny < 0 || ny >= CANVAS) continue;
68
+ for (let dx = -1; dx <= 1; dx++) {
69
+ const nx = x + dx;
70
+ if (nx >= 0 && nx < CANVAS && cur[ny * CANVAS + nx]) { v = 1; break; }
71
+ }
72
+ }
73
+ next[y * CANVAS + x] = v;
74
+ }
75
+ }
76
+ cur = next;
77
+ }
78
+ return cur;
79
+ }
80
+
81
+ function buildViseme(y0, mw, sh, cxf, strokeCanvas, colors, ink, thickness, p) {
82
+ const lipW = Math.max(2, Math.trunc(mw * p.wx));
83
+ const lipCanvas = newCanvas(lipW, sh);
84
+ const lctx = lipCanvas.getContext("2d");
85
+ lctx.imageSmoothingEnabled = true;
86
+ lctx.imageSmoothingQuality = "high";
87
+ lctx.drawImage(strokeCanvas, 0, 0, mw, sh, 0, 0, lipW, sh);
88
+ const lip = lctx.getImageData(0, 0, lipW, sh);
89
+ const drop = Math.trunc(mw * p.drop);
90
+ const xoff = bankersRound(cxf - lipW / 2);
91
+
92
+ const out = new Uint8ClampedArray(N * 4);
93
+
94
+ // interior: per column, fill between the bottom of the top lip and the top of
95
+ // the bottom lip. Masks threshold raw lip alpha at >123 (python _place squares
96
+ // alpha, so its `>60` on the placed layer equals raw alpha > 123.69).
97
+ const topMask = new Uint8Array(N);
98
+ const botMask = new Uint8Array(N);
99
+ stampAlpha(topMask, lip, xoff, y0, 123);
100
+ stampAlpha(botMask, lip, xoff, y0 + drop, 123);
101
+ for (let x = 0; x < CANVAS; x++) {
102
+ let tcLast = -1;
103
+ for (let y = CANVAS - 1; y >= 0; y--) { if (topMask[y * CANVAS + x]) { tcLast = y; break; } }
104
+ if (tcLast < 0) continue;
105
+ let bcFirst = -1;
106
+ for (let y = 0; y < CANVAS; y++) { if (botMask[y * CANVAS + x]) { bcFirst = y; break; } }
107
+ if (bcFirst < 0 || bcFirst <= tcLast) continue;
108
+ for (let y = tcLast; y <= bcFirst; y++) {
109
+ const i = (y * CANVAS + x) * 4;
110
+ out[i] = colors.fill[0]; out[i + 1] = colors.fill[1]; out[i + 2] = colors.fill[2]; out[i + 3] = 255;
111
+ }
112
+ }
113
+
114
+ const inner = new Uint8Array(N);
115
+ let anyInner = false, firstRow = -1, lastRow = -1;
116
+ for (let y = 0; y < CANVAS; y++) {
117
+ let rowHas = false;
118
+ for (let x = 0; x < CANVAS; x++) {
119
+ if (out[(y * CANVAS + x) * 4 + 3] > 0) { inner[y * CANVAS + x] = 1; rowHas = true; anyInner = true; }
120
+ }
121
+ if (rowHas) { if (firstRow < 0) firstRow = y; lastRow = y; }
122
+ }
123
+
124
+ if (p.teeth > 0 && anyInner) {
125
+ const bandEnd = Math.min(CANVAS, firstRow + Math.max(2, Math.trunc(drop * p.teeth)));
126
+ for (let y = firstRow; y < bandEnd; y++) {
127
+ for (let x = 0; x < CANVAS; x++) {
128
+ if (inner[y * CANVAS + x]) {
129
+ const i = (y * CANVAS + x) * 4;
130
+ out[i] = colors.teeth[0]; out[i + 1] = colors.teeth[1]; out[i + 2] = colors.teeth[2]; out[i + 3] = 255;
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ if (p.tongue > 0 && anyInner) {
137
+ // top half of an ellipse centered at the interior floor (cv2 angles 180..360).
138
+ const tc = newCanvas(CANVAS, CANVAS);
139
+ const tctx = tc.getContext("2d");
140
+ tctx.fillStyle = `rgb(${colors.tongue[0]},${colors.tongue[1]},${colors.tongue[2]})`;
141
+ tctx.beginPath();
142
+ tctx.ellipse(Math.trunc(cxf), lastRow, Math.trunc(mw * 0.28), Math.max(2, Math.trunc(drop * p.tongue)),
143
+ 0, Math.PI, 2 * Math.PI, false);
144
+ tctx.fill();
145
+ const td = tctx.getImageData(0, 0, CANVAS, CANVAS).data;
146
+ for (let idx = 0; idx < N; idx++) {
147
+ if (td[idx * 4 + 3] > 127 && inner[idx]) {
148
+ out[idx * 4] = colors.tongue[0]; out[idx * 4 + 1] = colors.tongue[1];
149
+ out[idx * 4 + 2] = colors.tongue[2]; out[idx * 4 + 3] = 255;
150
+ }
151
+ }
152
+ }
153
+
154
+ compositeLip(out, lip, xoff, y0);
155
+ compositeLip(out, lip, xoff, y0 + drop);
156
+
157
+ // ink outline: dilate the content mask and paint the new ring in ink under the
158
+ // content (contract's equivalent of cv2.drawContours).
159
+ const mask0 = new Uint8Array(N);
160
+ for (let idx = 0; idx < N; idx++) mask0[idx] = out[idx * 4 + 3] > 60 ? 1 : 0;
161
+ const dil = dilate(mask0, Math.round(thickness / 2) + 1);
162
+ for (let idx = 0; idx < N; idx++) {
163
+ if (dil[idx] && !mask0[idx]) {
164
+ const ca = out[idx * 4 + 3] / 255;
165
+ out[idx * 4] = out[idx * 4] * ca + ink[0] * (1 - ca);
166
+ out[idx * 4 + 1] = out[idx * 4 + 1] * ca + ink[1] * (1 - ca);
167
+ out[idx * 4 + 2] = out[idx * 4 + 2] * ca + ink[2] * (1 - ca);
168
+ out[idx * 4 + 3] = 255;
169
+ }
170
+ }
171
+ return canvasFromData(out, CANVAS, CANVAS);
172
+ }
173
+
174
+ /** Port of derive_mouth_set: A/E/I/O/U from the closed-mouth stroke + manifest. */
175
+ function deriveMouthSet(canvases, manifest, closed) {
176
+ const closedData = getData(closed);
177
+ const bb = bboxAlpha(closedData);
178
+ if (!bb) return;
179
+ const [x0, y0, x1, y1] = bb;
180
+ const mw = x1 - x0, sh = y1 - y0, cxf = (x0 + x1) / 2;
181
+ const strokeCanvas = newCanvas(mw, sh);
182
+ strokeCanvas.getContext("2d").putImageData(closed.getContext("2d").getImageData(x0, y0, mw, sh), 0, 0);
183
+
184
+ const style = manifest.mouthStyle ?? {};
185
+ const colors = {
186
+ fill: hexToRgb(style.fill ?? "#8a3535").map((v) => Math.floor(v * DERIVE.fillDarken)),
187
+ tongue: hexToRgb(style.tongue ?? "#d97b7b"),
188
+ teeth: hexToRgb(style.teeth ?? "#ffffff"),
189
+ };
190
+
191
+ // ink color = median of opaque stroke pixels; thickness = mean ink px/col * 0.9.
192
+ const cd = closedData.data;
193
+ const iR = [], iG = [], iB = [];
194
+ for (let idx = 0; idx < N; idx++) {
195
+ if (cd[idx * 4 + 3] > 200) { iR.push(cd[idx * 4]); iG.push(cd[idx * 4 + 1]); iB.push(cd[idx * 4 + 2]); }
196
+ }
197
+ const ink = iR.length ? [median(iR), median(iG), median(iB)] : [43, 43, 43];
198
+ let colSum = 0;
199
+ for (let x = x0; x < x1; x++) {
200
+ let cnt = 0;
201
+ for (let y = 0; y < CANVAS; y++) if (cd[(y * CANVAS + x) * 4 + 3] > 60) cnt++;
202
+ colSum += cnt;
203
+ }
204
+ const thickness = Math.max(2, bankersRound((colSum / (x1 - x0)) * 0.9));
205
+
206
+ for (const [name, p] of Object.entries(DERIVE.visemes)) {
207
+ const key = `mouth_${name}.png`;
208
+ if (key in canvases) continue;
209
+ canvases[key] = buildViseme(y0, mw, sh, cxf, strokeCanvas, colors, ink, thickness, p);
210
+ }
211
+ }
212
+
213
+ /** Port of derive_half_eye: vertical squash of the open eye, anchored at its bbox bottom. */
214
+ function deriveHalfEye(open) {
215
+ const bb = bboxAlpha(getData(open));
216
+ if (!bb) return canvasFromData(new Uint8ClampedArray(N * 4), CANVAS, CANVAS);
217
+ const [x0, y0, x1, y1] = bb;
218
+ const cw = x1 - x0, chh = y1 - y0;
219
+ const newH = Math.max(2, bankersRound(chh * DERIVE.squash));
220
+ const src = newCanvas(cw, chh);
221
+ src.getContext("2d").putImageData(open.getContext("2d").getImageData(x0, y0, cw, chh), 0, 0);
222
+ const out = newCanvas(CANVAS, CANVAS);
223
+ const octx = out.getContext("2d");
224
+ octx.imageSmoothingEnabled = true;
225
+ octx.imageSmoothingQuality = "high";
226
+ octx.drawImage(src, 0, 0, cw, chh, x0, y1 - newH, cw, newH);
227
+ return out;
228
+ }
229
+
230
+ /** Port of derive_smile: per-column integer lift (corners rise, center still). */
231
+ function deriveSmile(closed) {
232
+ const d = getData(closed);
233
+ const data = d.data;
234
+ const bb = bboxAlpha(d);
235
+ if (!bb) return canvasFromData(new Uint8ClampedArray(N * 4), CANVAS, CANVAS);
236
+ const [x0, y0, x1, y1] = bb;
237
+ const cxf = (x0 + x1) / 2, halfW = Math.max(1, (x1 - x0) / 2);
238
+ const amp = (x1 - x0) * DERIVE.smileAmpFrac;
239
+ const out = new Uint8ClampedArray(N * 4);
240
+ for (let x = x0; x < x1; x++) {
241
+ const t = Math.min(1, Math.abs(x - cxf) / halfW);
242
+ const shift = bankersRound(amp * t * t);
243
+ for (let y = 0; y < CANVAS; y++) {
244
+ if (shift > 0 && y >= CANVAS - shift) continue;
245
+ const si = ((y + shift) * CANVAS + x) * 4, di = (y * CANVAS + x) * 4;
246
+ out[di] = data[si]; out[di + 1] = data[si + 1]; out[di + 2] = data[si + 2]; out[di + 3] = data[si + 3];
247
+ }
248
+ }
249
+ return canvasFromData(out, CANVAS, CANVAS);
250
+ }
251
+
252
+ /** Adds derived sprites to `canvases` in place; never overwrites an existing key. */
253
+ export function deriveAll(canvases, manifest) {
254
+ if (!("mouth_closed.png" in canvases) && manifest.proceduralMouth) {
255
+ canvases["mouth_closed.png"] = makeProceduralClosed(manifest);
256
+ }
257
+ const closed = canvases["mouth_closed.png"];
258
+ if (closed) {
259
+ deriveMouthSet(canvases, manifest, closed);
260
+ if (!("mouth_smile.png" in canvases)) canvases["mouth_smile.png"] = deriveSmile(closed);
261
+ }
262
+ for (const side of ["L", "R"]) {
263
+ const open = canvases[`eye_${side}_open.png`];
264
+ const key = `eye_${side}_half.png`;
265
+ if (open && !(key in canvases)) canvases[key] = deriveHalfEye(open);
266
+ }
267
+ }