아바타 v3 세션 연결 수용 기록과 기준 상태판 동기화

This commit is contained in:
Yun Chan 2026-10-01 16:16:12 +09:00
parent 613bcb603e
commit 97c6d5d8ab
4 changed files with 65 additions and 6 deletions

View file

@ -461,6 +461,50 @@ type Performance = {
- **로드 실패**: 레이어 하나라도 실패하면 `data-load-state="error"`와 콜백으로 알린다. 세션(2B-2)은 이때 기존 SVG 리그로 되돌아간다. 이 환경에서 SVG `<image>`의 load·error 이벤트가 발동하지 않아(원인 미확인, 2단계-B-1b 관찰) 같은 href를 `HTMLImageElement`로 미리 불러 로드 상태를 집계한다.
- 새 공연(`playPerformance`)은 이전 공연의 **미발동 cue를 모두 취소**한다. 이미 재생 중인 클립은 자연 종료한다.
### 8.5 세션 연결 (2단계-B-2, 2026-10-01 설계)
- 연결 지점 지도(탐색 보고)
- `Session.tsx`의 ClientAvatar: 시작 전 정지 화면(size 168, animated false)과 진행 중 화면(state·affect·analyser·rapport, size 220)
- TTS 재생: `playTtsAudio` — decodeAudioData → `source.start()`, 실패 시 HTMLAudio
- 발화 텍스트: 텍스트 모드 `clientReply`, 음성 모드 reply `payload.text`. 자막 원문에 괄호 지문이 남아 있다.
- 실제 아바타 박스 크기는 `session.css`가 정한다: 진행 중 58~204px, 시작 전 88~168px.
- **분기**
- ClientAvatar의 기존 props(확정 인터페이스)는 바꾸지 않는다. 선택 prop만 더한다.
- 페르소나에 리노컷 리그가 있으면(리그 레지스트리 `v3/rigs/index.ts`, 지금은 P1만) 내부에서 v3 래퍼(`v3/ClientAvatarV3.tsx`)로 그린다.
- 빌드 플래그 `VITE_AVATAR_V3=0`이면 모두 기존 SVG다. 기본은 켜짐이다.
- **같은 캐릭터는 어디서나 같은 그림**: 시작 전 화면과 학습자 홈 썸네일(`animated=false`)도 v3로 그린다. 렌더러는 `running=false`여도 기본 자세 한 프레임을 계산해 벡터 부위를 그려야 한다. 지금은 얼굴이 비어 보인다.
- **로드**: 리그 이미지가 준비될 때까지 기존 SVG를 보이고, 준비되면 300ms에 걸쳐 v3로 바꾼다. `data-load-state="error"`면 기존 SVG로 남는다(§8.4 로드 실패).
- **엔진**
- 래퍼가 소유한다. `createAvatarEngine({ demeanor: demeanorFor(persona), seed, reducedMotion })`이고, seed는 페르소나 코드 해시다. 페르소나가 바뀔 때만 다시 만든다.
- 시계 함수는 안정 참조(ref)로 넘긴다. 인라인 함수는 리렌더마다 rAF 루프를 다시 시작시킨다.
- reduced motion은 `prefers-reduced-motion`을 따른다.
- **상태·표정**
- state prop을 `engine.setState`로 넘긴다.
- affect(겉표정)와 강도를 `setSurface`로 넘긴다.
- 새 선택 prop은 `openness?`(세션 `effective_openness`)와 `surfaceIntensity?`다. surfaceIntensity는 Session이 `surfaceIntensityFor`(performance.ts)로 계산한다.
- 없으면 openness는 rapport로 대신하고, 강도는 0.5로 둔다.
- **발화**: 새 선택 prop `speech?: AvatarSpeech | null`을 둔다.
- 형태: `{ id: string; text: string; audio?: { buffer: AudioBuffer; context: BaseAudioContext; startAt: number } }`
- text는 괄호 지문을 포함한 원문이다.
- startAt은 `source.start(when)`의 when이다.
- Session이 TTS 재생을 시작할 때 넘긴다. decode 직후 `when = ctx.currentTime + 0.08`로 예약해, 래퍼가 그 사이 타임라인을 만들게 한다.
- 래퍼는 id가 바뀌면 `buildPerformance`(지문 → cue, speakableText)를 호출하고, `buildSpeechTimeline`(오디오 포락선 정렬)과 `buildCoSpeechPlan`으로 계획을 만든다.
- 매 프레임 `tMs = (context.currentTime − startAt)·1000`(오디오 시계)으로 `sampleSpeech`·`sampleCoSpeech`를 표본한다.
- 지문 cue는 `playPerformance`로 재생한다.
- HTMLAudio 폴백(AudioBuffer 없음)이나 TTS 실패면 `speech = { id, text }`(audio 없음)를 넘긴다. 래퍼는 텍스트 타이밍(`demeanor.speech.syllablesPerSec`)으로 엔진 시계에서 말한다.
- **토큰 스트리밍 중(speaking이지만 speech 없음)에는 입을 움직이지 않는다.** 지금은 텍스트가 나올 때와 음성이 나올 때 두 번 입이 움직인다.
- 재생 중단(일시정지·위기·건너뛰기 등 기존 stop 경로)에서 Session은 speech를 null로 바꾼다. 래퍼는 발화층·동반층을 비우고, 미발동 cue는 다음 playPerformance가 취소한다.
- **화면**
- 박스가 120px 이상이면 bust 크롭, 미만이면 face 크롭이다.
- 원형 오브 안에서는 래퍼가 원형으로 자르고, 배경 색면은 원 안에 그대로 둔다.
- 모티프는 이번에 세션에 넣지 않는다(무대 레이아웃 변경, 별도 결정).
- **누설**: 2단계에서는 내지 않는다(3단계 API `avatar_cue`). 지금 웹의 `inner_reaction`은 피드백 정책에 따라 오지 않을 수 있다.
- **테스트 계약**
- v3 루트는 `data-avatar-renderer="linocut"`, `data-load-state`, 그리고 발화 중 `data-viseme`(현재 비짐)을 낸다.
- `avatar-expression.spec.ts`의 P1 SVG 기대는 v3 기대로 바꾸고, P2~P7은 SVG 그대로 둔다.
- 레이아웃 게이트(`layout-visual-gate`, `session-layout`)는 P1·P2 둘 다로 통과해야 한다.
- 세션 E2E로 모킹한 TTS 오디오 재생 중 `data-viseme`가 바뀌는지 확인한다.
### 8.3 외형 SSOT (4단계)
- 캐릭터 카드에 `avatar` 블록(`look`: 나이 표지·성별 골격·얼굴형·눈매·헤어·의상·소품·캐릭터 신호, `demeanor`: 기본 자세·expressivity·깜빡임·시선 회피·idle 클립 빈도)을 두고 `PersonaSummary`로 내보낸다.
@ -473,7 +517,7 @@ type Performance = {
|---|---|---|
| 0 | 아트 디렉션 선택 | 스타일 프레임 4종 생성·오케스트레이터 검수·소유자 선택 |
| 1 | 엔진 코어(웹): 채널·프리셋·클립·믹서·지문 파서·성향 기본값 + 디버그 렌더러 + dev Lab(`/dev/avatar-lab`) | typecheck·build 통과, Lab에서 28표정×강도·26클립·지문 텍스트 입력 재생 확인, 구분 가능성 불변량 검증, Playwright Lab 스펙 |
| 2 | P1 리그 v3(선택된 스타일) + 세션 연결(P1만, 나머지는 기존 리그) | 세션·레이아웃 게이트 무회귀(§2 AGENTS), 소유자 시각 확인 |
| 2 | P1 리그 v3(선택된 스타일) + 세션 연결(P1만, 나머지는 기존 리그) | 세션·레이아웃 게이트 무회귀(§2 AGENTS), 소유자 시각 확인. **운영 배포는 세션 연결(2단계-B-2) 뒤에 함께 한다**(2026-10-01 소유자 결정 — 세션 연결 전에는 배포해도 수련생 화면이 바뀌지 않는다) |
| 3 | API `avatar_cue`·청자 반응 조기 이벤트, Jev 경로 연결 | pytest·check:api-types, legacy/jev 양쪽 동작 |
| 4 | 7명 외형 SSOT·리그 v3 전환, 구 SVG 리그·RasterBust·live2d 메타데이터 정리 | 소유자 시각 확인, 자산 삭제는 소유자 확인 후 |
@ -530,6 +574,21 @@ type Performance = {
- **2단계-B-1d 수용(2026-10-01)**: F10(hairFront 턱선 잉크 자산 제거 + 렌더러 hairFront 구멍 원복, 중립 합성 차 1e-5)·F11(head 구멍을 입 중심 아래로 한정 — 코~인중이 들릴 때 볼 가장자리에 배경이 비치던 틈 제거) 뒤, 오케스트레이터가 out11의 볼 가장자리·턱(X·A·O art 1.0·A art 0.6)·코~입 확대를 직접 확인했다. 틈 없음, 턱선 한 줄, 코 음영 한 겹. 검증 6종과 avatar-lab E2E 19 passed. 남은 항목: 소유자 시각 확인(Lab), Lab "TTS 음성으로" 버튼(선택 항목, 미구현 — 오디오 파일 경로로 대신 확인), Lab 숨긴 미리보기의 rAF 정지.
- **2단계-B-2 세션 연결 수용(2026-10-01)**
- 구현: §8.5 설계대로 구현했다.
- 리그 레지스트리, `ClientAvatarV3`, 발화 구동 공용 모듈 `engine/speechDriver.ts`(Lab도 이 모듈을 쓴다)
- Session의 발화 전달·해제, 정지 프레임, 실측 폭 크롭
- 오케스트레이터가 diff를 직접 읽고 두 가지를 고쳤다.
- 크기 감지 중복 11줄을 `v3/observeWidth.ts`로 묶었다(웹 중복 0건).
- P1 레이아웃 시험이 320×568에서도 아바타가 보이길 기대하던 것을 바로잡았다. 이 화면은 설계상 `.sx-orb-wrap`을 숨긴다(기존 SVG도 같다).
- 검증
- typecheck·build·check:avatar-presets·check:avatar-lipsync·check:dead-code 통과
- E2E: session-layout·avatar-session-speech·avatar-expression 11 passed(3 workers), avatar-lab 19 passed(4 workers), uc-session-prestart·conversation·coach-voice 통과
- check:duplication 실패는 apps/api 파이썬 기존 중복 때문이다. 이 작업은 apps/api 변경이 없다.
- 11~12 worker 전면 병렬에서는 시간 초과가 났지만 단독 재실행에서는 재현되지 않았다(testing.md 3.5 포화 양상).
- 실화면(1440·1024·881·390): 시작 전 168px와 진행 중 202px는 bust 크롭, 112·88·62px는 face 크롭이다. v3 박스는 스테이지 원과 일치하고, 기존 SVG는 남지 않는다.
- 반영: master `613bcb60`. 공개 웹 반영 명령은 자동 권한 판정이 막아 소유자 실행을 기다린다.
### 2단계-B-1c P1 모티프 스프라이트 — 봉오리 1차 반려, 재배치 수용 (2026-09-30)
- codex imagegen(`gpt-5.6-terra`, 스타일 프레임 참조)으로 봉오리 4상태 시트·날씨 5종 시트를 각 1회에 생성. 프롬프트는 오케스트레이터 작성(`docs/avatar-art/p1-linocut/motif/prompts/`).