vignette/docs/decisions/avatar-expression-engine-v3.md

661 lines
85 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 가상 내담자 아바타 v3 — 표현 엔진·반응 모션·아트 리그
결정일: 2026-09-30. 상태: 설계 확정(소유자 결정 4건 반영), 아트 디렉션 선택 대기, 구현 전.
이 문서가 아바타 v3의 채널·표정·반응 클립·신호·렌더러 계약의 정본이다. 구현은 이 문서를 바꾸지 않고 따른다.
관련 결정: [Jev 내담자 평가·표현 v2](./jev-client-affect-v2.md)(표현 계획·가림·속마음 노출 계약).
## 1. 왜 바꾸는가
2026-09-30 점검 결과(탐색 워커 3건 수용, 오케스트레이터 직접 확인):
1. **표정이 보이지 않는다.** 제품은 SVG 도형 리그(`ClientAvatar.tsx`, 요소 15~17개)만 렌더한다. 28개 표정은 `persona.ts` `AFFECT_TABLE`에서 1~3px 차이라 육안 구분이 안 된다("과장 금지" 주석 방침).
2. **반응 애니메이션이 없다.** 호흡·깜빡임·saccade·립싱크와 260ms 표정 보간뿐이다. 끄덕임·움찔·고개 숙임 같은 일회성 동작은 코드에 없다.
3. **신호가 버려진다.** 내담자 LLM은 "(한숨)" "(고개 끄덕)" "(침묵)" 같은 괄호 지문을 쓰고, 백엔드는 이를 TTS에서만 지운다(`services/voice.py:825-841`, 주석 "아바타 애니메이션이 표현"). 프론트에 지문 파서가 없다. Jev의 감정 9축·표현 계획도 아바타에 가지 않는다. 표정은 프론트 `expressionForSession()`이 개방도 임계값으로만 정한다.
4. **외형이 캐릭터를 말하지 않는다.** 캐릭터 카드에 외형 필드가 없고, 외형 정보가 웹 색상표·imagegen 팔레트·리뷰 노트 세 곳에 따로 있으며 서로 어긋난다. P2·P3은 외형 정의가 없다.
5. **7월 래스터 아트는 소유자가 거절했다.** 이유(소유자 답변 2026-09-30): 그림 품질·어색함, 애니풍 교복 스타일 부적합, 캐릭터 구분 안 됨. P4~P7 래스터는 출처 라이선스 미확인 외부 PSB(`라투디 여캐.psb`)의 슬롯을 다시 칠한 것이라 v3에서 **기하·아트 어느 것도 재사용하지 않는다.**
## 2. 소유자 결정 (2026-09-30)
| 질문 | 결정 |
|---|---|
| 렌더 방향 | **벡터 리그 고도화** — 코드로 그리는 SVG 리그. 실제 Cubism·자체 WebGL 메시 리그는 채택하지 않는다. |
| 표현 강도 | **강도 비례 하이브리드** — 평소 절제, 강한 감정에서 뚜렷(눈물 포함). 숨긴 감정은 짧은 미세표정으로만 샌다. |
| 파일럿 | **P1 서연** |
| 아트 방향 | 현재 아트에 집착하지 않는다. **더 예술적이고 상징적이되 캐릭터 특징과 인간성이 드러나게.** → 탐색 후 **리노컷 판화** 선택, 상징은 **작은 모티프 + 배경 색 변화**(§8.1). |
| 리그 방식 | 트레이싱 spike 결과 판화 질감이 사라져, **혼합**으로 확정: 덩어리 부위는 판화 질감 이미지 레이어, 표정 부위는 코드 벡터(§8.2). "벡터 리그" 결정을 이 범위로 구체화한다. |
## 3. 구조
```
신호 표현 엔진 (렌더러 무관, 순수 함수) 렌더러
────────────── ───────────────────────────────────── ──────────────
괄호 지문 파서 ─┐ ① 기본 자세 페르소나 성향(demeanor) SVG 리그 v3
Jev 표현 계획 ─┼─▶ 공연 스크립트 ──▶ ② 표정층 표정 × 강도, 스프링 평활 ──▶ (아트 스타일별)
청자 반응 신호 ─┤ (턴 단위 cue 목록) ③ 반응층 클립 가산 합성(동시 ≤2) + 상징 모티프층
개방도·단계 ─┘ ④ 발화층 립싱크(한글 비짐 + 오디오 선분석, §5.4) + 발화 동반 머리·몸·눈썹(§5.5)
⑤ idle층 깜빡임·saccade·호흡·미세 흔들림
→ 채널 벡터(§4) 매 프레임 1개
```
- **엔진은 순수 함수다.** `evaluate(tMs, performance, demeanor, rng)` → 채널 벡터. 시계와 난수는 주입한다(테스트에서 고정 가능). React 상태를 매 프레임 갱신하지 않고, 렌더러가 rAF에서 채널 벡터를 받아 DOM 속성을 직접 쓴다(현재 `setFrame` 전체 재렌더 제거).
- **채널이 계약이다.** 신호 쪽은 채널을 모르고 cue만 만든다. 렌더러는 cue를 모르고 채널만 읽는다.
- `prefers-reduced-motion`: 반응층·idle층을 끄고 표정층만 즉시 적용한다(보간 없음). 현재 동작 유지.
## 4. 채널 (정본)
Live2D 표준 파라미터 이름과 대응시켜 나중에 다른 렌더러로 옮겨도 표정·클립 자산을 그대로 쓴다. 값 범위는 정규화 값이다.
| 채널 | Live2D 대응 | 범위 | 의미 (FACS 참고) |
|---|---|---|---|
| `headYaw` | ParamAngleX | -1..1 | 좌우 돌림(±30°) |
| `headPitch` | ParamAngleY | -1..1 | 음수=숙임 |
| `headRoll` | ParamAngleZ | -1..1 | 갸웃 |
| `bodyYaw` | ParamBodyAngleX | -1..1 | 몸 돌림 |
| `bodyLean` | ParamBodyAngleY | -1..1 | 양수=앞으로 기울임, 음수=물러남 |
| `bodyRoll` | ParamBodyAngleZ | -1..1 | 몸 기울임 |
| `shoulderRaise` | ParamShoulderY | -1..1 | 음수=처짐, 양수=움츠림 |
| `breath` | ParamBreath | 0..1 | 호흡 위상(idle 전용) |
| `eyeOpenL` `eyeOpenR` | ParamEyeLOpen/ROpen | 0..1.3 | 1=평상, >1 크게 뜸(AU5), 0=감음(AU43) |
| `eyeSmileL` `eyeSmileR` | ParamEyeLSmile/RSmile | 0..1 | 눈웃음·아래 눈꺼풀 올림(AU6) |
| `lidTension` | (custom) ParamEyeTension | 0..1 | 눈꺼풀 조임(AU7) |
| `gazeX` `gazeY` | ParamEyeBallX/Y | -1..1 | 시선 |
| `pupilSize` | ParamEyeBallForm | -1..1 | 동공 크기 |
| `browLY` `browRY` | ParamBrowLY/RY | -1..1 | 눈썹 전체 올림(AU1+2)/내림(AU4) |
| `browLAngle` `browRAngle` | ParamBrowLAngle/RAngle | -1..1 | 양수=안쪽 올라감(슬픔·걱정, AU1), 음수=안쪽 내려감(분노) |
| `browLX` `browRX` | ParamBrowLX/RX | -1..1 | 음수=미간 모음(AU4) |
| `browForm` | ParamBrowLForm/RForm | -1..1 | 눈썹 휘어짐 |
| `mouthForm` | ParamMouthForm | -1..1 | 음수=입꼬리 내림(AU15), 양수=올림(AU12) |
| `mouthOpen` | ParamMouthOpenY | 0..1 | 입 벌림(AU25/26) |
| `mouthPress` | (custom) | 0..1 | 입술 누름·얇아짐(AU23/24) |
| `mouthAsym` | (custom) | -1..1 | 한쪽 입꼬리(AU14, 냉소·억지웃음) |
| `mouthStretch` | (custom) | 0..1 | 입 옆으로 당김(AU20, 두려움). 발화층의 넓힘(ㅣ·ㅔ·ㅡ)도 여기에 더한다 |
| `mouthRound` | (custom) ParamMouthRound | 0..1 | 입술 오므림·내밂(AU18/22, ㅗ·ㅜ). 2026-09-30 립싱크 설계로 추가(§5.4) |
| `chinRaise` | (custom) | 0..1 | 턱 주름·울음 참기(AU17) |
| `cheek` | ParamCheek | 0..1 | 홍조 |
| `pallor` | (custom) | 0..1 | 창백 |
| `tear` | (custom) ParamTear | 0..1 | 0.3 고임, 0.7 흐름 |
| `sweat` | (custom) | 0..1 | 식은땀 |
- 채널 추가·삭제는 이 표를 먼저 고친다. 렌더러가 지원하지 않는 채널은 무시해도 되지만, 엔진은 모든 채널을 출력한다.
- 좌우 비대칭은 L/R 채널로만 표현한다. 표정 프리셋에서 L/R을 따로 쓰지 않으면 같은 값이다.
## 5. 표정층
### 5.1 표정 프리셋
- 28개 이름·그룹은 그대로 쓴다(`persona.ts` `EXPRESSION_GROUPS`, e2e·exp3 메타데이터 호환).
- 각 프리셋은 **강도 1.0에서의 채널 목표값**(기본 자세 대비 delta)이다.
- **구분 가능성 불변량**: 강도 1.0에서 모든 프리셋 쌍의 L1 거리(좌우 대칭 채널 쌍은 평균 1개로 계산) ≥ 0.6, 최대 단일 채널 차이 ≥ 0.25. 2026-09-30 초안 378쌍 전부 통과, 최소 L1 0.95(neutral/calm).
- 값 정본은 `apps/web/src/components/avatar/engine/expressionPresets.ts`(오케스트레이터 작성)다. 워커는 값을 바꾸지 않는다.
### 5.2 강도 정책 (소유자 결정: 강도 비례 하이브리드)
- 적용값 = 기본 자세 + delta × `gain(intensity)` × `demeanor.expressivity`.
- `gain`: 강도 0~0.35는 선형 0~0.3(절제), 0.35~0.7은 0.3~0.75, 0.7~1.0은 0.75~1.0.
- **오버레이 게이트**(채널 단위, `channels.ts` `OVERLAY_MIN_INTENSITY`): 표정층에서 `tear`는 강도 ≥ 0.7, `cheek`는 ≥ 0.5, `pallor`·`sweat`은 ≥ 0.7일 때만 적용한다. 미만이면 그 채널 delta는 0이다. 반응 클립(`tear_up` 등)은 명시적 cue이므로 게이트를 받지 않는다.
- 표정 전환은 채널별 임계 감쇠 스프링(time constant: 눈·눈썹 120ms, 입 160ms, 머리·몸 350ms)이다. 전체 260ms 선형 보간을 대체한다.
### 5.3 겉과 속 — 미세표정 누설
- Jev 표현 계획의 `display`가 `covered_by_agreement`·`masked`이면 **겉표정**은 display 규칙이 정한다: `covered_by_agreement` → warm 0.3 + `lip_press` 클립, `masked` → neutral 0.3 + `forced_smile` 클립(입만 웃고 눈은 웃지 않는다).
- `hidden_gap`이 참이면 반응값 최대 부정 감정의 표정을 강도 0.8로 **250~400ms 한 번** 겉표정 위에 덮는다(발화 시작 0.5~2.0초 사이, 시드 난수). 교육 목적은 수련생이 가려진 감정을 포착하는 연습이다.
- 누설은 관찰 가능한 행동이므로 `immersive` 모드에서도 렌더한다. 속마음 카드 노출 계약(Jev v2 §9)은 바꾸지 않는다.
- legacy 제공자(Jev 꺼짐)에서는 누설이 없다.
### 5.4 발화층 — 립싱크 (2026-09-30 설계)
- 계기: 소유자가 Lab에서 "입술이랑 말하는 게 너무 인위적이고 안 예쁘다"고 관찰했다. 원인은 크기 값 하나(RMS 또는 사인파)가 mouthOpen만 움직여 모든 음절이 같은 렌즈 모양으로 열렸다 닫히는 것(뻐끔거림)이다. 모음별 입 모양, 양순음 닫힘, 모양 유지, 앞당김이 없다. 조사(2D 립싱크 작화 원칙·Rhubarb·Live2D 모음 립싱크·음성 모음 추정·TTS 타이밍·한국어 발음)를 거쳐 아래로 정했다.
- 전제(코드 확인): TTS(OpenAI `gpt-4o-mini-tts`, 대체 MeloTTS)는 타이밍·비짐을 주지 않는다. 대신 웹은 오디오를 **모두 받은 뒤** `decodeAudioData`로 AudioBuffer를 만들어 재생하고, 발화 텍스트도 Session에 있다. 그래서 TTS 제공자를 바꾸지 않고 **재생 직전에 텍스트와 파형을 함께 분석**한다(선분석). 오디오가 없으면 텍스트만으로 타이밍을 만든다.
- 채택하지 않은 대안
- 음성만으로 모음을 추정하는 방식(MFCC 템플릿 — uLipSync·wLipSync, 포먼트 F1·F2): 텍스트를 이미 알고 있으므로 불필요하다. 화자 의존과 지연이 있다.
- Rhubarb Lip Sync: 영어 음소 인식기다.
- 비짐·문자 타임스탬프를 주는 TTS로 교체(Azure viseme ko-KR, ElevenLabs 정렬): 제공자·비용 변경이 필요하고, 선분석으로 충분하다.
- 둥글기를 mouthForm에 섞는 방식: 감정 입꼬리와 발음 입 모양이 섞인다. 그래서 별도 채널 mouthRound를 둔다.
- 구조: 발화 텍스트(+ AudioBuffer) → 입 모양 타임라인(재생 전 1회 계산, 순수 모듈 `engine/lipsync.ts`·`engine/speechEnvelope.ts`) → 재생 시계로 매 프레임 표본 → 엔진 `setSpeechShape(shape | null)`(기존 `setSpeechLevel` 대체) → 발화층 합성.
**입 모양(비짐) 9종** — 채널 목표값. wide는 mouthStretch에 더하고, round는 mouthRound, press는 mouthPress다.
| 비짐 | 자모 | open | wide | round | press |
|---|---|---|---|---|---|
| X 쉼 | 휴지·무음 | 0 | 0 | 0 | 0 |
| M 닫힘 | 초성 ㅁㅂㅃㅍ, 종성 ㅁㅂㅍ | 0 | 0 | 0 | 0.35 |
| A | ㅏ | 0.75 | 0.1 | 0 | 0 |
| EO | ㅓ | 0.55 | 0 | 0.1 | 0 |
| O | ㅗ | 0.4 | 0 | 0.75 | 0 |
| U | ㅜ | 0.2 | 0 | 1.0 | 0 |
| EU | ㅡ | 0.15 | 0.4 | 0 | 0 |
| I | ㅣ | 0.2 | 0.65 | 0 | 0 |
| E | ㅔ ㅐ | 0.45 | 0.45 | 0 | 0 |
- 이중모음은 활음 + 모음 두 키로 나눈다. 활음은 모음 구간 앞 25%다: y계(ㅑㅕㅛㅠㅒㅖ)는 I → 뒤 모음, w계(ㅘㅙㅝㅞ)는 U → 뒤 모음, ㅚ[we]는 U → E, ㅟ는 U → I, ㅢ는 EU → I.
- 그 밖의 자음은 따로 모양을 두지 않는다(동시조음). 초성 자음 구간은 뒤 모음 모양의 open×0.5다. ㅅㅆㅈㅉㅊ 초성은 open 상한 0.15에 wide +0.2(이 사이를 좁힘). 종성 ㄴㄹㅇㄱㄷ(와 같은 소리로 나는 받침)은 모음 모양을 유지하며 open×0.6이다.
- 음운 규칙(g2p)은 쓰지 않는다. 입 모양을 바꾸는 구분은 양순음 여부뿐인데, 비음화(ㅂ→ㅁ)·연음 뒤에도 양순음은 양순음이다. 한글 분해는 유니코드 산술로 한다(라이브러리 불필요). 한글이 아닌 글자(숫자·영문)는 글자 하나를 EO 한 음절로 친다. 괄호 지문은 TTS와 같은 규칙으로 뺀다.
**타이밍**
- 음절 가중치는 기본 1.0, 받침 있으면 +0.25, 구 끝 음절(휴지 앞) +0.5다. 휴지는 쉼표 250ms, 마침표·물음표·느낌표 450ms, 말줄임표 600ms다.
- 오디오가 있으면 선분석한다.
- AudioBuffer 모노 합성에서 10ms 간격 RMS 포락선을 만든다.
- 유성 구간은 포락선 > max(0.02, 0.12·P95)인 구간이다. 120ms 미만 틈은 메우고 60ms 미만 구간은 버린다.
- 텍스트를 휴지 기준 구로 나눠, 구 수 N과 유성 구간을 순서대로 맞춘다. 구간이 많으면 가장 짧은 틈부터 합치고, 적으면 가장 긴 구간을 구 가중치 비율로 나눈다.
- 각 구 안에서 음절을 가중치 비율로 배치한다. 그다음 음절 중심을 자기 슬롯 ±35% 안의 포락선 최고점으로 옮긴다(음절 핵 맞춤).
- 오디오가 없으면 페르소나 `demeanor.speech.syllablesPerSec`(P1 5.0, 기본 5.9 — 한국어 대화체 평균)로 배치한다.
- 음절 안 키: 초성 키는 음절 시작, 모음 목표는 음절의 35% 지점(활음이 있으면 활음 키가 시작, 모음 목표는 55%), 종성 ㅁㅂ 닫힘은 85% 지점이다.
**표본·평활** (재생 중 매 프레임)
- 재생 위치 t(오디오 시계, 없으면 엔진 시계)보다 **60ms 앞**을 표본한다. 입은 소리보다 먼저 움직인다(애니메이션 관례 1~2프레임).
- 키 사이는 smoothstep으로 보간하고, 채널별 임계 감쇠 스프링 τ 45ms를 둔다.
- **음절 사이에 입을 닫지 않는다.** 닫힘은 M과 X뿐이며, 모음에서 모음으로 바로 옮겨 간다. 이것이 뻐끔거림을 없애는 핵심이다.
- 모든 비짐은 최소 60ms 유지한다. 음절이 90ms보다 짧으면 비강세로 보고, 목표를 앞뒤 음절 평균 쪽으로 40% 끌어당긴다(빠른 말의 떨림 방지).
- 오디오가 있으면 크기로 벌림을 조절한다: open × clamp(0.45 + 0.75·env/envP90, 0.45, 1.1). 무음이 100ms 넘게 이어지면 X로 닫는다.
- 페르소나 조음 폭 `demeanor.speech.articulation`(P1 0.6 — 작게 웅얼거림, P7 0.55, P4 0.95)을 open·wide·round에 곱한다. 발화 중 open 상한은 0.6이다. 턱 레이어가 없어 아랫입술만 크게 내려가면 턱과 따로 논다.
**엔진 합성** (speaking 상태이고 `lipsync` 억제 cue가 없을 때)
- 발화층이 입 벌림을 **대체**한다: mouthOpen = s.open + 0.25·(표정 mouthOpen).
- mouthStretch += s.wide, mouthRound = max(표정 mouthRound, s.round), mouthPress = max(0.5·표정 mouthPress, s.press).
- mouthForm(입꼬리)은 표정 값을 유지하되 ×(1 − 0.5·s.round)다. 슬픈 사람은 입꼬리가 처진 채로 말한다.
**렌더러 입 모양** (§8.4 입 항목의 추가 규칙)
- mouthRound r: 모서리를 안쪽으로 32r 옮기고, 입술 두께를 ×(1 + 0.3r)로 한다.
- **틈은 가운데 타원**이다. 오므린 입은 모서리부터 입술이 붙고 가운데만 작게 둥글게 열린다.
- 틈 반폭 a = (W/2)·(1 − 0.65r), 틈 높이 G = g·(1 + 0.8r). Mc를 중심으로 입 선에서 위로 0.25G, 아래로 0.75G인 타원이며, r = 0이면 기존 렌즈(모서리∼모서리)와 같다. r에 따라 렌즈에서 타원으로 연속해서 바뀐다.
- 틈 밖의 입 선 구간은 닫힌 채 입 선만 그린다.
- 2단계-B-1d R6 관찰: 모서리만 부풀리는 첫 명세로는 O·U 틈이 가로:세로 3.7~9.9의 얇은 선이 되어 둥글게 읽히지 않았다. 목표는 articulation 1.0에서 U 약 1.8, O 약 1.8이다.
- mouthStretch s: 벌림 높이를 ×(1 − 0.3s)로 한다.
- 윗니: open > 0.08이고 (s > 0.2 또는 open > 0.3)이면 보인다. 높이는 min(6, 3 + 8·open)·(1 − 0.8·mouthRound)이고(오므리면 입술이 이를 가린다) 틈 높이를 넘지 않는다. 색은 teeth와 mouthInner를 3:1로 섞은 종이색이며, 위 35%에 윗입술 그늘(잉크 0.35)을 둔다. out4 판정: 새하얀 막대가 판화 그림에서 튀었고, ㅗ에서도 이가 보였다. 첫 문턱 0.12는 P1(조음 폭 0.6)의 ㅣ(open 0.12)에서 경계에 걸려 이가 안 보였다. 틈으로 이가 보여야 ㅣ가 읽힌다.
- 입 안은 mouthInner 짙은 적갈색이다. open > 0.45이면 아래쪽에 조금 밝은 적갈색 반달(혀 그늘)을 입 안 clip으로 넣는다.
**검증**
- `npm run check:avatar-lipsync`(esbuild 번들 node 스크립트)
- 분해·매핑 표본: "음"은 끝이 M, "우리"는 U → I, "봐요"는 M → U → A → I → O
- 합성 포락선 정렬: 휴지 2개일 때 구 경계 오차 ≤ 30ms
- 최소 유지 60ms, M·X 밖에서는 음절 사이 open > 0
- Lab: 발화 버튼은 새 립싱크(텍스트 타이밍)를 쓴다. dev API `/voice/speech`가 있으면 "TTS 음성으로" 옵션으로 실제 음성 선분석 경로를 쓴다.
- 캡처: "음… 그냥 요즘 잠을 잘 못 자요." 필름스트립(50ms 간격).
- 소유자 시각 확인(실제 TTS 음성).
- Session 연결(AudioBuffer·텍스트·재생 시작 시각을 아바타에 전달)은 2단계-B-2에서 한다.
### 5.5 발화 동반층 — 말할 때 얼굴·머리·몸 (2026-10-01 설계)
- 계기: 소유자 관찰 "입술만 움직이니까 기괴하다. 말하면 코도 들썩이고, 입 주변 볼도 볼록해지고, 얼굴 자체가 가만있지 않고, 몸이 들썩이고, 고개를 조금 옆으로 보기도 한다." 말소리에 맞춘 머리·눈썹 움직임(시각적 운율)은 사람의 말하기에 늘 있다. 이것이 없으면 입만 떠 보인다.
- 얼굴 안(턱·코·볼·팔자 주름)은 렌더러가 입 채널에서 직접 만든다(§8.4 하관 띠 변형·볼 불룩). 이 절은 엔진이 만드는 **머리·몸·눈썹·시선·깜빡임**이다.
- 입력은 발화 타임라인(§5.4)이다.
- 구: 시작·끝 시각, 종류(마침표나 구두점 없음 = 평서, `?` = 질문, `!` = 감탄, `,` = 이어짐)
- 강세 음절: 각 구의 첫 음절과, 구 안에서 포락선 최고점이 가장 큰 음절(오디오가 없으면 가중치가 가장 큰 음절). 구가 6음절 이상이면 이웃하지 않은 상위 2개.
- 휴지 구간
- 순수 모듈 `engine/coSpeech.ts`가 타임라인과 시드로 계획을 한 번 만들고, 매 프레임 표본해 채널 delta와 깜빡임 요청을 낸다. 엔진은 speaking 상태에서 idle층 뒤에 이 delta를 더한다. reduced motion이면 끈다(입·턱만 남는다).
- 박(pulse) b(t): 올림 코사인. 정점 전 rise ms 동안 0 → 1, 정점 뒤 fall ms 동안 1 → 0.
- 모든 크기는 페르소나 `demeanor.speech.gesture` g를 곱한다(P1 0.55, P7 0.4, P2 0.9). 깜빡임은 곱하지 않는다.
| 움직임 | 규칙 | 크기(g 곱하기 전) |
|---|---|---|
| 구 표류 | 구마다 고개 목표를 새로 뽑아 구 시작 100ms 전부터 450ms smoothstep으로 옮기고 유지. yaw 부호는 구마다 번갈아(첫 구는 시드). 마지막 구가 끝나면 700ms에 걸쳐 0으로 | headYaw ±U(0.2, 0.4), headRoll ±U(0.1, 0.2), headPitch U(−0.1, 0.05) |
| 강세 박 | 강세 음절의 모음 목표 시각보다 80ms 앞이 정점(머리는 소리보다 살짝 먼저). rise 120·fall 260. 감탄 구는 ×1.4 | headPitch −0.25, bodyLean +0.12, shoulderRaise +0.08 |
| 구 끝 | 평서: 마지막 음절 모음 목표에 정점인 끄덕임(rise 150·fall 400). 질문: 마지막 두 음절에 걸쳐 들고 갸웃해 구 끝 300ms 뒤까지 유지, 400ms에 풀기. 이어짐(`,`): 없음 | 평서 headPitch −0.3 / 질문 headPitch +0.12·headRoll +0.15(구 표류 roll 부호) |
| 들숨 | 200ms 이상 휴지 뒤(또는 발화 시작)의 구: 구 시작 전 250ms에 올리고, 구의 앞 40% 동안 내쉬며 풀기 | shoulderRaise +0.25, bodyLean −0.05 |
| 음량 들썩임 | 오디오가 있을 때만: 0.1·max(0, env/P90 − 0.5), τ 120ms 평활 | shoulderRaise |
| 눈썹 박 | 각 구의 첫 강세에서 확률 0.5(시드), 질문 구는 항상. rise 100·유지 150·fall 250 | browLY·browRY +0.15 |
| 시선 회피 | 구 시작에서 확률 `demeanor.speech.gazeAvertProb`(P1 0.7): 150ms에 옮겨 U(500, 900)ms 유지 후 250ms에 복귀. 발화 마지막 400ms에는 하지 않는다(말 끝에 상대를 본다) | gazeX 0.25·sign(구 표류 yaw), gazeY −0.15 |
| 깜빡임 | 250ms 이상 휴지의 시작 + 50ms에 확률 0.6으로 깜빡임 요청. 엔진 idle 깜빡임 타이머를 다시 잡는다 | — |
- 수치는 렌더러 변환(§8.4 표: headYaw·14px, headPitch·20px, headRoll·7°, shoulderRaise·10px)에서 bust 200px에서도 1px 넘게 보이도록 정했다. 필름스트립을 보고 조정한다.
- 엔진 API: `setSpeechMotion(plan | null)`, 또는 매 프레임 delta를 받는 형태. 형태는 구현이 정하되 순수 표본 함수는 `coSpeech.ts`에 둔다. 깜빡임 요청은 엔진의 idle 깜빡임과 같은 경로로 재생한다.
- 검증(`check:avatar-lipsync`에 추가)
- 같은 시드면 계획이 같다.
- 강세 박 정점 = 모음 목표 − 80ms.
- 질문 구에서 headRoll·headPitch가 들린다.
- 마지막 400ms에는 시선 회피가 없다.
- reduced motion에서는 delta가 0이다.
## 6. 반응층 (클립)
### 6.1 클립 형식
```ts
type ReactionClip = {
id: ReactionClipId; // 아래 카탈로그
label: string; // 한글, 개발 도구 표시용
durationMs: number;
fadeInMs: number;
fadeOutMs: number;
priority: 1 | 2 | 3; // 3이 가장 높음
cooldownMs: number; // 같은 클립 재발동 최소 간격
mirrorable?: boolean; // 스케줄러가 시드 난수로 좌우 반전 재생 가능
tracks: Partial<Record<ChannelId, Array<[tMs: number, value: number, ease?: "linear" | "in" | "out" | "inOut" | "hold"]>>>;
suppress?: Array<"blink" | "saccade" | "lipsync" | "sway">;
};
```
- 키프레임의 ease는 직전 키프레임에서 이 키프레임으로 가는 구간 곡선이다. `hold`는 직전 값을 유지하다 이 시각에 도달하는 계단이다.
- 값 정본은 `apps/web/src/components/avatar/engine/clipCatalog.ts`(오케스트레이터 작성)다.
- 합성: 클립 값은 표정층 결과에 **가산**하고 채널 범위로 자른다. 가중치는 fade 엔벨로프다.
- 동시 재생은 최대 2개. 세 번째가 오면 priority가 낮은 것부터 fade-out한다. 같은 priority면 오래된 것을 뺀다.
- `suppress`가 있는 동안 해당 idle 요소를 멈춘다(예: `freeze`는 깜빡임 억제).
### 6.2 카탈로그 v1 (26종)
| id | 한글 | 요지 |
|---|---|---|
| `nod_small` | 작게 끄덕 | pitch -0.15↔0, 2회 |
| `nod_deep` | 깊게 끄덕 | pitch -0.35, 1회, 느림 |
| `head_shake` | 고개 젓기 | yaw ±0.2, 2~3회 |
| `head_drop` | 고개 떨굼 | pitch -0.45 유지, 시선 아래, 어깨 처짐 |
| `look_away_side` | 옆으로 시선 회피 | gazeX ±0.7, yaw ±0.15 |
| `look_down` | 시선 내림 | gazeY -0.7, pitch -0.1 |
| `glance_up` | 힐끗 봄 | 시선 정면 0.4초 후 복귀 |
| `flinch` | 움찔 | pitch +0.15·lean -0.2 120ms, 눈 깜빡, 어깨 올림 |
| `freeze` | 얼어붙음 | eyeOpen 1.25 유지, 깜빡임·흔들림 억제 |
| `sigh` | 한숨 | 어깨 올림(들숨)→처짐(날숨), 눈 반쯤 감김 |
| `deep_breath` | 심호흡 | 느린 어깨·몸 상승 하강 |
| `swallow` | 침 삼킴 | chinRaise·mouthPress 짧게 |
| `lip_press` | 입술 누름 | mouthPress 0.7 유지 |
| `lip_bite` | 입술 깨묾 | mouthPress + mouthAsym |
| `scoff` | 피식·코웃음 | mouthAsym 0.6, yaw 소폭, 시선 위 |
| `eye_roll_small` | 눈 굴림 | gazeY 위→옆 |
| `shrug` | 어깨 으쓱 | shoulderRaise 0.6 후 복귀, 눈썹 올림 |
| `lean_back` | 물러남 | bodyLean -0.4 유지 |
| `lean_forward` | 다가옴 | bodyLean +0.3 유지 |
| `tear_up` | 눈물 고임 | tear 0→0.4, chinRaise, 깜빡임 증가 |
| `blink_flurry` | 잦은 깜빡임 | 1.2초에 4회 |
| `fidget_sway` | 몸 흔들림 | bodyRoll 소폭 불규칙 |
| `forced_smile` | 억지 미소 | mouthForm 0.4, eyeSmile 0 |
| `nervous_laugh` | 어색한 웃음 | 짧은 mouthOpen·eyeSmile, 시선 회피 |
| `jaw_clench` | 이 악묾 | mouthPress 0.8, browL/RX -0.4 |
| `silence_hold` | 침묵 유지 | 정지, 시선 아래, 느린 깜빡임 |
페르소나 성향 기본값(P1~P7)의 정본은 `apps/web/src/components/avatar/engine/demeanorDefaults.ts`다.
## 7. 신호 → 공연 스크립트
### 7.1 공연 스크립트 (턴 단위)
```ts
type Performance = {
surface: { expression: AvatarExpression; intensity: number }; // 겉표정
leak?: { expression: AvatarExpression; intensity: number; atMs: number; durationMs: number };
cues: Array<{ clip: ReactionClipId; anchor: "listen" | "pre" | "inline" | "post"; at: number; weight?: number; source: "direction" | "state" | "jev" }>;
// anchor=inline이면 at은 발화 텍스트 내 비율(0..1), 그 외에는 앵커 기준 지연 ms
};
```
- 발화 시간축: TTS가 있으면 오디오 길이, 없으면 발화 가능 텍스트 길이 × 90ms로 추정한다. inline cue 시각 = 발화 시작 + 길이 × at.
- 지문만 있는 발화("(침묵)")는 발화층 없이 cue만 재생한다.
### 7.2 신호원과 단계
| 신호원 | 단계 | 계산 위치 | 비고 |
|---|---|---|---|
| ① 괄호 지문 | 1단계 | **웹** 파서(`stageDirections.ts`) | 자막 원문에 지문이 남아 있어 추가 API 없이 동작. legacy에서도 동작. |
| ② 개방도·단계·위기 | 1단계 | 웹(`expressionForSession` 대체) | 겉표정·강도 결정. 기존 매핑을 강도 포함 형태로 옮긴다. |
| ③ Jev 표현 계획 | 3단계 | **API** `avatar_cue`(신규, nullable) | behavior·stance·display·hidden_gap·반응 9축 → 겉표정·누설·자세 cue. done·TurnResponse·음성 reply에 한 헬퍼로 같은 값. `inner_reaction`과 달리 피드백 정책으로 막지 않는다(관찰 가능한 행동만 담는다: 원 감정 수치·속마음 문구는 넣지 않는다). |
| ④ 청자 반응 | 3단계 | API 조기 이벤트 | 상담자 발화 판정(Jev A층, legacy면 라포 휴리스틱) 직후 `listen` cue 1개를 생성 전에 보낸다. |
| ⑤ 페르소나 성향 | 1단계 | 웹(기본값 표), 4단계에 카드로 이전 | idle 빈도·기본 자세·expressivity. |
- stance → 자세 cue(3단계): `engage` → `lean_forward`(약), `cautious` → 없음, `pull_back` → `look_away_side` 또는 `head_drop`, `push_back` → `lean_back` + `scoff`/`jaw_clench`.
### 7.3 괄호 지문 → cue (1단계)
표본(2026-09-30 수집, 워커 보고 수용·오케스트레이터 재확인):
- 로컬 DB `app.turns` 0행이라 운영 빈도는 미확인이다.
- Claude 생성 P1 40턴은 턴마다 지문 1개(`(한숨)` 37, 멈춤·침묵·고개 돌림 각 1)이며, 위치는 문장 사이 33·발화 시작 7이다.
- **운영 모델(gpt-5.6-terra) 6턴은 지문 0개다.** 지문만으로는 운영에서 반응이 거의 나오지 않는다.
- golden 합성 19개: 잠시 멈춤·어깨 으쓱·피식·한숨·어색한 웃음·머뭇·손톱 만지작·시선 피함 등.
- 괄호는 인용·메모 용도로도 쓰인다(예: `('지침 vs 게으름' 재구성)`).
파서 규칙:
- 대상은 내담자 발화뿐이다. 반각·전각 소괄호와 대괄호(`voice.py` `_STAGE_DIRECTION_RE`와 같은 문자 집합)를 찾는다.
- 괄호 안쪽 길이가 `STAGE_DIRECTION_MAX_LENGTH`(16)를 넘으면 무시한다. 쉼표·가운뎃점으로 나뉜 복합 지문(`(한숨, 침묵 10초)`)은 조각마다 따로 대응한다.
- 어휘집(`engine/stageDirectionLexicon.ts`, 오케스트레이터 작성)의 규칙을 위에서부터 검사해 처음 맞는 것 하나를 쓴다. 맞는 규칙이 없으면 무시하고 Lab의 "미대응 지문" 목록에만 보인다.
- anchor: 앞에 발화 가능한 글자가 없으면 `pre`(지연 0), 뒤에 없으면 `post`(지연 0), 그 외 `inline`(at = 앞쪽 발화 가능 글자 수 / 전체 발화 가능 글자 수).
- 한 턴 최대 3개. 같은 클립이 연달아 나오면 하나로 합친다.
### 7.4 상태 유도 보조 cue (1단계)
지문 cue가 0개인 턴에만, 시드 난수(발화 텍스트 해시)로 확률 0.6에 한 개를 만든다.
| 겉표정 계열(`AVATAR_EXPRESSION_LIBRARY` group) | 조건 | 후보(균등 선택) |
|---|---|---|
| negative | 강도 ≥ 0.5 | `look_down` pre 200ms, `lip_press` post |
| defensive | — | `look_away_side` inline 0.3, `lean_back` pre |
| positive | — | `nod_small` inline 0.4 |
| energy | — | `look_down` pre(weight 0.6) |
| cognitive | — | 없음 |
### 7.5 겉표정 강도 (1단계, legacy 경로)
표정 이름은 기존 `expressionForSession()`이 정한다. 강도는 새로 계산한다.
- 위기(`safety`) → 0.9, 일시정지(`paused`) → 0.6
- negative·defensive 계열: `0.35 + 0.5 × (1 − openness)`
- positive 계열: `0.3 + 0.6 × openness`
- cognitive·energy 계열: 0.5
### 7.6 지문 어휘 가이드 (3단계에서 확정)
운영 모델이 지문을 거의 쓰지 않으므로, 3단계에서 내담자 프롬프트에 **지문 어휘 가이드**(대사 사이 0~2개, 행동만 짧게, 감정 이름 금지, 예시 목록)를 넣는다. 가이드는 권장이며 형식 강제가 아니다. 기존 대화 비교 러너로 전후 3턴×2회 이상 원문을 보고 역할 이탈·과잉 지문이 없을 때만 수용한다.
## 8. 아트 디렉션과 리그
### 8.1 탐색
- 1차(`docs/avatar-art/art-direction-v3/p1/d1~d4`, 프롬프트 `prompts/`): 종이 컷아웃·잉크 에디토리얼·모던 포크·시네마틱 플랫. 오케스트레이터 검수 결과 네 장 모두 애니/웹툰 얼굴 템플릿과 교복에서 벗어나지 못해 **브리프 미충족**(원인: 프롬프트의 교복 카디건·얼굴 지시 부족). 턱 괸 손 포즈도 리그 부적합. d1의 상징 모티프만 유효.
- 2차(`p1/r2-a~d`, 프롬프트 `prompts/r2/`): 애니·웹툰 명시 금지, 실제 개인 얼굴(자연스러운 눈 크기·비대칭·점), 후드티, 손 화면 밖. 종이 초상·리노컷 판화·과슈 에디토리얼·모더니즘 포스터. 네 장 모두 브리프 충족.
- **소유자 선택(2026-09-30): `r2-b-linocut` 리노컷 판화 스타일 + "작은 모티프 + 배경 색 변화".**
- 선 언어: 조각도 자국이 있는 굵은 선(인디고 검정), 평면 색 2도(슬레이트 블루·황토) + 크림 종이, 약한 인쇄 어긋남·잉크 질감.
- 표정은 선 몇 개(눈꺼풀·눈썹·입·턱 주름)가 전달한다. 절차적 베지어 선의 굵기 변화로 조각 선을 흉내 낸다.
- 상징: 꽃봉오리·날씨 모티프를 옆에 작게 두고, 배경 색면도 은은하게 감정을 따른다(모드별 반영 범위는 §8.2 상징 모티프층 규칙 그대로).
### 8.2 리그 원칙 (방향과 무관)
- **표정을 만드는 부위(눈·눈꺼풀·눈썹·입·눈물·홍조)는 코드가 그리는 절차적 베지어**다. 채널 값에서 제어점을 계산한다. 이미지 파츠를 표정마다 생성하지 않는다(7월 실패의 원인인 파츠 간 일관성 문제를 구조적으로 없앤다).
- **덩어리 부위(뒷머리·몸·의상·목·얼굴 없는 머리·앞머리·소품)는 페르소나당 한 벌의 판화 질감 이미지 레이어**다. 채널에 따라 이동·회전·시차만 주고 변형하지 않는다.
- 근거(2026-09-30 spike, `scratch/rig-spike/`): vtracer 트레이싱은 기본값에서 path 4,559개·4.6MB로 래스터를 감싼 것에 불과하고, 단순화하면(1.3~1.4MB) 조각 선·해칭이 사라지고 머리 윤곽에 halo가 생긴다(A). 참조 이미지 편집(codex exec `-i`)으로 같은 인물의 정면 기본형·얼굴 없는 기본형·머리카락 분리를 만들면 스타일이 유지되고 레이어 간 위치 차이가 1px 이내다(B).
- 생성 규칙: 페르소나마다 **기준 이미지 2장**(정면·중립·손 없음 기본형과, 그것을 참조 편집해 눈·눈썹·입만 지운 얼굴 없는 기본형)을 먼저 확정한다.
- **레이어 픽셀은 항상 얼굴 없는 기본형에서 가져오고, 레이어 경계는 분할 마스크로 정한다**(머리카락·얼굴 피부·목·옷 분할 + 랜드마크 얼굴 윤곽). 정지 상태 합성은 기본형과 픽셀 단위로 같아야 한다.
- 참조 편집으로 재생성한 이미지는 **가려진 영역 채움**(고개가 움직일 때 드러나는 머리카락 뒤 후드 등)에만 쓴다. 2026-09-30 2단계-A 1차에서 머리·앞머리를 재생성으로 만들자 얼굴 크기·턱선·머리숱이 바뀌고 노란 획이 생겼다(평행이동 정렬로는 크기 차이를 못 고친다).
- 표정 때문에 덩어리 레이어를 다시 생성하지 않는다. 7월 실패(파츠 53개 개별 생성·표정별 이미지 교체)의 원인을 구조적으로 배제한다.
- 고개 돌림은 레이어별 시차(앞머리 > 얼굴 부위 > 얼굴 윤곽 > 뒷머리)와 얼굴 부위의 수평 압축으로 표현한다.
- 질감(종이결·인쇄 망점 등)은 SVG 필터 1개 또는 정적 오버레이 1장으로 제한한다(프레임 비용).
- **상징 모티프층**: 페르소나별 상징(P1 예: 닫힌 꽃봉오리·안개/비/빛)을 아바타 옆 작은 층으로 둔다. `immersive` 모드에서는 겉표정의 정서(날씨)만 반영하고, `ambient`·`coached` 모드에서만 개방도(봉오리 열림)를 반영한다. 속 감정(반응 9축·hidden_gap)은 모티프에 반영하지 않는다.
- **배경 색면**: 겉표정 계열에 따라 배경 색을 은은하게 바꾼다(모든 모드). 전환은 1.5초 이상, 채도 변화는 작게 해 본문 대비·가독성을 해치지 않는다. 누설 미세표정 동안에는 바꾸지 않는다.
- **얼굴 음영 레이어(faceDetail)**: 얼굴 없는 기본형은 눈·눈썹·입과 함께 눈두덩 그늘·다크서클 해칭·입가 음영·점까지 지운다. 벡터 부위만 얹으면 평평한 얼굴에 스티커를 붙인 것처럼 보이므로, 정면 기본형에서 눈 주변·입 주변 음영과 점만 떼어 낸 정적 레이어 1장을 둔다. 눈 틈·눈썹·입술 자리는 피부로 메워(눈을 감으면 그 자리가 눈꺼풀 피부가 된다) 표정과 무관하게 한 장으로 쓴다. 메우는 규칙(2단계-B 두 차례 반려 후 확정): 제외 영역은 쌍꺼풀·아랫눈꺼풀·눈꼬리·눈썹 털·입꼬리·아랫입술 그늘선까지 이목구비 선 전체를 덮고, 그 안은 얼굴 없는 기본형 픽셀을 **그대로** 쓴다(음영비 확산 보간은 눈·입 모양 회색 얼룩을 만들어 금지). 제외 영역이 좁으면 원본 선이 남아 표정이 바뀔 때 유령 윤곽이 된다. 머리카락 픽셀은 알파 0이다(face 그룹 시차로 가닥이 두 겹이 되지 않게). 표정별 이미지가 아니므로 §10 비목표에 걸리지 않는다.
- 좌우는 화면 기준이다. 엔진 채널 `*L`/`*R`, 리그 랜드마크 `Left`/`Right` 모두 작은 x가 Left다(DebugFace와 같다). 계약: `apps/web/src/components/avatar/v3/linocutRig.ts`(오케스트레이터 작성).
### 8.4 P1 리노컷 렌더러 명세 (2단계-B)
단일 SVG다. 레이어 이미지(`<image>`)와 벡터 부위가 한 좌표계(캔버스 1005×1566)를 공유해야 부위가 머리와 정확히 같이 움직인다. `viewBox`가 크롭(`portrait`·`bust`·`face`)이다. 매 프레임 React 재렌더 없이 rAF에서 속성을 직접 쓴다(DebugFace와 같은 방식).
그리는 순서(뒤→앞):
1. 배경 색면 rect(겉표정 그룹 색, `fill` 전환 1.8초)
2. figure 그룹 — 몸 전체
1. body 그룹: body 이미지
2. head 그룹: head 이미지 → face 그룹(faceDetail 이미지 → 창백 → 홍조 해칭 → 벡터 부위, 얼굴 윤곽 clip) → hairFront 이미지
3. 종이결 타일(곱하기, 불투명도 0.35) — 전체 위
채널 → 변환(단위: 캔버스 px, 각도 °). 부호는 DebugFace와 같다.
| 그룹 | 변환 |
|---|---|
| figure | translate(bodyYaw·8, −bodyLean·6) · rotate(bodyRoll·3, pivot.body) · scale(1+bodyLean·0.03, pivot.body) |
| body | translate(0, −shoulderRaise·10) · scaleY(1+breath·0.006, pivot.body) |
| head | translate(headYaw·14, −headPitch·k − shoulderRaise·4 − breath·2.5) · rotate(headRoll·7, pivot.neck), k = 20(숙임, headPitch<0)·14(들기) |
| face | translate(headYaw·6, −headPitch·4) · scale(1−\|headYaw\|·0.08, 1+headPitch·0.03, pivot.face) |
| hairFront | translate(headYaw·10, −headPitch·3) |
- 코는 head 이미지(래스터)에 있으므로 얼굴 부위의 추가 수평 이동을 작게(headYaw·6) 제한한다. 숙임 쪽 계수가 큰 것은 숙이면 턱이 목을 덮어 레이어 틈이 드러나지 않기 때문이다(들기는 2단계-A 모션 검사 범위 14px 안). 고개 돌림은 머리 이동·시선·약한 압축으로 전달한다.
벡터 부위(눈 E: inner I·outer O·upperLidTop U·lowerLidBottom D·홍채 중심 C·반지름 r). `mid(x)`는 O–I 직선의 x 지점 y, `hU = mid(U.x) − U.y`, `hD = D.y − mid(D.x)`.
- **눈 틈**: `openD = (1 − 0.75·eyeSmile)(1 − 0.3·lidTension)·min(1, 0.4 + 0.6·clamp(eyeOpen,0,1))`, `openU = e·(1 − 0.25·lidTension)(1 − 0.15·eyeSmile)`, `e = eyeOpen ≤ 1 ? max(0, eyeOpen) : min(1.5, 1 + (eyeOpen − 1)·1.8)`(크게 뜬 눈을 증폭해 놀람이 읽히게 한다). 아래 꼭짓점 `yD = mid(D.x) + hD·openD`, 위 꼭짓점 `yU = yD + (U.y − yD)·openU`(2단계-B 재작업에서 정정: 이전 식은 O–I 직선을 U.x·D.x에서 따로 보간해 중립에서 0.5~0.7px 벗어났다). 윗·아랫 눈꺼풀은 O·꼭짓점·I를 지나는 매끈한 곡선이다. openU=0이면 윗눈꺼풀이 아랫눈꺼풀까지 내려와 감긴 속눈썹 선 하나가 된다. 채널이 중립(eyeOpen 1, 나머지 0)이면 곡선이 랜드마크 O·U·I, O·D·I를 지난다.
- **윗눈꺼풀 선**: 윗곡선 아래로 두께 t(s)만큼 채운 초승달(s=0 바깥→1 안쪽, `t = 1.5 + 4·(1 − smoothstep(0.45, 1, s))`) + 바깥 꼬리 8px. 쌍꺼풀 주름은 윗곡선을 `9 + 5·max(0, openU−1)`만큼 위로 띄운 가는 선(s 0.15~0.85, 불투명도 0.6, openU<0.35면 비례해 흐려짐). 아랫눈꺼풀은 s 0~0.8 가는 선.
- **눈 속**: 눈 틈을 흰자색으로 채우고, 홍채(중심 C + (gazeX·0.85r, gazeY·0.5r), 테두리 0.14r, 방사 조각 획 8개), 동공(0.42r·(1+0.3·pupilSize)), 하이라이트(0.13r, 홍채 기준 (−0.35r, −0.4r))를 눈 틈 clip 안에 그린다. 윗눈꺼풀 아래 3.5px 그늘 띠(잉크 0.18).
- **눈썹 랜드마크는 원화 눈썹 잉크 띠의 중심선 위 점**이다(열마다 잉크 픽셀의 y 무게중심을 이은 선을 매끈하게 한 뒤, 안쪽 끝·가장 높은 점·바깥 끝). 2단계-B 3차 판정에서 안쪽 점이 눈썹 머리 아래 모서리, 꼭짓점이 띠 윗가장자리에 찍혀 원화에서 거의 수평인 눈썹이 38px 기운 사선으로 그려졌다. 이것이 "중립이 화난 얼굴"의 근본 원인이었다(굵기는 악화 요인).
- **눈썹**(inner Bi·peak Bp·outer Bo): 전체 dy = −browY·45. 미간 쪽 dx `−browX·16`(음수=미간으로 모임). browAngle a는 부호에 따라 다르게 옮긴다(AU1 안쪽 올림은 바깥이 따라 오르지 않는다): **a > 0**이면 안쪽 끝 dy −60a, 꼭짓점 dy −4a, 바깥 끝 dy +18a. **a < 0**(AU4 안쪽 내림)이면 안쪽 끝 dy −50a, 꼭짓점 dy −12a, 바깥 끝 dy +14a. 꼭짓점에 추가로 −browForm·10. 비탄 강도 1에서 안쪽 끝이 꼭짓점보다 20px 이상 높아 ㅅ자가 한눈에 보여야 한다(소유자 기준 이미지). 동시에 **눈썹이 이마로 떠 보이면 안 된다**: 소유자가 2단계-B-1d 중 Lab에서 비탄을 보고 "눈썹이 너무 위, 두 배는 더 내려와야"라고 했다(안쪽 끝이 쉴 때보다 약 57px 위, 눈 윗선과의 거리가 쉴 때의 3배). 그래서 비탄의 ㅅ자는 안쪽을 크게 올려서가 아니라 AU1+AU4 — 안쪽은 조금 올리고 눈썹 전체를 누르며 미간으로 모아 바깥이 처지게(프리셋 browAngle 0.8·browY −0.4·browX −0.25) — 만든다. 강도 1에서 안쪽 끝 상승은 쉴 때보다 30px 이하. 모양은 세 점을 지나는 매끈한 곡선(12점 이상 표본)이며 꼭짓점에서 꺾이면 안 된다. 몸체는 반두께 4.5 → 3.2 → 1.0의 리본(잉크 0.6), 그 위에 **털 획 14개**(잉크 0.7, 굵기 1.3, 길이 9~14, 시드 고정): 방향은 곡선 접선에서 위·바깥으로 안쪽 1/3은 35°·나머지는 15° 기울이고, 뿌리는 리본 폭의 ±60% 안에 흩뿌려 몸체 밖으로 조금 삐져나오게 한다. 평평한 검은 막대처럼 보이면 안 된다(2단계-B-1b 1차 반려 사유). 굵기·짙기는 소유자 기준 이미지 수준이다: 오케스트레이터가 한때 반두께 6.5·잉크 0.85로 올렸다가 굵은 막대 눈썹이 비탄의 ㅅ자를 묻고 **중립을 화난 얼굴로** 만들어 철회했다. 표정은 굵기가 아니라 변위(안쪽 50 등)로 읽히게 한다. 미간 주름 2획 불투명도 `clamp(max(0,−browLX,−browRX)·1.1 + max(0,−browLAngle,−browRAngle)·0.6, 0, 0.85)`.
- **이마 주름**은 두 종류다(3차 판정: 같은 높이의 곧은 평행선 3줄이 바코드처럼 보였다). 모든 획은 양끝이 가는 리본(반두께 0.4 → 1.1 → 0.4)이고, 사인 물결(진폭 1.5px, 획당 1.5주기, 시드 고정 위상)을 준다.
- **안쪽 올림 주름**(AU1, 비탄·슬픔·불안): 불투명도 `clamp((browAngle 평균 − 0.3)·1.4, 0, 0.75)`. 좌우 각각 현재 눈썹 안쪽 끝과 꼭짓점 사이(안쪽 65%·꼭짓점 35% 지점) 위에 3획 무리. 맨 아래 획은 현재 안쪽 끝보다 22px 위, 간격 11px, 길이 58·48·38(아래가 김). 각 획은 가운데가 솟은 호이고 미간 쪽 끝이 4px 더 높다(올라간 눈썹 머리를 따라감). 좌우 무리 사이 미간은 16px 이상 비운다.
- **전체 올림 주름**(AU1+2, 놀람·기대): 불투명도 `clamp((browY 평균 − 0.35)·1.6, 0, 0.7)`. 두 꼭짓점 사이를 가로지르는 가운데가 5px 솟은 긴 호 3획(현재 꼭짓점 위 30·42·54px), 각 획을 시드 고정 길이의 2~3토막으로 끊는다(토막 사이 틈 6~12%).
- 안쪽 올림이 0.3을 넘으면 눈썹 머리 위에 흐린 그늘(잉크, 가우시안 흐림 표준편차 10, 반지름 34×20, 불투명도 `0.18·clamp((a − 0.3)/0.7)`)을 둔다(소유자 기준 이미지의 눈썹 머리 주변 회색 그늘).
- **입**(랜드마크 모서리 Lc0·Rc0, 입 선 중앙 M0, 윗입술 윗선 T, 아랫입술 아랫선 B). 입술 경계는 모두 **곡선**이며 수평선·직사각형·꺾인 직선 금지(2단계-B-1b 1차 반려 사유).
- 모서리: `Lc = (Lc0.x − w, Lc0.y − 36·mouthForm − 22·mouthAsym + 6·chinRaise)`, `Rc = (Rc0.x + w, Rc0.y − 36·mouthForm + 22·mouthAsym + 6·chinRaise)`, `w = 26·mouthStretch + 12·max(0, mouthForm) − 10·max(0, −mouthForm)`(찡그리면 입이 좁아진다 — 3차 판정: 비탄 입이 넓은 ∩자 만화 입이 되었다).
- 입 선 중앙: `Mc = (M0.x, M0.y − 6·mouthForm − 4·chinRaise)`. 모서리는 36, 중앙은 6만 움직이므로 미소면 U자, 찡그림이면 ∩자로 휜다. **입술 면과 입 선은 모두 이 곡선을 기준으로 한다**(모서리 평균으로 통째 이동 금지 — 1차 설계 변경 요청 8-1 반려).
- 벌림 g = mouthOpen·44. 윗 안쪽 곡선 `Cu`는 Lc·(Mc.x, Mc.y − 0.25g)·Rc를, 아랫 안쪽 곡선 `Cl`은 Lc·(Mc.x, Mc.y + 0.75g)·Rc를 지나는 2차 곡선이다(g=0이면 둘이 같다). 모서리는 고정이고 가운데가 렌즈 모양으로 열린다.
- 두께: `tU = (M0.y − T.y)(1 − 0.6·mouthPress)(1 − 0.25·max(0, mouthForm))`, `tL = (B.y − M0.y)(1 − 0.55·mouthPress)(1 − 0.2·max(0, mouthForm))(1 − 0.3·chinRaise)`. 입 폭 W = Rc.x − Lc.x. chinRaise(AU17)는 아랫입술을 밀어 올려 입을 삐죽 모은다: 아랫입술 바깥선 가운데 점을 추가로 8·chinRaise 올린다.
- 윗입술 바깥선: Lc → (Lc.x + 0.22W, Cu(x) − 0.55tU) → 왼 봉우리 (Mc.x − 0.19W, Cu(x) − tU) → 골 (Mc.x, Cu(Mc.x) − tU + 4) → 오른 봉우리 → (Rc.x − 0.22W, Cu(x) − 0.55tU) → Rc. 점들을 Catmull-Rom → 3차 베지어로 매끈하게 잇는다. 윗입술 면 = 바깥선 + Cu 역방향.
- 아랫입술 바깥선: Lc → (Lc.x + 0.25W, Cl(x) + 0.7tL) → (Mc.x, Cl(Mc.x) + tL) → (Rc.x − 0.25W, Cl(x) + 0.7tL) → Rc, Catmull-Rom. 아랫입술 면 = Cl + 바깥선 역방향.
- 입 안(g>0.5): Cu와 Cl 사이 mouthInner. 윗니(mouthOpen>0.3): Cu와 Cu를 min(9, (mouthOpen−0.3)·22)만큼 내린 곡선 사이, 양끝 12% 안쪽, 입 안 clip.
- 입 선: Cu를 따라 12점 이상 표본한 리본(굵기·색은 아래 질감 항목), g>0.5면 Cl에도 불투명도 0.6의 가는 선.
- 질감: 입술은 평면 색 덩어리가 아니라 **원화의 조각 판화 입술**이어야 한다. 3차 판정: 벡터 결 획·윤곽선을 얹은 단색 렌즈 입은 96~300px 어디서도 판화로 읽히지 않고 스티커처럼 떠 보였다. 그래서 **입술 결은 원화에서 떼어 낸 래스터**(리그 `lipTexture`: 윗입술·아랫입술 2장)이고, **모양은 벡터**다. 덩어리는 이미지, 표정은 벡터라는 원칙 그대로이며, 표정별 이미지가 아니므로 §10에 걸리지 않는다.
- **원칙: 중립에서 렌더된 입은 원화와 픽셀 단위로 같고, 움직이면 원화 픽셀이 변형된다**(Live2D가 원화를 메시로 변형하는 것과 같은 원리). 2단계-B-1d 중간 판정: 공식으로 만든 벡터 입술 윤곽으로 결을 잘라내자 윗입술이 뾰족한 지붕 모양이 되고, 입꼬리 쐐기가 바늘처럼 튀어나오고, 아랫입술 아래 그늘이 성긴 빗금이 되어 원화와 달랐다.
- 입술 스프라이트 영역 = 입술 색 마스크를 10px 넓힌 영역이며, **원화 픽셀 그대로**다(복제·반사·메우기 없음). 입꼬리의 짙은 홈과 입 선 전체가 들어간다. 바깥 4px만 알파를 선형으로 줄여 피부에 섞는다.
- **아랫입술 아래 그늘은 별도 조각**(`lipTexture.shadow`)이다: 아랫입술 마스크 아래 경계에서 18px 띠, 원화 픽셀, 아래·옆 6px 알파 램프, 위는 아랫입술 스프라이트 아래로 6px 겹친다. 턱 변위 f(그늘 중심 y)만큼만 옮기고 가로로 줄이지 않는다. out5 판정: 아랫입술 스프라이트에 붙여 두자 ㅗ에서 W/W0로 함께 압축되어 짙은 직사각형이 됐다. 턱 피부 그늘은 입이 좁아져도 줄지 않는다.
- 중립 입 선 곡선 Cu0로 위·아래로 나눈다(윗 스프라이트는 y ≤ Cu0(x), 아랫 스프라이트는 y > Cu0(x)). 입을 벌리면 입 선 잉크가 둘로 갈라져 윗입술 아랫면과 아랫입술 윗면의 짙은 가장자리가 된다.
- 2단계-B-1d 1차 반려 사유: 입 선 띠를 거울 반사로 메우자 정상 그늘이 지워지고 줄무늬·만화경 무늬가 생겼다. 결 보존은 원본 픽셀 동일성으로 검사한다.
- **띠 변형**(SVG 메시 변형 근사): 각 스프라이트를 가로 24개 세로 띠로 나눈다(16개는 joy·U에서 입 선 추종 오차 3.9~6.9px, 24개는 2.6~4.6px). 띠 중심 x(중립 캔버스)에 대해 x' = Mc.x + (x − M0.x)·W/W0로 옮긴다. 윗입술 띠는 (x, y) → (x', Cu(x') + (y − Cu0(x))·tU/tU0), 아랫입술 띠는 (x, y) → (x', Cl(x') + (y − Cu0(x))·tL/tL0)로 옮긴다. 띠 가로 배율은 W/W0이고, 이웃 띠와 1px 겹친다. W0·tU0·tL0는 채널 반영 전 값이다(W0 = Rc0.x − Lc0.x, tU0 = M0.y − T.y, tL0 = B.y − M0.y).
- **입술 모양은 스프라이트 알파가 정한다.** 벡터 입술 면(upperD·lowerD)으로 잘라내지 않으며, 입술 단색 면·아랫입술 아래 벡터 그늘 빗금·입꼬리 벡터 쐐기도 그리지 않는다. 입 안(Cu와 Cl 사이)·윗니·혀 그늘은 스프라이트 아래에 그린다.
- 벡터 입 선은 **작은 화면 보조**로만 그린다: 불투명도 0.95·clamp((sizeScale − 1)/0.5, 0, 1). face 300px에서는 보이지 않고 원화 입 선이 보인다. bust 200px 이하에서는 원화 입 선이 1px 아래로 흐려지므로 벡터 선이 받친다.
- 벡터 입 선의 모양: mouthLine 잉크 리본, 반두께 모서리 1.4 → 가운데 2.6 → 모서리 1.4(mouthPress>0.3 또는 mouthForm<−0.3이면 모서리 쪽 +0.8까지), 모두 sizeScale을 곱한다.
- `lipTexture`가 없는 리그는 기존 경로를 쓴다: 벡터 입술 면(upperD·lowerD) + lipUpper·lipLower 단색, 위 벡터 입 선(항상 불투명도 0.95), 입꼬리마다 접선 바깥 10px·반두께 2.2 → 0 잉크 쐐기, 아랫입술 아래 그늘 초승달 해칭(잉크 0.3, 아랫입술 폭 70%).
- 검사
- 중립 합성 = 원화: 스프라이트 알파 1 영역 PNG 최대 차 0, 가장자리 4px 평균 차 보고
- 렌더 중립 입 3배 확대 대 원화 육안 비교
- 중립 채널에서 입 선은 랜드마크 Lc0·M0·Rc0를 지난다.
- **렌더 구분도 측정**(2단계-B 소유자 관찰 "표정마다 차이를 모르겠다" 이후): P1, 강도 1, Reduced motion 켬(idle·반응층 잡음 제거, 기본 자세는 유지), face 크롭 96px에서 중립 대비 평균 절대 픽셀 차를 15종 모두 잰다. 처음 정한 고정 목표 12는 근거 없는 수치라 폐기했고, 소유자가 인정한 비탄(아래 기준점)의 값 G에 대한 비율로 목표를 정한다(측정 후 오케스트레이터 결정). 불안·피곤처럼 미세 긴장 위주의 표정은 면적 변화가 작아 이 지표만으로 판정하지 않고 시각 확인을 함께 한다. 세션 화면 크기에 가까운 **bust 200px**에서도 같은 측정과 시각 확인을 한다(3차 판정: face 300px에서는 구분되던 표정이 bust 200px에서 중립·슬픔·비탄·불안·외로움 모두 비슷해 보였다).
- **작은 크기 선 보정**: 렌더러는 크기가 바뀔 때만(ResizeObserver, 프레임마다 아님) 화면 px/캔버스 단위 비율 `k = 렌더 폭 / viewBox 폭`을 재고, 표정 선 굵기 배율 `s = clamp(0.3 / k, 1, 2)`를 기하 계산에 넘긴다. 적용 대상: 눈썹 리본·털 획, 입 선·입꼬리 쐐기, 윗눈꺼풀 초승달 두께, 미간·이마·팔자·턱 주름 리본. face 300px(k≈0.5)에서는 s = 1이라 모양이 바뀌지 않고, bust 200px(k≈0.2)에서 1.5, face 96px(k≈0.16)에서 약 1.9다. 작은 화면에서 선이 1px 아래로 떨어져 사라지면 변위가 있어도 표정이 안 읽힌다(인쇄의 광학 보정과 같은 이유).
- **소유자 명확도 기준점(2026-09-30)**: 소유자가 재작업 중간 Lab 화면의 비탄(grief) 강도 1을 보고 "이 정도가 맞다"고 확인했다(`docs/avatar-art/p1-linocut/reference/owner-grief-intensity1.png`: 안쪽이 크게 올라간 사선 눈썹, 이마 가운데 가로 주름, 반쯤 감긴 눈, 양 뺨 눈물 자국, 처진 입꼬리, 숙인 고개). 다른 표정도 강도 1에서 이 정도로 한눈에 읽혀야 한다. 강도가 낮을수록 약해지는 것은 강도 곡선(§5.2)이 맡는다. 같은 화면의 입은 아직 평면 색 덩어리라 조각 판화 입술로 바꾼다.
- **보조 주름**(모두 곡선·양끝이 가는 리본): 팔자 주름 2획 `clamp((mouthForm − 0.15)·1.6, 0, 0.5)`(발화 반응 항은 아래). 콧방울 아래 옆 (noseTip.x ± 40, noseTip.y + 28)에서 시작해 현재 입꼬리 바깥 (corner.x ∓ 12, corner.y + 2)에서 끝나고, 바깥으로 6px 부푼 호다(반두께 1.1 → 0.3, 불투명도 상한 0.5). 끝이 가늘게 사라지는 짧은 주름이어야 한다. out4·out5 판정: 코 옆 높은 곳에서 시작하는 균일한 가는 선이 입을 감싸는 괄호 아치로 떠 보였다. 턱 주름 `chinRaise·0.8` — 아랫입술 아래 (B.x, B.y + 26) 중심 타원(가로 0.3W, 세로 12) 안에 짧은 굽은 획 6개(길이 8~12, 시드 고정)로 턱 끝 오돌토돌함(AU17)을 표현한다. 수평 직선 금지.
- **하관 띠 변형 — 턱·코 들썩**(2026-10-01, §5.5 계기와 같은 소유자 관찰): 입이 벌어지는 양의 대부분은 턱이 만든다. 아랫입술만 내려가고 턱이 제자리면 기괴하다. ㅏ에서 실제 턱은 약 0.6cm, 캔버스로 약 15px 내려간다. 윗입술이 들리면 인중·콧방울도 조금 들린다.
- 세로 변위장 f(y)(캔버스 px, 아래가 +)를 정한다. y_n = noseTip.y − 50, T = upperLipTop, B = lowerLipBottom, C = chinTip.
- y ≤ y_n: 0
- y_n → T.y: smoothstep으로 0 → −U
- T.y → B.y: smoothstep으로 −U → +Jl
- B.y → C.y + 20: smoothstep으로 Jl → Jc
- 그 아래: Jc
- g는 현재 입 틈 높이(§5.4 렌더러 규칙 반영 값)다. U = 0.7·0.25g + 1.5·mouthStretch(윗입술을 따라 인중·콧방울이 들림), Jl = 0.8·0.75g(턱이 아랫입술 이동의 80%를 나름), Jc = 1.3·Jl(턱 끝이 더 많이 내려감).
- 띠의 원본은 리그 `jaw`의 **잘라 낸 조각**(head·faceDetail에서 y_n − 10부터 C.y + 40까지, 얼굴 윤곽 18px 바깥 폭만큼 자른 원본 픽셀)이다. 전체 1005×1566 이미지를 띠마다 다시 그리면 비용이 크다(out5 실측: 인스턴스 1개 발화 중 long task 14/7.3s). 조각을 y_n부터 C.y + 30까지 가로 띠 **12개**로 나눈다. 각 띠는 윗변을 y0 + f(y0)으로, 아랫변을 y1 + f(y1)로 옮기는 세로 이동·배율 변환이다. 이웃 띠와 경계가 이어지므로 찢어지지 않는다.
- 띠 clip = 띠 사각형 ∩ **얼굴 윤곽을 바깥으로 10px 넓힌 다각형**이다(처음 18px에서 줄였다 — 넓으면 목 해칭까지 함께 내려가 목에 이음매가 생긴다. 원화 턱선 잉크 두께 약 6px만 들어가면 된다). **그룹 전체에 정적 clip을 걸면 안 된다**: out6에서 정적 윤곽 clip이 내려간 턱선을 경계마다 잘라 점선처럼 만들었다. 띠의 로컬 좌표에 두어 내용과 함께 움직인다. 원화 턱선 잉크 전체가 띠 안에 들어가 한 줄로 온전히 움직여야 한다. out4 판정: 윤곽 다각형이 턱선 잉크 한가운데를 지나, 안쪽 반은 내려가고 바깥쪽 반은 남아 턱선이 점선처럼 끊겼다. 넓힌 경계(머리카락·목 영역)의 이음매가 3배 확대에서 보이면 흐린 마스크(표준편차 4)로 경계를 섞는다. 윤곽 밖(머리카락·귀·목)은 움직이지 않는다. 턱선이 내려가면 목 위를 덮는다. 원본 이미지는 띠 아래에 그대로 둔다.
- 입을 제외한 얼굴 벡터 부위 중 y > y_n인 점(팔자·턱 주름, 아랫입술 아래 그늘, 홍조 중심, 눈물 끝)에 f(y)를 더한다. 입 자체는 §5.4 규칙으로 따로 움직이며, f는 입술 경계에서 그 움직임과 맞도록 정했다.
- **띠가 켜진 동안 원본 faceDetail은 변형 영역(y > y_n, 넓힌 윤곽 안)을 빼고 그린다.** faceDetail은 대부분 투명한 덧칠 레이어라, 변형된 복사본을 위에 얹어도 원본의 턱 음영이 비쳐 턱선이 원래 자리와 내려간 자리에 두 겹(점선)으로 보였다(out4·out5 반려 사유, 흐린 마스크로는 해결되지 않음). head 원본도 같은 방식으로 변형 영역을 뺀 사본으로 바꾼다(R13 실측: 띠가 덮지 않는 자리로 원본 턱선이 비쳤다). 띠 경계는 1px 겹친 딱딱한 clip이다. 흐린 마스크는 쓰지 않는다(효과가 없고 비용만 크다).
- 얼굴 그룹 clip 다각형과 창백 마스크 다각형의 점에도 f(y)를 더한다. 얼굴 윤곽이 턱과 함께 내려가야 벡터 부위와 faceDetail 띠가 원래 윤곽에서 잘리지 않는다.
- max|f| < 0.3px이면 띠를 숨기고 원본만 그린다(쉬는 동안 비용 0).
- 성능 목표: 실제 엔진·인스턴스 1개가 발화하는 동안 rAF 간격 p95 ≤ 20ms, long task 0(7초 기준 1회 이하).
- 검사: 중립에서 띠 합성이 원본과 같다(픽셀 차 0에 가깝다). A(open 0.6)에서 턱 끝 이동 Jc를 보고한다. 띠 경계에 가로 줄·틈이 없다(3배 확대). 인스턴스 5개 프레임 비용을 보고한다.
- **볼 불룩·팔자 주름의 발화 반응**
- 볼 불룩: 입꼬리마다 바깥으로 (±16, −4) 지점에 흐린 잉크 타원(반지름 16×24, 가우시안 표준편차 6, 불투명도 0.10·b)을 둔다. 그 위 (±4, −8) 비킨 곳에 종이색 하이라이트(불투명도 0.10·b)를 둔다. b = max(mouthPress, mouthRound)이며, ㅁ·ㅂ·ㅗ·ㅜ에서 입 옆 볼이 볼록해진다.
- 팔자 주름 불투명도는 `clamp((mouthForm − 0.15)·1.6 + 0.35·mouthStretch + 0.2·mouthOpen, 0, 0.5)`로 한다. ㅣ·ㅔ와 크게 벌린 입에서 깊어진다.
- **눈물**: 고임 `pool = clamp(tear/0.3)` — 아랫눈꺼풀 위 종이색 윤기 선 + 안쪽 눈구석 물방울. 흐름 `flow = clamp((tear−0.3)/0.7)` — 아랫눈꺼풀 안쪽 35% 지점에서 뺨을 따라 길이 flow·120(화면 오른쪽 눈은 ×0.7), 잉크 테두리 + 종이색 심 + 끝 물방울.
- **홍조**: 눈 아래 뺨(홍채 중심 아래 85px, 반지름 55×32)에 사선 해칭 패턴(간격 7, 굵기 2.2, blush색), 가장자리 흐림, 불투명도 cheek·0.8.
- **창백**: 얼굴 윤곽을 중심 기준 4% 줄이고 가우시안 흐림(표준편차 16)을 준 **마스크** 안에 pallor색 `mix-blend-mode: color` 불투명도 pallor·0.45 + 흰색 pallor·0.12. 경계가 칼같이 떨어지는 가면처럼 보이면 안 된다(1차 반려 사유). 벡터 부위보다 아래.
- **땀**: 관자놀이 물방울 2개(잉크 테두리·종이색·하이라이트), 불투명도 clamp(sweat·1.3), 아래로 sweat·8.
- **잉크 질감**: 벡터 부위 그룹에 SVG 필터 1개(fractalNoise 기반 변위, 규모 약 2px). id는 인스턴스마다 고유.
- **배경 색면·모티프**: 배경은 `backdrop[그룹]`. 모티프는 별도 컴포넌트(봉오리 + 날씨)로 두고 배치는 화면이 정한다.
- **모티프 그림은 리노컷 스타일로 생성한 래스터 스프라이트다**(2단계-B-1b 판정으로 변경). 코드로 그린 벡터 모티프는 가는 막대·아이콘 수준의 클립아트가 되어 아트 방향(조각 질감·상징)에 못 미쳤다. 스프라이트는 페르소나당 한 벌이며 표정별 이미지가 아니다(§10에 걸리지 않음).
- P1 스프라이트: 봉오리 4상태(닫힘·반쯤 열림·활짝 열림 — 곧은 줄기, 닫힌 채 고개 숙임), 날씨 5종(positive 해와 빛살, negative 비구름과 빗줄기, defensive 짙은 안개 구름, energy 해 질 녘 초승달·별, cognitive 옅은 수평 안개결). 스타일 프레임 `r2-b-linocut`의 모티프 언어와 색(황토 `motifPetal`, 슬레이트 블루 `motifLeaf`)을 따른다. 봉오리 스프라이트는 줄기 밑동 위치가 같아 교차 페이드로 겹쳐도 어긋나지 않아야 한다.
- 봉오리: 개방도 o(0..1)를 닫힘·반쯤·활짝 가중치로 섞는다(o ≤ 0.5면 닫힘 1−2o·반쯤 2o, 그 위는 반쯤 2−2o·활짝 2o−1). 세 장은 줄기·잎이 겹치므로 동시 교차 페이드해도 된다. `immersive`에서는 o = 0.25로 고정한다.
- 고개 숙임: `immersive`에서는 겉표정이 negative면 숙인 봉오리를 쓴다. `ambient`·`coached`에서는 negative이면서 o < 0.35일 때만 숙인다. 우울한 P1은 negative가 잦아서, 조건 없이 숙이면 개방도 신호가 대부분 가려진다. 곧은 봉오리와 숙인 봉오리는 자세가 달라 동시 교차 페이드하면 줄기가 두 겹으로 보이므로 **순차 페이드**로 바꾼다(이전 것 0.6초에 사라짐, 새 것 0.4초부터 0.9초에 나타남).
- 날씨: 그룹이 바뀌면 1.5초 교차 페이드한다. 움직임은 약하게만 준다(해 60초 1회전, 비구름·안개 ±2~3px 가로 흔들림 6~8초, 초승달·옅은 안개결 정지). `prefers-reduced-motion`에서는 모두 정지.
- 배치: 날씨(320×200 캔버스)를 위에, 봉오리(280×420 캔버스, 밑동 기준점 정렬)를 아래에 겹쳐 둔 세로 구성 하나. 화면에서의 위치는 사용하는 쪽이 정한다.
- **로드 실패**: 레이어 하나라도 실패하면 `data-load-state="error"`와 콜백으로 알린다. 세션(2B-2)은 이때 기존 SVG 리그로 되돌아간다. 이 환경에서 SVG `<image>`의 load·error 이벤트가 발동하지 않아(원인 미확인, 2단계-B-1b 관찰) 같은 href를 `HTMLImageElement`로 미리 불러 로드 상태를 집계한다.
- 새 공연(`playPerformance`)은 이전 공연의 **미발동 cue를 모두 취소**한다. 이미 재생 중인 클립은 자연 종료한다.
### 8.4.1 캐스트 확장 (P2~P7, 2026-10-01 설계)
- **원화 선택**: 소유자 지시("모든 사람 병렬로, 지금 아트웍 스타일을 따라서, 다양한 표정과")에 따라 오케스트레이터 추천안을 쓴다. 소유자가 바꾸면 해당 페르소나만 다시 돈다.
| 페르소나 | 후보 파일 |
|---|---|
| P2 | front-b |
| P3 | front-v2b |
| P4 | front-v2b |
| P5 | front-v2a |
| P6 | front-v2b |
| P7 | front-v2b |
- **편집본을 원본 구도에서 만든다**: 얼굴 없는 기본형·몸 원본은 정규화 전 원화를 참조로 codex 편집한다. 페르소나마다 2장씩 만든다. 프롬프트는 `pN-linocut/prompts/`에 있고, P1 문구에서 사람·소지품만 바꿨다.
- 패딩된 가장자리를 모델이 "고치는" 일을 막기 위해서다.
- 편집본은 `register_edit.py`(ECC 정렬)로 원화 좌표에 맞춘다.
- 머리카락·옷·얼굴 윤곽의 에지 챔퍼 거리로 수용한다.
- faceless: 중앙값 ≤1.0px, p90 ≤2.5px, 윤곽 띠 p90 ≤2.0px
- body: 중앙값 ≤1.5px, p90 ≤3.0px
- **정규화**: `normalize_base.py`가 원화·얼굴 없는 기본형·몸 원본을 한 변환으로 P1 캔버스 1005×1566에 옮긴다.
- 머리 배율은 P1과 같게 한다. 눈-턱·눈 사이 거리 비의 평균이다.
- 폭은 반드시 덮는다(배율은 s = max(s_head, 1005/원화 폭)). 두 눈 중점은 P1에 맞춘다.
- 위아래 빈 곳은 반사 패딩한다.
- 렌더러 기하 상수(px)가 P1 머리 크기 기준이라, 머리 크기를 맞추는 것이 조건이다. 측정한 s_head는 0.91~0.97이다.
- 아래쪽 판화 끝선이 캔버스 바닥보다 위에서 끊길 수 있다. 이는 portrait 크롭에서만 보이고, 세션의 bust·face 크롭에는 들어오지 않는다.
- **눈썹 굵기를 원화에서 잰다**: 리그 계약 `RigBrow.halfWidth?: [안쪽, 꼭짓점, 바깥]`을 추가했다.
- 렌더러 반두께가 P1 설계값 [4.5, 3.2, 1.0]으로 고정이면, 굵은 눈썹(남성·성인) 원화가 가는 벡터 눈썹으로 바뀌어 "원화를 따른다"는 원칙이 깨진다.
- brow_centerline이 잉크 띠 반두께를 재고 export_rig이 쓴다.
- P1은 `browHalfWidthFromArt: false`로 기존 리그를 유지한다(소유자 검수본).
- **세션 반영 단위**: 파이프라인이 만든 리그는 Lab(`import.meta.glob`)에서 바로 보인다. 세션 레지스트리(`linocutRigFor`)에는 오케스트레이터가 표정 시트를 판정한 페르소나만 넣는다.
- **표정 검수**: 페르소나마다 4가지를 본다.
- 16표정 bust·face 시트
- 비짐 9종
- neutral 렌더와 원화 대조
- P1과 같은 판정 기준: 중립 렌더 = 원화, 얼굴이 원화 인물 그대로, 표정 구분
- 모티프 스프라이트(시계·촛불·연·참새·고치/나비·달팽이)는 세션에 아직 배치하지 않아(§8.5) 이번 범위 밖이다.
- **제작 중 확정한 추가 규칙(2026-10-01 P7 시범 판정)**
- **얼굴 해칭은 원화 픽셀로 지킨다**
- codex 얼굴 지우기 편집은 눈·입뿐 아니라 얼굴 전체 해칭(다크서클·볼 빗금·팔자·수염)까지 지운다.
- persona.json `faceDetail.region: "face"`이면 faceDetail이 얼굴 윤곽 안쪽 전체(윤곽에서 `faceInsetPx` 24px 안쪽, 윤곽 잉크선은 빼서 턱선이 두 겹이 되지 않게 함)를 원화 픽셀로 덮는다.
- 눈·눈썹·입 제외 영역만 faceless로 채운다.
- `matchFacelessColor`는 그 채움을 주변 원화 피부색에 상수 평행이동으로 맞춘다(보간·블러 금지).
- 머리카락 알파 0 마스크는 원화·faceless 분할의 합집합이다.
- P1은 `features`(기존)를 유지한다.
- **눈썹은 원화 스프라이트**
- 벡터 눈썹(반투명 띠 + 털 획)은 P1의 가늘고 옅은 눈썹에 맞춘 것이라, 굵은 판화 눈썹이 "갈색 막대"가 됐다.
- `RigBrow.sprite`
- `brow_sprite.py`가 원화 눈썹 잉크를 뜬다.
- 알파는 잉크 농도이고, L_ink = 잉크 픽셀 휘도 중앙값, 경계 1.5px다. 색은 피부 오염을 역산한다.
- 실제로 위에 그려지는 hairFront 레이어 알파 아래 픽셀은 뺀다. 분할 category로 빼면 굵은 눈썹이 머리카락으로 오분류돼 82%가 사라졌다.
- 렌더러는 이를 세로 16띠로 나눠, 벡터 눈썹과 같은 계수의 inner·peak·outer 변위를 2차 보간으로 옮긴다.
- P7 중립 합성 평균절대차는 7.8/5.0(알파>0 영역)이다.
- **눈꺼풀 선 굵기·홍채는 원화에서 잰다**
- `RigEye.lineScale`
- 원화 윗눈꺼풀 잉크 두께 ÷ P1 2.0px이고, [1, 2.5]로 제한한다.
- 늘어나는 분량은 눈꺼풀 선 **바깥쪽**으로만 더한다. 안쪽으로 두꺼워지면 홍채가 가려져 눈이 졸려 보였다.
- 홍채 중심·반지름은 원화 실측을 쓴다(`irisFromArt`). MediaPipe 홍채는 P7에서 14~15% 컸다.
- P1은 세 필드 모두 false로 기존 리그를 유지한다.
- **정규화 위쪽 빈 곳은 종이색으로 채운다.** 반사 패딩은 머리카락이 원화 위 끝 가까이 오는 후보(P3·P5·P6·P7)에서 머리 위에 뒤집힌 머리카락을 만들었다.
- **QA 시트는 깜빡임을 피해 찍는다.** 3연사 중 눈 윤곽이 가장 큰 장을 고른다.
### 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`로 내보낸다.
- 웹 `PERSONA_AVATAR_LOOKS`·`live2dModel.ts` `PERSONA_PROFILES.art`·imagegen 팔레트의 중복을 이 블록 하나로 없앤다.
- 블록이 없는 카드(스튜디오 신규 페르소나)는 demographics·big5·affect_baseline에서 기본값을 유도한다.
## 9. 단계와 완료 기준
| 단계 | 범위 | 완료 기준 |
|---|---|---|
| 0 | 아트 디렉션 선택 | 스타일 프레임 4종 생성·오케스트레이터 검수·소유자 선택 |
| 1 | 엔진 코어(웹): 채널·프리셋·클립·믹서·지문 파서·성향 기본값 + 디버그 렌더러 + dev Lab(`/dev/avatar-lab`) | typecheck·build 통과, Lab에서 28표정×강도·26클립·지문 텍스트 입력 재생 확인, 구분 가능성 불변량 검증, Playwright Lab 스펙 |
| 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 메타데이터 정리 | 소유자 시각 확인, 자산 삭제는 소유자 확인 후 |
## 10. 비목표
- 실제 Live2D Cubism `.moc3`, WebGL 메시 변형 엔진, **표정별 래스터 파츠**(7월 방식) 재도입. 덩어리 레이어의 이미지화(§8.2)는 비목표가 아니다.
- 전신·손 동작(흉상 범위 유지).
- 아바타 표현으로 수련생 채점.
- TTS 감정 조절(별도 과제).
## 11. 구현 판정 기록
### 0단계 아트 디렉션 — 1차 브리프 미충족, 2차 수용 (2026-09-30)
- 1차 4종: 생성은 수용, 아트로서는 애니/웹툰 얼굴·교복·턱 괸 손 포즈로 브리프 미충족(원인은 오케스트레이터 프롬프트). 2차 4종 수용, 소유자가 리노컷 선택.
- spike A(트레이싱)·B(참조 편집 레이어) 수용. 결론은 §8.2 혼합 리그.
### 1단계 엔진 코어·Lab — 수용 (2026-09-30)
- 산출: `engine/{rng,clipPlayer,engine,performance}.ts`, `v3/DebugFace.tsx`, `pages/AvatarLab.tsx`(`/dev/avatar-lab`), `scripts/check-avatar-presets.mjs`(`npm run check:avatar-presets`), `e2e/avatar-lab.spec.ts`. 기존 파일은 `App.tsx` 라우트 1개·`package.json` 스크립트 1줄만.
- 오케스트레이터 확인: 설계 데이터 5개 무수정(수정 시각), `engine.ts`·`clipPlayer.ts`·`performance.ts` 직접 읽음(층 순서·보간·파서·보조 cue·강도 공식 일치), DebugFace는 rAF에서 React 재렌더 없음. 재실행: `check:avatar-presets` 통과(378쌍, 최소 L1 0.95), avatar-lab E2E 6 passed. 워커 보고: typecheck·build·check:dead-code 통과.
- 패킷 정정 1건: anchor 기대값 계산 오류(0.43→0.375)를 오케스트레이터가 정정.
- 후속(2단계 패킷에 추가): 새 `playPerformance`가 이전 공연의 미발동 cue를 취소하는 규칙이 없다(패킷 누락).
### 2단계-A P1 리노컷 레이어 — 1차 반려, 재작업 수용 (2026-09-30)
- 1차 반려: head·hairFront를 재생성 이미지로 만들어 얼굴 크기·턱선·머리숱이 바뀌고 노란 획이 생겼다. §8.2 원칙(픽셀은 얼굴 없는 기본형 + 분할 마스크, 재생성은 가려진 영역 채움 전용)으로 바꿔 재작업.
- 재작업 수용: MediaPipe ImageSegmenter(selfie_multiclass) + FaceLandmarker로 body·head·hairFront 분리(`docs/avatar-art/p1-linocut/`). 정지 합성 대 얼굴 없는 기본형 평균 절대차 전체 1.22·얼굴 0.32·머리 윤곽 5.58. 오케스트레이터가 비교·마스크·모션 이미지를 직접 확대해 확인했다. 목의 대각선 경계는 원본 그림의 그늘이라 결함이 아니다.
- 남은 결함 2건(2단계-B 자산 게시에서 보정): 고개를 들거나 기울이면 턱 밑에 밝은 가로 띠가 드러남(body 턱 밑 채움이 목 그늘과 이어지지 않음), 바깥 잔머리가 마스크에 잘려 점선처럼 끊기고 조각이 떠 있음.
- 설계 보강: 얼굴 없는 기본형에서 눈두덩·다크서클·입가 음영·점까지 지워진 것을 확인하고 faceDetail 레이어를 추가(§8.2).
### 2단계-B-1b 리노컷 렌더러·모티프·Lab — 1차 반려 후 재작업 (2026-09-30)
- 기능 계약은 수용: 단일 SVG·rAF 직접 쓰기·`useId` 고유 id·DOM 계약·Lab 렌더러 선택/크기 미리보기/피드백 모드·공연 cue 취소(`playPerformance` 첫머리 `scheduledCues = []`)·E2E 12개. verifier 재실행: typecheck·build·check:avatar-presets·check:dead-code 통과, avatar-lab E2E 12 passed 2회 연속. 성능 측정(보고만): 인스턴스 5개에서 프레임 콜백 평균 0.18ms·p95 0.40ms.
- 그림 품질은 반려(오케스트레이터가 표정 15종·클립 10종·모티프 5종 모음 이미지로 직접 확인): 입이 직선 다각형이라 아랫입술이 갈색 직사각형, joy에서 검은 막대. 눈썹이 평평한 검은 막대. 표정 간 차이가 작음(눈썹 이동량이 두께보다 작음). 창백이 경계가 칼같은 회색 가면. 주름이 수평 직선. 모티프가 클립아트 수준이고 negative 봉오리가 화면 밖으로 잘림. 명세의 빈틈(입 곡선 형태·변위 크기)도 원인이라 §8.4를 개정했다.
- 설계 변경 요청 8-1(입 기준점을 모서리 평균으로) 반려: 입술 면이 통째로 이동하고 입 선만 휘어 선이 입술을 가로지른다. 원인은 기준점이 아니라 입술 경계가 곡선을 따르지 않은 것. 8-2(SVG `<image>` load 이벤트 미발동 → `HTMLImageElement` 선로드로 상태 집계) 수용, 단 주석은 "원인 미확인"으로 사실대로.
- 모티프는 리노컷 래스터 스프라이트로 설계 변경(§8.4).
- 재작업 2차(중간): 곡선 입·눈썹·주름 호·창백 마스크·로드 주석 정정 수용, 수치 검증(중립 입 선·눈꺼풀이 랜드마크를 0px로 통과, joy U자·sad ∩자) 통과. 소유자가 Lab에서 "표정마다 차이를 모르겠다"고 관찰 → 오케스트레이터 측정(중립 대비 5.2~8.6)으로 확인하고 변위 계수·입술 질감·눈썹 짙기를 다시 올리고 P1 expressivity 0.75 → 0.9, 렌더 구분도 목표(§8.4)를 완료 기준으로 추가. panic 창백이 직사각형 상자로 잘리는 버그 발견.
- 재작업 3차 판정(캡처 `scratch/linocut-review/out3/`, 오케스트레이터가 표정 모음·소유자 기준 비교·눈 확대·클립·모티프를 직접 확인): 창백 직사각형 수정·눈(눈꺼풀·다크서클 경계)·강한 표정(비탄·공황·놀람·분노·피곤) 구분은 수용. 구분도(G = 비탄 10.33 대비) sad 0.81·joy 0.85·warm 1.08·anxious 0.44·panic 1.12·angry 0.56·skeptical 1.11·tired 1.06·startled 0.62·ashamed 1.24·relief 0.54·overwhelmed 0.78·lonely 0.85. 반려 4건: ① 입이 잉크 없는 단색 렌즈라 판화 위 스티커로 보이고 비탄 입은 넓은 ∩자 만화 입 ② 중립 눈썹이 사선이라 bust 크기에서 째려보는 얼굴 — 원화에 랜드마크를 찍어 보니 눈썹 점이 중심선이 아니라 띠 모서리에 찍힌 것이 원인(굵기 지시 철회만으로는 못 고쳤다) ③ 이마 주름이 곧은 평행선 3줄 ④ bust 200px에서 약한 표정이 서로 안 구분됨. 대응으로 §8.4에 눈썹 중심선 랜드마크·부호별 눈썹 각도 계수·이마 주름 두 종류·원화 입술 결 래스터 + 벡터 모양·찡그림 입 좁힘·턱 올림 삐죽 입·작은 크기 선 보정을 정했다. 약한 표정 프리셋 조정은 이 수정 뒤 재측정해 정한다.
- 2단계-B-1d 판정(캡처 `scratch/linocut-review/out4/`, 2026-10-01, 오케스트레이터가 표정·흉상·입 확대·비짐·발화 스트립·턱 확대를 직접 확인)
- 수용: 중립 눈썹(중심선 랜드마크로 째려봄 해소), 비탄(ㅅ자 눈썹 inner−peak −23/−24, 안쪽 상승 27px ≤ 30, 삐죽 입, 소유자 기준과 같은 인상), 입(원화 픽셀 스프라이트 + 24띠 변형 — 중립에서 원화와 같음, 기본 자세 변환을 빼면 차이는 피부 톤 수준 7.0), bust 200px 판독성(out3보다 크게 개선), 비짐 9종(ㅗ·ㅜ 둥근 틈 1.8), 발화 동반층(질문 끝 갸웃·쉼 깜빡임 확인), 검증 6종·E2E 19 passed.
- 렌더 구분도(G 대비, face96 / bust200): sad 0.80/0.73, joy 0.82/0.92, warm 1.03/1.09, anxious 0.44/0.80, panic 1.09/1.20, angry 0.61/0.82, skeptical 1.03/1.15, tired 1.01/1.09, startled 0.68/0.97, ashamed 1.20/1.25, relief 0.52/0.76, overwhelmed 0.80/0.94, lonely 0.80/0.99. 가장 약한 anxious 프리셋을 강화했다(눈썹 0.6·미간 −0.45·눈 0.22·누름 0.45·턱 0.2·움츠림).
- 반려 5건: ① 턱 띠 clip이 턱선 잉크를 반으로 잘라 점선처럼 끊김 ② 윗니가 새하얀 막대, ㅗ에서도 이가 보임 ③ 팔자 주름이 입 위 괄호 아치 ④ 눈썹 확대 캡처가 얇은 띠로 잘려 판정 불가 ⑤ 발화 스트립이 bust 240px라 입·턱 세부 판정 불가(얼굴 크기 재캡처). §8.4를 개정했다.
- 성능(브라우저 실측, 발화 중): 인스턴스 1개(하네스) rAF p95 17.7ms·long task 0, Lab 인스턴스 5개 p95 39.5ms·long task 14/7.3s. 세션은 인스턴스 1개이지만, 실제 엔진으로 1개 실측을 다시 받는다.
- 2단계-B-1d 턱 띠 결함 규명(R9~R13, 2026-10-01): 입을 크게 벌리면 턱선이 두 줄로 보였다. 원인은 셋이 겹친 것이다.
- ① faceDetail(투명 덧칠) 원본이 비침 → R10에서 변형 영역을 뺀 사본으로 해결
- ② 띠 그룹에 정적 윤곽 clip → R11에서 띠 로컬 clip으로 해결
- ③ head 원본과 **hairFront 자산에 잘못 들어간 턱선 잉크**(머리카락 분할이 짙은 턱선을 머리카락으로 분류)가 제자리에 남음 → R13에서 head는 변형 영역을 뺀 사본으로 해결. hairFront는 렌더러 구멍으로 막았다가, 진짜 앞머리 가닥까지 사라질 위험이 있어 자산에서 지우고 구멍을 되돌린다(F10).
- 교훈: 같은 잉크가 여러 레이어에 들어 있으면 레이어를 하나씩 끄는 시험이 서로를 가려 잘못된 가설(띠 수·clip 모양·"잔여 잉크")로 이끈다. `elementFromPoint`는 opacity 0 요소도 잡는다.
- out9 턱 확대에서 X·A·O(art 1.0)·A(art 0.6) 모두 한 줄을 오케스트레이터가 직접 확인했다. 성능: 하네스 인스턴스 1개, 턱 연속 진동에서 rAF p95 6.2ms, long task 0.
- 참고: Lab의 숨긴 미리보기 4개는 `running`이 항상 참이라 CSS로 숨겨도 계속 돈다. Lab에서 "인스턴스 1개" 성능을 재려면 언마운트해야 한다.
- **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`. 공개 웹(Pages `vignette`, 미리보기 `https://dd96c2d5.vignette-b1q.pages.dev`)은 소유자가 직접 실행했다. 오케스트레이터의 반영 명령은 자동 권한 판정이 막았다.
- 운영 확인
- `https://vignette.chanpaca.net` 주 묶음이 이 빌드(`index-DdcluDZF.js`)와 같고, `ClientAvatar`·`p1Rig` 묶음에 v3 코드가 있다.
- `/avatar/v3/p1/*.webp`는 200(image/webp)이고, 이전 세대 묶음도 보존돼 200이다.
- 운영 로그인 화면에서 소유자가 시각 확인하는 일이 남았다.
### 2단계-B-1c P1 모티프 스프라이트 — 봉오리 1차 반려, 재배치 수용 (2026-09-30)
- codex imagegen(`gpt-5.6-terra`, 스타일 프레임 참조)으로 봉오리 4상태 시트·날씨 5종 시트를 각 1회에 생성. 프롬프트는 오케스트레이터 작성(`docs/avatar-art/p1-linocut/motif/prompts/`).
- 날씨 5종 수용: 조각 질감·색 제한·회청색 배경 halo 0. 봉오리 1차 반려: 캔버스보다 큰 식물을 축소 없이 넣어 봉오리 머리가 잘렸다(워커는 "깨끗하다"고 보고). 공통 배율 0.7677·캔버스 280×420·밑동 (134.2, 399)으로 재배치 후 수용 — 잘림 0, 밑동 정렬 오차 0.57px, 96·48px 가독 확인(오케스트레이터 직접 확인).
- 스프라이트는 `motif/sprites/`, 검사 수치는 `motif/manifest.json`. 웹 게시·리그 연결은 자산 파이프라인(`export_rig.py`)과 렌더러에서 한다(계약 `RigMotif`).