feat(vrm): 바디 애니 스트리밍 + idle 클립 미동봉 (Mixamo 라이선스)
Browse files10_vrm_live_streaming(청킹+바디), tools/fbx_to_clip(FBX→클립 추출). 09/10 --idle 선택화 + rest-pose fallback. avatar_vrm/idle.npz 삭제 — Mixamo 모션 데이터 raw 재배포 불가, 사용자가 직접 준비. 데모 영상은 바디 idle 포함(완성품 embed 는 무관). submodule 718ff68.
- .gitignore +12 -3
- README_lipsync.md +27 -5
- README_lipsync_ko.md +27 -5
- example/09_vrm_live.py +28 -16
- example/10_vrm_live_streaming.py +178 -0
- example/avatar_vrm/idle.npz +0 -3
- example/ppaso_vrm/vrm_anim_body.py +8 -1
- example/tools/fbx_to_clip.py +85 -0
- samples/08_vrm_lipsync.mp4 +2 -2
.gitignore
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
|
|
| 1 |
__pycache__/
|
| 2 |
-
*.
|
| 3 |
-
.
|
| 4 |
-
*.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
|
| 7 |
+
# Editor / OS
|
| 8 |
+
.DS_Store
|
| 9 |
+
.vscode/
|
| 10 |
+
.idea/
|
| 11 |
+
|
| 12 |
+
# Body animation clips — user-supplied (Mixamo 등, 재배포 금지). tools/fbx_to_clip.py 로 생성.
|
| 13 |
+
example/avatar_vrm/*.npz
|
README_lipsync.md
CHANGED
|
@@ -100,6 +100,26 @@ python 08_vrm_video.py --backend numpy # no-GPU fallback
|
|
| 100 |
> to map your own character's blendshapes (9 or more) — see
|
| 101 |
> [README_bs2d.md](./README_bs2d.md).**
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
## Quick start
|
| 104 |
|
| 105 |
```python
|
|
@@ -181,7 +201,8 @@ body** (GPU skinning + body idle animation — `09_vrm_live.py`).
|
|
| 181 |
| **E2E** | **5580 ms · RTF 0.77** |
|
| 182 |
|
| 183 |
> Reproduce: `cd example && python bench_vrm.py --backend gles --model-dir ..`
|
| 184 |
-
> (lipsync only) — add `--body
|
|
|
|
| 185 |
>
|
| 186 |
> **lipsync only** sits at **RTF ≈ 0.6**, **lipsync + body** at
|
| 187 |
> **RTF ≈ 0.77** — both comfortably faster than realtime. Body animation
|
|
@@ -215,10 +236,11 @@ body** (GPU skinning + body idle animation — `09_vrm_live.py`).
|
|
| 215 |
anim_body (skeleton) / raster (numpy + egl/moderngl + gles + gles_skinned).
|
| 216 |
- **bs2d mapping detail**: [README_bs2d.md](./README_bs2d.md)
|
| 217 |
- **Examples**: `07_avatar_video.py` (atlas) · `08_vrm_video.py` (blendshape)
|
| 218 |
-
· `09_vrm_live.py` (blendshape + body
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
|
|
|
| 222 |
|
| 223 |
## License
|
| 224 |
|
|
|
|
| 100 |
> to map your own character's blendshapes (9 or more) — see
|
| 101 |
> [README_bs2d.md](./README_bs2d.md).**
|
| 102 |
|
| 103 |
+
## Body animation (optional)
|
| 104 |
+
|
| 105 |
+
`09_vrm_live.py` and `10_vrm_live_streaming.py` add a skeletal **body idle**
|
| 106 |
+
on top of lipsync — GPU skinning via `raster_gles_skinned`. It needs a body
|
| 107 |
+
**clip** (`--idle clip.npz`); **without one the body stays at rest pose**
|
| 108 |
+
(lipsync and video still render fine).
|
| 109 |
+
|
| 110 |
+
Make a clip from any FBX skeletal animation with `tools/fbx_to_clip.py`
|
| 111 |
+
(needs `bpy` — `pip install bpy`):
|
| 112 |
+
|
| 113 |
+
```bash
|
| 114 |
+
python tools/fbx_to_clip.py idle.fbx avatar_vrm/idle.npz
|
| 115 |
+
python 09_vrm_live.py --idle avatar_vrm/idle.npz
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
The clip is **not bundled** — animation assets such as Mixamo are
|
| 119 |
+
royalty-free to embed in your rendered output, but their raw motion data is
|
| 120 |
+
generally not redistributable, so supply your own. `BodyClipPlayer` assumes
|
| 121 |
+
a loop-authored clip; `fbx_to_clip.py` drops the duplicate loop endpoint.
|
| 122 |
+
|
| 123 |
## Quick start
|
| 124 |
|
| 125 |
```python
|
|
|
|
| 201 |
| **E2E** | **5580 ms · RTF 0.77** |
|
| 202 |
|
| 203 |
> Reproduce: `cd example && python bench_vrm.py --backend gles --model-dir ..`
|
| 204 |
+
> (lipsync only) — add `--body <clip.npz>` for lipsync + body (see
|
| 205 |
+
> *Body animation* above for making a clip).
|
| 206 |
>
|
| 207 |
> **lipsync only** sits at **RTF ≈ 0.6**, **lipsync + body** at
|
| 208 |
> **RTF ≈ 0.77** — both comfortably faster than realtime. Body animation
|
|
|
|
| 236 |
anim_body (skeleton) / raster (numpy + egl/moderngl + gles + gles_skinned).
|
| 237 |
- **bs2d mapping detail**: [README_bs2d.md](./README_bs2d.md)
|
| 238 |
- **Examples**: `07_avatar_video.py` (atlas) · `08_vrm_video.py` (blendshape)
|
| 239 |
+
· `09_vrm_live.py` (blendshape + body) · `10_vrm_live_streaming.py`
|
| 240 |
+
(streaming + body) · `06_streaming_with_lipsync.py`
|
| 241 |
+
· `bench_lipsync.py` (atlas) · `bench_vrm.py` (blendshape)
|
| 242 |
+
- **Body-clip tool**: `example/tools/fbx_to_clip.py` — FBX → `.npz` body clip
|
| 243 |
+
- **Sample assets**: `example/avatar/` (2D) · `example/avatar_vrm/` (3D VRM)
|
| 244 |
|
| 245 |
## License
|
| 246 |
|
README_lipsync_ko.md
CHANGED
|
@@ -95,6 +95,26 @@ python 08_vrm_video.py --backend numpy # GPU 없는 환경
|
|
| 95 |
> 만든 캐릭터의 blendshape(9개 이상) 매핑법은
|
| 96 |
> [README_bs2d_ko.md](./README_bs2d_ko.md) 참고.**
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
## 빠른 시작
|
| 99 |
|
| 100 |
```python
|
|
@@ -176,7 +196,8 @@ end-to-end (TTS + lipsync + 눈 깜박임 + morph + 렌더, 512×768, ffmpeg
|
|
| 176 |
| **E2E** | **5580 ms · RTF 0.77** |
|
| 177 |
|
| 178 |
> 재현: `cd example && python bench_vrm.py --backend gles --model-dir ..`
|
| 179 |
-
> (lipsync only) — `--body
|
|
|
|
| 180 |
>
|
| 181 |
> **lipsync only** 은 **RTF ≈ 0.6**, **lipsync + body** 는 **RTF ≈ 0.77**
|
| 182 |
> — 둘 다 realtime 보다 여유 있게 빠름. 바디 애니메이션은 CPU **FK +
|
|
@@ -209,10 +230,11 @@ end-to-end (TTS + lipsync + 눈 깜박임 + morph + 렌더, 512×768, ffmpeg
|
|
| 209 |
anim_body (스켈레톤) / raster (numpy + egl/moderngl + gles + gles_skinned).
|
| 210 |
- **bs2d 매핑 상세**: [README_bs2d_ko.md](./README_bs2d_ko.md)
|
| 211 |
- **예제**: `07_avatar_video.py` (atlas) · `08_vrm_video.py` (blendshape)
|
| 212 |
-
· `09_vrm_live.py` (blendshape + 바디
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
|
|
|
| 216 |
|
| 217 |
## 라이선스
|
| 218 |
|
|
|
|
| 95 |
> 만든 캐릭터의 blendshape(9개 이상) 매핑법은
|
| 96 |
> [README_bs2d_ko.md](./README_bs2d_ko.md) 참고.**
|
| 97 |
|
| 98 |
+
## 바디 애니메이션 (선택)
|
| 99 |
+
|
| 100 |
+
`09_vrm_live.py` · `10_vrm_live_streaming.py` 는 립싱크에 스켈레톤 **바디
|
| 101 |
+
idle** 을 더한다 — `raster_gles_skinned` GPU skinning. 바디 **클립**
|
| 102 |
+
(`--idle clip.npz`) 이 필요하며, **클립이 없으면 바디는 rest pose 로** 렌더
|
| 103 |
+
된다 (립싱크·영상은 정상).
|
| 104 |
+
|
| 105 |
+
클립은 FBX 스켈레톤 애니메이션에서 `tools/fbx_to_clip.py` 로 만든다
|
| 106 |
+
(`bpy` 필요 — `pip install bpy`):
|
| 107 |
+
|
| 108 |
+
```bash
|
| 109 |
+
python tools/fbx_to_clip.py idle.fbx avatar_vrm/idle.npz
|
| 110 |
+
python 09_vrm_live.py --idle avatar_vrm/idle.npz
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
클립은 **동봉하지 않는다** — Mixamo 등 애니메이션 에셋은 렌더 결과물에
|
| 114 |
+
embed 하는 건 royalty-free 이나 원본 모션 데이터의 재배포는 일반적으로
|
| 115 |
+
불가하므로 각자 준비할 것. `BodyClipPlayer` 는 loop-authored 클립을
|
| 116 |
+
가정하며, `fbx_to_clip.py` 가 loop endpoint 복제 프레임을 제거한다.
|
| 117 |
+
|
| 118 |
## 빠른 시작
|
| 119 |
|
| 120 |
```python
|
|
|
|
| 196 |
| **E2E** | **5580 ms · RTF 0.77** |
|
| 197 |
|
| 198 |
> 재현: `cd example && python bench_vrm.py --backend gles --model-dir ..`
|
| 199 |
+
> (lipsync only) — `--body <clip.npz>` 추가 시 lipsync + body (클립 만드는
|
| 200 |
+
> 법은 위 *바디 애니메이션* 참고).
|
| 201 |
>
|
| 202 |
> **lipsync only** 은 **RTF ≈ 0.6**, **lipsync + body** 는 **RTF ≈ 0.77**
|
| 203 |
> — 둘 다 realtime 보다 여유 있게 빠름. 바디 애니메이션은 CPU **FK +
|
|
|
|
| 230 |
anim_body (스켈레톤) / raster (numpy + egl/moderngl + gles + gles_skinned).
|
| 231 |
- **bs2d 매핑 상세**: [README_bs2d_ko.md](./README_bs2d_ko.md)
|
| 232 |
- **예제**: `07_avatar_video.py` (atlas) · `08_vrm_video.py` (blendshape)
|
| 233 |
+
· `09_vrm_live.py` (blendshape + 바디) · `10_vrm_live_streaming.py`
|
| 234 |
+
(스트리밍 + 바디) · `06_streaming_with_lipsync.py`
|
| 235 |
+
· `bench_lipsync.py` (atlas) · `bench_vrm.py` (blendshape)
|
| 236 |
+
- **바디 클립 도구**: `example/tools/fbx_to_clip.py` — FBX → `.npz` 바디 클립
|
| 237 |
+
- **샘플 자산**: `example/avatar/` (2D) · `example/avatar_vrm/` (3D VRM)
|
| 238 |
|
| 239 |
## 라이선스
|
| 240 |
|
example/09_vrm_live.py
CHANGED
|
@@ -44,13 +44,17 @@ DEFAULT_TEXT = (
|
|
| 44 |
)
|
| 45 |
|
| 46 |
|
| 47 |
-
def
|
| 48 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
head = head_position(model)
|
| 50 |
-
target = head + np.array([0.0, -0.
|
| 51 |
-
eye = target + np.array([0.0, 0.
|
| 52 |
view = look_at(eye, target)
|
| 53 |
-
proj = perspective(
|
| 54 |
return view, proj
|
| 55 |
|
| 56 |
|
|
@@ -110,13 +114,19 @@ def main():
|
|
| 110 |
wav_dur = len(wav) / 22050
|
| 111 |
print(f" wav {wav_dur:.2f}s · lipsync {n} frames · TTS {tts_sec*1000:.0f} ms")
|
| 112 |
|
| 113 |
-
# --- 2. VRM 로드 + 골격 + 바디 클립 ---
|
| 114 |
model = load_vrm(args.vrm)
|
| 115 |
skel = VRMSkeleton(model)
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
# 얼굴 morph (lipsync) — LOCAL 공간
|
| 122 |
face_pos, face_morphs = face_local_baked(model)
|
|
@@ -133,9 +143,11 @@ def main():
|
|
| 133 |
def frame_fn(i):
|
| 134 |
nonlocal t_pose, t_morph
|
| 135 |
a = time.perf_counter()
|
| 136 |
-
# 바디 idle —
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
| 139 |
b = time.perf_counter()
|
| 140 |
# 얼굴 morph (lipsync) — LOCAL
|
| 141 |
face_local = A.morph_face(face_pos, face_morphs, weights[i])
|
|
@@ -144,9 +156,9 @@ def main():
|
|
| 144 |
t_morph += c - b
|
| 145 |
return renderer.render(joint_mats, view, proj, face_local=face_local)
|
| 146 |
|
| 147 |
-
renderer.render(
|
| 148 |
-
|
| 149 |
-
print(f"[render] GPU skinning (
|
| 150 |
rsec = encode_video(frame_fn, n, W, H, args.fps, wav_path, args.out)
|
| 151 |
|
| 152 |
# --- 벤치마크 ---
|
|
|
|
| 44 |
)
|
| 45 |
|
| 46 |
|
| 47 |
+
def bust_portrait(model, width, height):
|
| 48 |
+
"""얼굴 위주 버스트샷 카메라 (08_vrm_video.py 와 동일, 정면 -Z).
|
| 49 |
+
|
| 50 |
+
talking-head 라이브 에이전트 — 얼굴 중심. 바디 애니메이션은 머리/어깨/
|
| 51 |
+
목의 미세한 idle 로 화면에 드러난다 (팔 등은 프레임 밖).
|
| 52 |
+
"""
|
| 53 |
head = head_position(model)
|
| 54 |
+
target = head + np.array([0.0, -0.03, 0.0])
|
| 55 |
+
eye = target + np.array([0.0, 0.05, -1.20])
|
| 56 |
view = look_at(eye, target)
|
| 57 |
+
proj = perspective(26.0, width / height, 0.05, 20.0)
|
| 58 |
return view, proj
|
| 59 |
|
| 60 |
|
|
|
|
| 114 |
wav_dur = len(wav) / 22050
|
| 115 |
print(f" wav {wav_dur:.2f}s · lipsync {n} frames · TTS {tts_sec*1000:.0f} ms")
|
| 116 |
|
| 117 |
+
# --- 2. VRM 로드 + 골격 + 바디 클립 (선택) ---
|
| 118 |
model = load_vrm(args.vrm)
|
| 119 |
skel = VRMSkeleton(model)
|
| 120 |
+
view, proj = bust_portrait(model, W, H)
|
| 121 |
+
rest_jm = skel.rest_joint_matrices() # 클립 없을 때 fallback
|
| 122 |
+
player = None
|
| 123 |
+
if args.idle and os.path.isfile(args.idle):
|
| 124 |
+
player = BodyClipPlayer(args.idle, skel)
|
| 125 |
+
print(f" body idle: {player.n_frames} frames @ {player.fps:.0f}fps "
|
| 126 |
+
f"({player.duration:.1f}s, loop)")
|
| 127 |
+
else:
|
| 128 |
+
print(f" body idle 클립 없음 ('{args.idle}') — rest pose 로 렌더 "
|
| 129 |
+
f"(바디 애니메이션 비활성, 립싱크·영상은 정상)")
|
| 130 |
|
| 131 |
# 얼굴 morph (lipsync) — LOCAL 공간
|
| 132 |
face_pos, face_morphs = face_local_baked(model)
|
|
|
|
| 143 |
def frame_fn(i):
|
| 144 |
nonlocal t_pose, t_morph
|
| 145 |
a = time.perf_counter()
|
| 146 |
+
# 바디 idle — 클립 있으면 시간축 재생(loop, slerp), 없으면 rest pose
|
| 147 |
+
if player is not None:
|
| 148 |
+
joint_mats = skel.joint_matrices(skel.fk(player.pose_at(i / args.fps)))
|
| 149 |
+
else:
|
| 150 |
+
joint_mats = rest_jm
|
| 151 |
b = time.perf_counter()
|
| 152 |
# 얼굴 morph (lipsync) — LOCAL
|
| 153 |
face_local = A.morph_face(face_pos, face_morphs, weights[i])
|
|
|
|
| 156 |
t_morph += c - b
|
| 157 |
return renderer.render(joint_mats, view, proj, face_local=face_local)
|
| 158 |
|
| 159 |
+
renderer.render(rest_jm, view, proj) # warmup
|
| 160 |
+
mode = "lipsync + body" if player else "lipsync (body=rest pose)"
|
| 161 |
+
print(f"[render] GPU skinning ({mode}) → {args.out}")
|
| 162 |
rsec = encode_video(frame_fn, n, W, H, args.fps, wav_path, args.out)
|
| 163 |
|
| 164 |
# --- 벤치마크 ---
|
example/10_vrm_live_streaming.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
10_vrm_live_streaming.py — VRM 립싱크 + 바디 (스트리밍 / 청킹)
|
| 3 |
+
|
| 4 |
+
09_vrm_live.py 의 스트리밍 버전. `StreamingTTS` 가 LLM 토큰 스트림을 받아
|
| 5 |
+
강한 구두점마다 (wav, lipsync) 청크를 emit → 청크가 도착하는 대로 렌더한다.
|
| 6 |
+
|
| 7 |
+
핵심 — **바디 idle 은 전역 anim 시계로 구동**:
|
| 8 |
+
각 청크의 얼굴 morph 는 그 청크의 lipsync 로,
|
| 9 |
+
바디 pose 는 청크를 가로지르는 전역 프레임 카운터 g 로 `pose_at(g/fps)`.
|
| 10 |
+
→ 청크 경계에서 바디가 리셋되지 않고 연속 재생·loop 된다.
|
| 11 |
+
(청크별 0 부터 다시 재생하면 청크마다 idle 이 튄다 — 반드시 전역 시계.)
|
| 12 |
+
|
| 13 |
+
카메라는 얼굴 위주(bust). 라이브 에이전트 talking-head 용.
|
| 14 |
+
|
| 15 |
+
사용:
|
| 16 |
+
python 10_vrm_live_streaming.py
|
| 17 |
+
python 10_vrm_live_streaming.py --text "..." --out live.mp4
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import argparse
|
| 23 |
+
import os
|
| 24 |
+
import subprocess
|
| 25 |
+
import tempfile
|
| 26 |
+
import time
|
| 27 |
+
|
| 28 |
+
import numpy as np
|
| 29 |
+
import soundfile as sf
|
| 30 |
+
|
| 31 |
+
from ppaso_tts import PpasoTTS, StreamingTTS
|
| 32 |
+
from ppaso_lipsync import generate_blink_timeline
|
| 33 |
+
from ppaso_vrm import (load_vrm, look_at, perspective, head_position,
|
| 34 |
+
vrm_anim as A, face_local_baked,
|
| 35 |
+
VRMSkeleton, BodyClipPlayer, SkinnedGLRenderer)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
DEFAULT_TEXT = (
|
| 39 |
+
"엣지디바이스용 경량화 립싱크 애니메이션 기능이 추가되었습니다. "
|
| 40 |
+
"애니메이션은 커브로 구성되어 있어, 스프라이트 기반의 애니메이션 뿐만 아니라, "
|
| 41 |
+
"블렌드쉐입 기반의 3D 애니메이션에서도 적용 가능합니다."
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def bust_portrait(model, width, height):
|
| 46 |
+
"""얼굴 위주 버스트샷 카메라 (08_vrm_video.py 와 동일, 정면 -Z)."""
|
| 47 |
+
head = head_position(model)
|
| 48 |
+
target = head + np.array([0.0, -0.03, 0.0])
|
| 49 |
+
eye = target + np.array([0.0, 0.05, -1.20])
|
| 50 |
+
view = look_at(eye, target)
|
| 51 |
+
proj = perspective(26.0, width / height, 0.05, 20.0)
|
| 52 |
+
return view, proj
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def fake_token_stream(text, chunk=3):
|
| 56 |
+
"""LLM 토큰 스트림 시뮬레이션 — 텍스트를 chunk 글자씩 흘린다."""
|
| 57 |
+
for i in range(0, len(text), chunk):
|
| 58 |
+
yield text[i:i + chunk]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def main():
|
| 62 |
+
ap = argparse.ArgumentParser()
|
| 63 |
+
ap.add_argument("--text", default=DEFAULT_TEXT)
|
| 64 |
+
ap.add_argument("--vrm", default="./avatar_vrm/AvatarSample_F.vrm")
|
| 65 |
+
ap.add_argument("--idle", default="./avatar_vrm/idle.npz")
|
| 66 |
+
ap.add_argument("--model-dir", default="..")
|
| 67 |
+
ap.add_argument("--out", default="vrm_live_streaming.mp4")
|
| 68 |
+
ap.add_argument("--fps", type=int, default=30)
|
| 69 |
+
ap.add_argument("--width", type=int, default=512)
|
| 70 |
+
ap.add_argument("--height", type=int, default=768)
|
| 71 |
+
ap.add_argument("--seed", type=int, default=42)
|
| 72 |
+
args = ap.parse_args()
|
| 73 |
+
W, H = args.width, args.height
|
| 74 |
+
|
| 75 |
+
# --- 셋업 ---
|
| 76 |
+
print(f"[setup] '{args.text}'")
|
| 77 |
+
tts = PpasoTTS(args.model_dir, backend="onnx")
|
| 78 |
+
stream = StreamingTTS(tts)
|
| 79 |
+
model = load_vrm(args.vrm)
|
| 80 |
+
skel = VRMSkeleton(model)
|
| 81 |
+
face_pos, face_morphs = face_local_baked(model)
|
| 82 |
+
view, proj = bust_portrait(model, W, H)
|
| 83 |
+
renderer = SkinnedGLRenderer(model, W, H)
|
| 84 |
+
rest_jm = skel.rest_joint_matrices() # 클립 없을 때 fallback
|
| 85 |
+
player = None
|
| 86 |
+
if args.idle and os.path.isfile(args.idle):
|
| 87 |
+
player = BodyClipPlayer(args.idle, skel)
|
| 88 |
+
print(f" body idle: {player.n_frames} frames @ {player.fps:.0f}fps "
|
| 89 |
+
f"({player.duration:.1f}s loop)")
|
| 90 |
+
else:
|
| 91 |
+
print(f" body idle 클립 없음 ('{args.idle}') — rest pose 로 렌더 "
|
| 92 |
+
f"(바디 애니메이션 비활성)")
|
| 93 |
+
|
| 94 |
+
# --- 비디오 파이프 (영상만; 오디오는 청크 wav 모아 마지막에 mux) ---
|
| 95 |
+
vid_tmp = os.path.join(tempfile.gettempdir(), "_vrm_live_vid.mp4")
|
| 96 |
+
proc = subprocess.Popen(
|
| 97 |
+
["ffmpeg", "-y", "-loglevel", "error", "-f", "rawvideo",
|
| 98 |
+
"-pix_fmt", "rgba", "-s", f"{W}x{H}", "-r", str(args.fps), "-i", "-",
|
| 99 |
+
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "20", vid_tmp],
|
| 100 |
+
stdin=subprocess.PIPE)
|
| 101 |
+
|
| 102 |
+
g = 0 # 전역 프레임 카운터 = 바디 anim 시계
|
| 103 |
+
chunk_idx = 0
|
| 104 |
+
all_wavs = []
|
| 105 |
+
t_tts = t_render = 0.0
|
| 106 |
+
|
| 107 |
+
def render_chunk(wav, lip):
|
| 108 |
+
"""청크 1개 — 얼굴 morph(청크 lipsync) + 바디 pose(전역시계) 렌더."""
|
| 109 |
+
nonlocal g, chunk_idx, t_render
|
| 110 |
+
blink = generate_blink_timeline(duration=lip.duration, fps=args.fps,
|
| 111 |
+
seed=args.seed + chunk_idx)
|
| 112 |
+
weights = A.morph_weight_timeline(lip.xy_timeline, model,
|
| 113 |
+
blink.curve_timeline)
|
| 114 |
+
for i in range(len(weights)):
|
| 115 |
+
t0 = time.perf_counter()
|
| 116 |
+
face_local = A.morph_face(face_pos, face_morphs, weights[i])
|
| 117 |
+
# 바디 — 클립 있으면 전역 시계 재생(청크 경계 무관 연속), 없으면 rest
|
| 118 |
+
if player is not None:
|
| 119 |
+
joint_mats = skel.joint_matrices(skel.fk(player.pose_at(g / args.fps)))
|
| 120 |
+
else:
|
| 121 |
+
joint_mats = rest_jm
|
| 122 |
+
img = renderer.render(joint_mats, view, proj, face_local=face_local)
|
| 123 |
+
t_render += time.perf_counter() - t0 # ffmpeg write 제외
|
| 124 |
+
try:
|
| 125 |
+
proc.stdin.write(np.ascontiguousarray(img, np.uint8).tobytes())
|
| 126 |
+
except BrokenPipeError:
|
| 127 |
+
break
|
| 128 |
+
g += 1
|
| 129 |
+
all_wavs.append(wav)
|
| 130 |
+
chunk_idx += 1
|
| 131 |
+
print(f" chunk {chunk_idx:2d}: {len(wav)/22050:5.2f}s wav, "
|
| 132 |
+
f"{len(weights):3d} frames (g={g})")
|
| 133 |
+
|
| 134 |
+
# warmup
|
| 135 |
+
renderer.render(rest_jm, view, proj)
|
| 136 |
+
|
| 137 |
+
# --- 토큰 스트림 → 청크 합성 → 렌더 ---
|
| 138 |
+
print("[stream] 토큰 흘리는 중 — 강한 구두점마다 청크 emit")
|
| 139 |
+
for tok in fake_token_stream(args.text):
|
| 140 |
+
t0 = time.perf_counter()
|
| 141 |
+
chunks = list(stream.feed_with_lipsync(tok, fps=args.fps))
|
| 142 |
+
t_tts += time.perf_counter() - t0
|
| 143 |
+
for wav, lip in chunks:
|
| 144 |
+
render_chunk(wav, lip)
|
| 145 |
+
t0 = time.perf_counter()
|
| 146 |
+
tail = list(stream.flush_with_lipsync(fps=args.fps))
|
| 147 |
+
t_tts += time.perf_counter() - t0
|
| 148 |
+
for wav, lip in tail:
|
| 149 |
+
render_chunk(wav, lip)
|
| 150 |
+
|
| 151 |
+
proc.stdin.close()
|
| 152 |
+
proc.wait()
|
| 153 |
+
|
| 154 |
+
# --- 오디오 mux ---
|
| 155 |
+
joined = np.concatenate(all_wavs)
|
| 156 |
+
wav_path = os.path.join(tempfile.gettempdir(), "_vrm_live_audio.wav")
|
| 157 |
+
sf.write(wav_path, joined, 22050)
|
| 158 |
+
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", vid_tmp,
|
| 159 |
+
"-i", wav_path, "-c:v", "copy", "-c:a", "aac",
|
| 160 |
+
"-b:a", "128k", "-shortest", args.out], check=True)
|
| 161 |
+
os.remove(vid_tmp)
|
| 162 |
+
|
| 163 |
+
# --- 벤치마크 ---
|
| 164 |
+
wav_dur = len(joined) / 22050
|
| 165 |
+
e2e = t_tts + t_render
|
| 166 |
+
print("=" * 60)
|
| 167 |
+
print(f"스트리밍 — {chunk_idx} chunks, {g} frames, wav {wav_dur:.2f}s")
|
| 168 |
+
print(f" TTS+lipsync (청크 누적) : {t_tts*1000:8.0f} ms")
|
| 169 |
+
print(f" render (청크 누적) : {t_render*1000:8.0f} ms "
|
| 170 |
+
f"({t_render/g*1000:.1f} ms/frame)")
|
| 171 |
+
print(f" ── E2E : {e2e:.2f}s RTF {e2e/wav_dur:.3f}")
|
| 172 |
+
print(f" → {args.out}")
|
| 173 |
+
print("=" * 60)
|
| 174 |
+
renderer.release()
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
if __name__ == "__main__":
|
| 178 |
+
main()
|
example/avatar_vrm/idle.npz
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:6c21987154052c47303975b0f18c2b1476ce77bf3e9bcfa70115de8fccb8a1ed
|
| 3 |
-
size 747115
|
|
|
|
|
|
|
|
|
|
|
|
example/ppaso_vrm/vrm_anim_body.py
CHANGED
|
@@ -219,7 +219,14 @@ def retarget(clip, skeleton: VRMSkeleton):
|
|
| 219 |
class BodyClipPlayer:
|
| 220 |
"""리타게팅된 바디 클립을 시간 기반으로 재생. loop + slerp 보간.
|
| 221 |
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
frame_pose(i) — 정수 클립 프레임 (보간 없음, 클립 fps 고정 영상용).
|
| 224 |
"""
|
| 225 |
|
|
|
|
| 219 |
class BodyClipPlayer:
|
| 220 |
"""리타게팅된 바디 클립을 시간 기반으로 재생. loop + slerp 보간.
|
| 221 |
|
| 222 |
+
**loop-authored 클립을 가정한다** — 첫↔마지막 프레임이 이어지도록
|
| 223 |
+
제작된 idle/loop 애니메이션. `pose_at` 은 `% n_frames` 로 감싸 무한
|
| 224 |
+
반복한다. 비-loop 클립이면 wrap 지점에서 pop 이 보인다 (그 경우 클립
|
| 225 |
+
자체를 loop-authored 로 교체).
|
| 226 |
+
|
| 227 |
+
pose_at(t) — 임의 시각(초) 의 포즈. 스트리밍/청킹에서는 청크별로
|
| 228 |
+
리셋하지 말고 **전역 anim 시계**(start_anim_time + 경과)
|
| 229 |
+
를 넘기면 청크 경계와 무관하게 연속 재생된다.
|
| 230 |
frame_pose(i) — 정수 클립 프레임 (보간 없음, 클립 fps 고정 영상용).
|
| 231 |
"""
|
| 232 |
|
example/tools/fbx_to_clip.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
fbx_to_clip.py — FBX 스켈레톤 애니메이션 → BodyClipPlayer 용 .npz 클립
|
| 3 |
+
|
| 4 |
+
오프라인 도구. Mixamo 등에서 받은 FBX 애니메이션을 `ppaso_vrm.BodyClipPlayer`
|
| 5 |
+
가 읽는 .npz (골격 rest + 프레임별 armature-space 행렬) 로 변환한다.
|
| 6 |
+
디바이스 런타임에는 불필요 — 바디 애니메이션 클립 준비용.
|
| 7 |
+
|
| 8 |
+
요구: `bpy` (Blender Python 모듈). `pip install bpy` 로 PyPI 에서 설치
|
| 9 |
+
가능 (Blender 설치와 무관). FBX 파싱은 Blender 의 FBX importer 를 쓴다.
|
| 10 |
+
|
| 11 |
+
사용:
|
| 12 |
+
python fbx_to_clip.py input.fbx output.npz
|
| 13 |
+
|
| 14 |
+
그 다음 09_vrm_live.py / 10_vrm_live_streaming.py 에 --idle output.npz.
|
| 15 |
+
|
| 16 |
+
핵심:
|
| 17 |
+
- action.frame_range (실제 키프레임 범위) 로 추출 — Blender scene 기본
|
| 18 |
+
범위(1..250)가 아님. 이걸 안 맞추면 클립이 잘리거나 늘어난다.
|
| 19 |
+
- Mixamo loop endpoint (마지막 = 첫 프레임 복제) 자동 drop → seamless.
|
| 20 |
+
- 출력 npz: bones / parents / rest(B,4,4) / anim(F,B,4,4) / fps / nframes.
|
| 21 |
+
|
| 22 |
+
리타게팅(→ VRM humanoid)은 런타임 BodyClipPlayer 가 로드 시 수행한다.
|
| 23 |
+
|
| 24 |
+
⚠ 애니메이션 라이선스 — Mixamo 등 외부 클립은 각 제공처 약관 확인.
|
| 25 |
+
Mixamo: 완성품(렌더 영상 등)에 embed 는 royalty-free 이나, 애니메이션
|
| 26 |
+
데이터 자체의 독립 재배포는 금지 — 변환한 .npz 를 공개 배포하지 말 것.
|
| 27 |
+
각자 받은 클립을 각자 프로젝트에서 쓰는 용도.
|
| 28 |
+
"""
|
| 29 |
+
import sys
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
import bpy
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def main():
|
| 36 |
+
if len(sys.argv) < 3:
|
| 37 |
+
sys.exit("usage: python fbx_to_clip.py input.fbx output.npz")
|
| 38 |
+
fbx, out = sys.argv[1], sys.argv[2]
|
| 39 |
+
|
| 40 |
+
bpy.ops.wm.read_factory_settings(use_empty=True)
|
| 41 |
+
bpy.ops.import_scene.fbx(filepath=fbx)
|
| 42 |
+
arm = next((o for o in bpy.data.objects if o.type == "ARMATURE"), None)
|
| 43 |
+
if arm is None:
|
| 44 |
+
sys.exit("ERROR: FBX 안에 armature 없음")
|
| 45 |
+
scene = bpy.context.scene
|
| 46 |
+
|
| 47 |
+
act = arm.animation_data.action if arm.animation_data else None
|
| 48 |
+
if act is None:
|
| 49 |
+
sys.exit("ERROR: armature 에 action(애니메이션) 없음")
|
| 50 |
+
f0, f1 = (int(round(x)) for x in act.frame_range)
|
| 51 |
+
nframes = f1 - f0 + 1
|
| 52 |
+
print(f"action '{act.name}' frame_range {f0}..{f1} = {nframes} frames")
|
| 53 |
+
|
| 54 |
+
posebones = list(arm.pose.bones)
|
| 55 |
+
bones = [pb.name for pb in posebones]
|
| 56 |
+
idx = {n: i for i, n in enumerate(bones)}
|
| 57 |
+
parents = np.array(
|
| 58 |
+
[idx.get(pb.parent.name, -1) if pb.parent else -1 for pb in posebones])
|
| 59 |
+
rest = np.stack(
|
| 60 |
+
[np.array(arm.data.bones[n].matrix_local, np.float64) for n in bones])
|
| 61 |
+
anim = np.zeros((nframes, len(bones), 4, 4), np.float64)
|
| 62 |
+
for fi, frame in enumerate(range(f0, f1 + 1)):
|
| 63 |
+
scene.frame_set(frame)
|
| 64 |
+
for bi, pb in enumerate(posebones):
|
| 65 |
+
anim[fi, bi] = np.array(pb.matrix, np.float64)
|
| 66 |
+
|
| 67 |
+
# Mixamo loop endpoint — 마지막 프레임이 첫 프레임 복제면 drop
|
| 68 |
+
R = anim[:, :, :3, :3]
|
| 69 |
+
typ = float(np.median([np.abs(R[i + 1] - R[i]).mean()
|
| 70 |
+
for i in range(nframes - 1)]))
|
| 71 |
+
if nframes > 2 and float(np.abs(R[-1] - R[0]).mean()) < typ * 0.3:
|
| 72 |
+
anim = anim[:-1]
|
| 73 |
+
nframes -= 1
|
| 74 |
+
print(f"loop endpoint 복제본 drop → {nframes} frames")
|
| 75 |
+
|
| 76 |
+
fps = scene.render.fps / scene.render.fps_base
|
| 77 |
+
np.savez_compressed(
|
| 78 |
+
out, bones=np.array(bones), parents=parents,
|
| 79 |
+
rest=rest.astype(np.float32), anim=anim.astype(np.float32),
|
| 80 |
+
fps=np.float64(fps), nframes=np.int64(nframes))
|
| 81 |
+
print(f"saved: {out} ({nframes} frames @ {fps:.0f}fps)")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
if __name__ == "__main__":
|
| 85 |
+
main()
|
samples/08_vrm_lipsync.mp4
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:97b302dd6eaab444b13333a122850399da97c1dd3b197fdf5acfc838efc1da24
|
| 3 |
+
size 2541608
|