akamotaco commited on
Commit
cdf615f
·
verified ·
1 Parent(s): 3847023

feat(vrm): 바디 애니 스트리밍 + idle 클립 미동봉 (Mixamo 라이선스)

Browse files

10_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 CHANGED
@@ -1,4 +1,13 @@
 
1
  __pycache__/
2
- *.pyc
3
- .cache/
4
- *.tmp.wav
 
 
 
 
 
 
 
 
 
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 avatar_vrm/idle.npz` for lipsync + 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 animation)
219
- · `06_streaming_with_lipsync.py` · `bench_lipsync.py` (atlas) · `bench_vrm.py` (blendshape)
220
- - **Sample assets**: `example/avatar/` (2D) · `example/avatar_vrm/` (3D VRM
221
- + `idle.npz` body clip)
 
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 avatar_vrm/idle.npz` 추가 시 lipsync + 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
- · `06_streaming_with_lipsync.py` · `bench_lipsync.py` (atlas) · `bench_vrm.py` (blendshape)
214
- - **샘플 자산**: `example/avatar/` (2D) · `example/avatar_vrm/` (3D VRM
215
- + `idle.npz` 바디 클립)
 
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 live_portrait(model, width, height):
48
- """상반신 portrait — 말하기 + 바디 idle 둘 다 보이는 카메라 (정면 -Z)."""
 
 
 
 
49
  head = head_position(model)
50
- target = head + np.array([0.0, -0.32, 0.0])
51
- eye = target + np.array([0.0, 0.04, -1.55])
52
  view = look_at(eye, target)
53
- proj = perspective(30.0, width / height, 0.05, 20.0)
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
- player = BodyClipPlayer(args.idle, skel)
117
- view, proj = live_portrait(model, W, H)
118
- print(f" body idle: {player.n_frames} frames @ {player.fps:.0f}fps "
119
- f"({player.duration:.1f}s, loop)")
 
 
 
 
 
 
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 — 발화 시간축에 맞춰 재생 (loop, slerp 보간)
137
- world = skel.fk(player.pose_at(i / args.fps))
138
- joint_mats = skel.joint_matrices(world)
 
 
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(skel.joint_matrices(skel.fk(player.pose_at(0))),
148
- view, proj) # warmup
149
- print(f"[render] GPU skinning (lipsync + body) → {args.out}")
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
- pose_at(t) — 임의 시각(초) 의 포즈 (렌더 fps ≠ 클립 fps 대응).
 
 
 
 
 
 
 
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:139507c912191d8f7af19196699e2f1a7f39e86f218e375d7cea4733f1fd22b9
3
- size 2839263
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:97b302dd6eaab444b13333a122850399da97c1dd3b197fdf5acfc838efc1da24
3
+ size 2541608