Spaces:
Running
Running
kim commited on
sync from a4623ed60bfdc48500dc98de93020c59fe69bf34
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +12 -0
- README.md +10 -5
- assets3d/LICENSE-claire.txt +18 -0
- assets3d/LICENSE-mark.txt +17 -0
- assets3d/claire.glb +3 -0
- assets3d/mark.glb +3 -0
- avatar_core.js +1256 -0
- boy.html +5 -0
- characters/boy/base.png +0 -0
- characters/boy/brow_L.png +0 -0
- characters/boy/brow_R.png +0 -0
- characters/boy/eye_L_closed.png +0 -0
- characters/boy/eye_L_open.png +0 -0
- characters/boy/eye_R_closed.png +0 -0
- characters/boy/eye_R_open.png +0 -0
- characters/boy/manifest.json +18 -0
- characters/boy/pupil_L.png +0 -0
- characters/boy/pupil_R.png +0 -0
- characters/girl/base.png +0 -0
- characters/girl/brow_L.png +0 -0
- characters/girl/brow_R.png +0 -0
- characters/girl/eye_L_closed.png +0 -0
- characters/girl/eye_L_open.png +0 -0
- characters/girl/eye_R_closed.png +0 -0
- characters/girl/eye_R_open.png +0 -0
- characters/girl/manifest.json +19 -0
- characters/girl/pupil_L.png +0 -0
- characters/girl/pupil_R.png +0 -0
- combo.html +223 -0
- dev/authoring_test.html +94 -0
- dev/runtime_test.html +127 -0
- draw.html +99 -0
- illust.html +114 -0
- img/auto_visemes.png +3 -0
- img/demo.gif +0 -0
- img/flp/fail_handdrawn.png +3 -0
- img/flp/vermeer.mp4 +0 -0
- img/flp_illust_ok.png +3 -0
- img/flp_painting_ok.png +3 -0
- img/pig_states.png +3 -0
- img/sprite_vs_flp.gif +3 -0
- img/stick_auto.png +3 -0
- img/studio_5.gif +3 -0
- img/studio_live.gif +3 -0
- img/studio_sync.gif +3 -0
- index.html +411 -18
- js/compositor.js +109 -0
- js/config.js +49 -0
- js/delaunator.js +497 -0
- js/derive.js +267 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 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> 표준 비율 일러스트 · 명화</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> 손그림 · 낙서 · 치비</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
|
img/demo.gif
ADDED
|
img/flp/fail_handdrawn.png
ADDED
|
Git LFS Details
|
img/flp/vermeer.mp4
ADDED
|
Binary file (71.5 kB). View file
|
|
|
img/flp_illust_ok.png
ADDED
|
Git LFS Details
|
img/flp_painting_ok.png
ADDED
|
Git LFS Details
|
img/pig_states.png
ADDED
|
Git LFS Details
|
img/sprite_vs_flp.gif
ADDED
|
|
Git LFS Details
|
img/stick_auto.png
ADDED
|
Git LFS Details
|
img/studio_5.gif
ADDED
|
Git LFS Details
|
img/studio_live.gif
ADDED
|
Git LFS Details
|
img/studio_sync.gif
ADDED
|
Git LFS Details
|
index.html
CHANGED
|
@@ -1,19 +1,412 @@
|
|
| 1 |
-
<!
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|