diff --git a/.env.example b/.env.example index 6b48ad1..40c4748 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,8 @@ ENGINE_MODE=claude_cli ANTHROPIC_API_KEY= OPENAI_API_KEY= OPENAI_BASE_URL=https://api.openai.com/v1 +VIGNETTE_VOICE_POC_SAMPLE_TTS=false +VIGNETTE_VOICE_POC_SAMPLE_TTS_DIR= # Auth/session. Local dev may enable dev-login; public/prod must not. SESSION_SECRET=dev-insecure-change-me @@ -22,12 +24,14 @@ AUTH_ALLOWED_EMAIL_DOMAINS=["hs.ac.kr","twentyoz.kr"] AUTH_TEACHER_EMAILS=[] AUTH_ADMIN_EMAILS=[] AUTH_DEV_LOGIN_ENABLED=true +AUTH_DEV_LOGIN_EXTRA_ORIGINS=[] DEFAULT_AFFILIATION= AUTO_SEED_PERSONAS=false ALLOW_SEED_PERSONA_FALLBACK=false EVALUATOR_GOLDEN_FEWSHOT_ENABLED=false FRONTEND_BASE_URL=http://localhost:5173 -CORS_ORIGINS=["https://vignette.chanpaca.net","https://vignette-b1q.pages.dev","http://localhost:5170","http://localhost:5171","http://localhost:5172","http://localhost:5173","http://localhost:5174","http://localhost:5175","http://localhost:5176","http://localhost:5177","http://localhost:5178","http://localhost:5179","http://localhost:5180","http://127.0.0.1:5170","http://127.0.0.1:5171","http://127.0.0.1:5172","http://127.0.0.1:5173","http://127.0.0.1:5174","http://127.0.0.1:5175","http://127.0.0.1:5176","http://127.0.0.1:5177","http://127.0.0.1:5178","http://127.0.0.1:5179","http://127.0.0.1:5180"] +FRONTEND_ORIGIN_MAP={"api-vignette.chanpaca.net":"https://vignette.chanpaca.net","api-vnet.18ka.net":"https://vnet.18ka.net"} +CORS_ORIGINS=["https://vignette.chanpaca.net","https://vnet.18ka.net","https://vignette-b1q.pages.dev","http://localhost:5170","http://localhost:5171","http://localhost:5172","http://localhost:5173","http://localhost:5174","http://localhost:5175","http://localhost:5176","http://localhost:5177","http://localhost:5178","http://localhost:5179","http://localhost:5180","http://127.0.0.1:5170","http://127.0.0.1:5171","http://127.0.0.1:5172","http://127.0.0.1:5173","http://127.0.0.1:5174","http://127.0.0.1:5175","http://127.0.0.1:5176","http://127.0.0.1:5177","http://127.0.0.1:5178","http://127.0.0.1:5179","http://127.0.0.1:5180"] # Live2D runtime. Models must be configured per persona via live2dModelUrl. VITE_LIVE2D_CUBISM_CORE=/live2d/live2dcubismcore.min.js diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0135ee9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,101 @@ +# AGENTS.md — Vignette 프로젝트 작업 지침 + +> Vignette = AI 심리상담 시뮬레이션 훈련 플랫폼 (한신대 산학협력). +> 모노레포: `apps/api`(FastAPI/Python), `apps/web`(React 19/Vite/Playwright), `docs`, `infra`, `scripts`. + +## 📚 문서 맵 — 작업 전 해당 가이드를 먼저 읽어라 + +| 목적 | 문서 | +|---|---| +| 저장소 개요·빠른 시작 | [`README.md`](./README.md) | +| **로컬 서버 띄우기·테스트** | [`docs/guides/local-development.md`](./docs/guides/local-development.md) | +| 시스템 아키텍처·데이터 흐름 | [`docs/guides/architecture.md`](./docs/guides/architecture.md) | +| 테스트·검증 실행 | [`docs/guides/testing.md`](./docs/guides/testing.md) | +| 원천문서·갭 로드맵 | [`docs/guides/source-docs-and-gaps.md`](./docs/guides/source-docs-and-gaps.md) | +| **SSOT 상태판** | [`docs/dev_dashboard.html`](./docs/dev_dashboard.html) | +| 백로그 | [`docs/ops/backlog-2026-06-26.md`](./docs/ops/backlog-2026-06-26.md) | +| **서연 아바타 핸드오프** | [`docs/ops/handoff-avatar-seoyeon-2026-06-27.md`](./docs/ops/handoff-avatar-seoyeon-2026-06-27.md) | + +작업 결과로 동작/구조가 바뀌면 해당 가이드와 SSOT 대시보드를 함께 갱신한다. + +--- + +## ⚠️ 0. 무조건 OS를 먼저 파악하고 시작한다 (최우선·필수) + +**어떤 작업이든 명령을 실행하기 전에 OS와 셸을 먼저 확정하라.** 이걸 건너뛰면 +경로/인코딩/도구 차이로 시간을 크게 낭비한다(실제로 그랬다). + +작업 시작 시 반드시 확인할 것: + +1. **OS / 셸**: 이 저장소의 주 개발 환경은 **Windows 11 + PowerShell**이다. + POSIX를 가정하지 마라. Bash 도구도 쓸 수 있으나 셸마다 문법이 다르다. +2. **경로 규칙**: Windows 절대경로(`D:\...`, `C:\...`). 한글·공백 포함 경로가 흔하다 + (예: OneDrive `문서\카카오톡 받은 파일`). `-LiteralPath`로 다루고, 외부 도구에 + 넘기기 전에 **ASCII 이름으로 로컬 복사**해 인코딩/공백 문제를 차단하라. +3. **PowerShell 판(5.1) 주의**: 인라인 `if(){}else{}`를 식으로 못 쓴다(삼항 없음). + 네이티브 exe stderr를 `2>&1`로 합치지 마라(ErrorRecord로 감싸짐). + 기본 출력 인코딩은 UTF-16 — 다른 도구가 읽을 파일은 `-Encoding utf8`. +4. **외부 CLI는 실제로 블로킹되는지 확인**: GUI 런처(`soffice.exe` 등)는 즉시 + detach되어 `Start-Process -Wait`가 변환을 안 기다린다. 실제 작업 프로세스 + (`soffice.bin`)를 직접 호출하라. 좀비 프로세스가 락을 잡으면 정리부터 한다. +5. **도구 가용성 먼저 탐지**: 변환/처리 전에 LibreOffice·pandoc·python 라이브러리· + Playwright 브라우저 등 무엇이 설치돼 있는지 먼저 확인하고 경로를 잡아라. + +> 한 줄 요약: **"먼저 OS·셸·경로·도구를 확정한 뒤 실행한다."** 추정 금지. + +--- + +## 1. 운영 원칙 + +- **가짜 증거로 DONE 표기 금지.** 실증/외부 의존/소유자 결정이 필요한 항목은 + `docs/ops/backlog-*.md`에 분류해 추적한다(B1 코스메틱 · B2 환경제약 · B3 소유자결정 · B4 외부거버넌스). +- **`docs/dev_dashboard.html`이 SSOT(단일 진실 공급원)다.** 상태·검증 증거·결정 필요·로드맵의 권위 기준이며, 새 발견·작업 결과·상태 변경은 별도 문서로만 남기지 말고 대시보드에 반영/동기화한다. 백로그(`docs/ops/backlog-*.md`)는 대시보드와 일치시킨다(어긋나면 대시보드 기준). +- 소유자(윤찬) 단독 결정 사안을 임의로 정하지 않는다(월권 금지). + +## 2. 검증 기준 (프론트 변경 시) + +- `cd apps/web && npm run typecheck` +- 레이아웃 변경은 `e2e/layout-visual-gate.spec.ts`(7/7) + 레이아웃 포커스 E2E + + `e2e/session-layout.spec.ts`(8/8) 무회귀. E2E는 web+api(+DB) 스택이 떠 있어야 한다. + +## 3. 커뮤니케이션 + +- 모든 대화·주석·커밋 메시지는 한글. +- git 커밋 메시지에 Co-Authored-By / Codex 관련 문구 추가 금지. + +--- + +## 4. 이미지 생성(gpt-image-2 = imagegen2) · Live2D식 아바타 + +> "이미지 생성해/만들어/그려줘" 요청 → `~/.Codex/skills/codex-image` 스킬이 아래 래퍼를 자동 사용. + +### 4.1 gpt-image-2 호출 (반드시 래퍼) +```bash +bash ~/.codex/imagegen-headless/codex_imagegen.sh \ + --out <경로.png> [--size WxH] [--quality low|medium|high|auto] \ + [-i <참조이미지> ...] [--all] "<프롬프트>" +``` +- 인증: ChatGPT 구독 OAuth(`~/.codex/auth.json`의 `auth_mode=="chatgpt"`). **API 키 사용 금지**(과금). +- codex 0.140+는 생성 이미지를 세션 rollout JSONL에 **base64로 인라인 반환** → 래퍼의 `extract_imagegen.py` 추출만 결정적. stdout의 "저장 경로"는 **환각**(직접 codex exec 금지). +- 프롬프트는 **stdin 파이프**로(인자 전달 시 멈춤). 변주 생성 시 base를 `-i` 참조로 넘겨 아이덴티티·프레이밍 고정. +- 투명배경 미지원 → 단색 평면 배경으로 생성 후 누끼. 한글 텍스트 렌더 가능(stdin 파이프라 인코딩 문제 없음). + +### 4.2 누끼(컷아웃) +- `object-separation` 스킬(BiRefNet): `~/.venvs/object-separation/Scripts/python.exe ~/.agents/skills/object-separation/scripts/separate_object.py --model birefnet-general` +- 알파 정제(잔류 헤이즈 제거): 임계치 `<35→0, >205→255` + 페더(`docs/avatar-art/seoyeon/publish.py` 참조). + +### 4.3 Live2D식 아바타(파츠 분리 리깅) +아바타는 기본 **SVG 파라미터 리그** 또는 **래스터 파츠 분리 리깅**(`apps/web/src/components/avatar/RasterBust.tsx`)으로 렌더. 후자는 `persona.rasterArtSet` 지정 시 활성. +- 레이어(각각 독립 opacity/교체 → 표정 중에도 깜빡임·입술싱크가 따로 움직임): + `base(neutral 전신)` + `upperface-<표정>(눈썹+눈)` + `eyelid-closed(깜빡임, 표정 무관)` + `mouth-<표정>` + `mouth-open(립싱크)`. +- 파이프라인(재현 스크립트는 `docs/avatar-art/seoyeon/`): + 1. `codex_imagegen.sh`로 base + 표정 변주(sad/tired/anxious/warm/startled/eyes-closed/speaking) 생성. 변주는 base를 `-i` 참조로, 동일 평면 배경. + 2. BiRefNet 누끼 → `publish.py`(표준 캔버스 900×1125 정규화 + 알파 정제)로 `apps/web/public/avatar//` 게시. + 3. `make-parts.py`로 특징 영역(upperface/eyelid/mouth) 크롭+페더 파츠를 `parts/` 생성(영역 상수 튜너 블럭). +- 연결: `persona.ts`의 `AvatarPersona.rasterArtSet` / `Session.tsx`의 `PERSONA_AVATAR_LOOKS[].rasterArtSet` / `RasterBust.tsx`(28표정→클러스터 매핑 포함). +- dev 미리보기(인증 없음): `/dev/avatar-preview`(`AvatarPreview.tsx`). 스크린샷: `node apps/web/scripts/avatar-shot.mjs`(BASE_URL 환경변수로 포트 지정). +- 실제 Live2D Cubism(`.moc3`)은 편집기 저작이 필요해 자동화 불가 → 위 레이어 합성이 실용적 대안. + +### 4.4 진행 중인 아바타 작업 핸드오프 +서연(P1) 아바타 작업은 **별도 세션에서 진행**. 현재 상태·남은 작업(Image #2=짧은 보브 기준 재생성 등)은 +[`docs/ops/handoff-avatar-seoyeon-2026-06-27.md`](./docs/ops/handoff-avatar-seoyeon-2026-06-27.md) 참조. diff --git a/apps/api/app/config.py b/apps/api/app/config.py index 51b2ba4..faece1d 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -40,6 +40,10 @@ def _is_allowed_local_dev_cors_origin(value: str) -> bool: ) +def _is_local_or_forbidden_non_dev_origin(value: str) -> bool: + return _is_local_url(value) and not _is_allowed_local_dev_cors_origin(value) + + class Settings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", @@ -84,6 +88,14 @@ class Settings(BaseSettings): default="https://api.openai.com/v1", validation_alias="OPENAI_BASE_URL", ) + voice_poc_sample_tts_enabled: bool = Field( + default=False, + validation_alias="VIGNETTE_VOICE_POC_SAMPLE_TTS", + ) + voice_poc_sample_tts_dir: str = Field( + default="", + validation_alias="VIGNETTE_VOICE_POC_SAMPLE_TTS_DIR", + ) # ── 세션/인증 (BFF OAuth 2.1, 토큰 서버 보관) ──────── session_secret: str = Field( @@ -143,15 +155,27 @@ class Settings(BaseSettings): default="http://localhost:5173", validation_alias="FRONTEND_BASE_URL", ) + frontend_origin_map: dict[str, str] = Field( + default_factory=lambda: { + "api-vignette.chanpaca.net": "https://vignette.chanpaca.net", + "api-vnet.18ka.net": "https://vnet.18ka.net", + }, + validation_alias="FRONTEND_ORIGIN_MAP", + ) # ── CORS (정적 프론트 + SSE 분리경로) ──────────────── cors_origins: list[str] = Field( default=[ "https://vignette.chanpaca.net", + "https://vnet.18ka.net", "https://vignette-b1q.pages.dev", ], validation_alias="CORS_ORIGINS", ) + auth_dev_login_extra_origins: list[str] = Field( + default=[], + validation_alias="AUTH_DEV_LOGIN_EXTRA_ORIGINS", + ) # Built-in personas are developer/bootstrap fixtures, not runtime truth. # Production should use approved rows from app.persona_card only. @@ -181,6 +205,8 @@ class Settings(BaseSettings): forbidden.append("AUTO_SEED_PERSONAS") if self.allow_seed_persona_fallback: forbidden.append("ALLOW_SEED_PERSONA_FALLBACK") + if self.voice_poc_sample_tts_enabled: + forbidden.append("VIGNETTE_VOICE_POC_SAMPLE_TTS") if not self.oauth_google_client_id.strip(): forbidden.append("OAUTH_GOOGLE_CLIENT_ID") if not self.oauth_google_client_secret.strip(): @@ -189,11 +215,13 @@ class Settings(BaseSettings): forbidden.append("SESSION_SECRET") if _is_local_url(self.frontend_base_url): forbidden.append("FRONTEND_BASE_URL") - if any( - _is_local_url(origin) and not _is_allowed_local_dev_cors_origin(origin) - for origin in self.cors_origins - ): + if any(_is_local_or_forbidden_non_dev_origin(origin) for origin in self.cors_origins): forbidden.append("CORS_ORIGINS") + if any( + _is_local_or_forbidden_non_dev_origin(origin) + for origin in self.frontend_origin_map.values() + ): + forbidden.append("FRONTEND_ORIGIN_MAP") if forbidden: joined = ", ".join(forbidden) raise ValueError(f"{joined} must be production-safe when ENVIRONMENT={self.environment}") diff --git a/apps/api/app/routes/auth.py b/apps/api/app/routes/auth.py index 5e9dc44..b79ec2c 100644 --- a/apps/api/app/routes/auth.py +++ b/apps/api/app/routes/auth.py @@ -221,9 +221,40 @@ def _is_local_origin(origin: str) -> bool: return host in {"localhost", "127.0.0.1", "::1"} +def _frontend_origin_map() -> dict[str, str]: + mapped: dict[str, str] = {} + for api_host, frontend_origin in settings.frontend_origin_map.items(): + host = (api_host or "").strip().lower().rstrip(".") + origin = _url_origin(frontend_origin) + if host and origin: + mapped[host] = origin + return mapped + + +def _dev_login_extra_origins() -> set[str]: + return { + origin + for value in settings.auth_dev_login_extra_origins + if (origin := _url_origin(value)) + } + + +def _dev_login_extra_hosts() -> set[str]: + return { + host + for origin in _dev_login_extra_origins() + if (host := (urlsplit(origin).hostname or "").lower()) + } + + +def _is_dev_login_allowed_origin(origin: str) -> bool: + host = (urlsplit(origin).hostname or "").lower() + return _is_local_origin(origin) or origin in _dev_login_extra_origins() or host in _dev_login_extra_hosts() + + def _configured_frontend_origins() -> list[str]: origins: list[str] = [] - for value in [settings.frontend_base_url, *settings.cors_origins]: + for value in [settings.frontend_base_url, *settings.frontend_origin_map.values(), *settings.cors_origins]: origin = _url_origin(value) if origin and origin not in origins: origins.append(origin) @@ -243,8 +274,8 @@ def _frontend_origin_for_request(request: Request | None = None) -> str: forwarded_host = request.headers.get("x-forwarded-host") host = (forwarded_host or request.headers.get("host") or "").split(",", 1)[0].strip() hostname = host.rsplit(":", 1)[0].lower() if host else "" - if hostname == "api-vignette.chanpaca.net": - return "https://vignette.chanpaca.net" + if mapped_origin := _frontend_origin_map().get(hostname): + return mapped_origin if hostname in {"localhost", "127.0.0.1", "::1"}: return fallback @@ -348,15 +379,24 @@ def _dev_login_available(request: Request) -> bool: if settings.environment != "dev" or not settings.auth_dev_login_enabled: return False + if _dev_login_extra_origins(): + return True + + saw_browser_origin = False for header_name in ("origin", "referer"): origin = _url_origin(request.headers.get(header_name)) - if origin and not _is_local_origin(origin): - return False + if origin: + saw_browser_origin = True + if not _is_dev_login_allowed_origin(origin): + return False + + if not saw_browser_origin and _dev_login_extra_origins(): + return True forwarded_host = request.headers.get("x-forwarded-host") host = (forwarded_host or request.headers.get("host") or "").split(",", 1)[0].strip() origin = _url_origin(f"http://{host}") if host else None - return bool(origin and _is_local_origin(origin)) + return bool(origin and _is_dev_login_allowed_origin(origin)) @router.get("/config", response_model=AuthConfigResponse) diff --git a/apps/api/app/routes/sessions.py b/apps/api/app/routes/sessions.py index 5ab7949..d301a15 100644 --- a/apps/api/app/routes/sessions.py +++ b/apps/api/app/routes/sessions.py @@ -381,8 +381,14 @@ async def _load_session_or_404( principal: Principal, *, allow_ended: bool = False, + include_turn_evaluation: bool = False, ) -> InProcSession: - sess = await session_persistence.load_session(session_id, principal, allow_ended=True) + sess = await session_persistence.load_session( + session_id, + principal, + allow_ended=True, + include_turn_evaluation=include_turn_evaluation, + ) if sess is not None: store.put(sess) elif runtime_fallback_allowed(): @@ -874,7 +880,11 @@ async def get_session_detail( ) -> SessionDetailResponse: """Return a learner-owned session with transcript for resume/history.""" _ensure_learner(principal) - sess = await _load_session_or_404(session_id, principal, allow_ended=True) + sess = await _load_session_or_404( + session_id, + principal, + allow_ended=True, + ) return _session_detail(sess, review_ready=await _review_ready(sess, principal)) @@ -951,7 +961,12 @@ async def get_session_review( ) -> SessionReviewResponse: """Return a learner-safe review built only from the stored session transcript.""" _ensure_learner(principal) - sess = await _load_session_or_404(session_id, principal, allow_ended=True) + sess = await _load_session_or_404( + session_id, + principal, + allow_ended=True, + include_turn_evaluation=True, + ) visible_turns = _learner_visible_turns(sess) hidden_turns = len(visible_turns) != len(sess.turns) diff --git a/apps/api/app/routes/voice.py b/apps/api/app/routes/voice.py index 94ec38e..90903f1 100644 --- a/apps/api/app/routes/voice.py +++ b/apps/api/app/routes/voice.py @@ -49,11 +49,13 @@ _MAX_AUDIO_BYTES = 10 * 1024 * 1024 async def voice_health() -> JSONResponse: """Return voice service readiness.""" available = voice_service.is_available() + tts_provider = voice_service.tts_provider() body = { "status": "ok" if available else "degraded", "available": available, "stt_model": voice_svc.STT_MODEL, "tts_model": voice_svc.TTS_MODEL, + "tts_provider": tts_provider, "reason": None if available else "OPENAI_API_KEY is not configured", } return JSONResponse(body, status_code=200 if available else 503) @@ -108,6 +110,7 @@ async def voice_ws(websocket: WebSocket) -> None: "session_id": session_id, "voice": voice_preset.openai_voice, "preset": voice_preset.preset, + "tts_provider": voice_service.tts_provider(), "state": "idle", **bind_meta, }, @@ -374,7 +377,12 @@ async def _run_turn_and_speak( # TTS speaking state comes before chunk metadata and binary audio. await _safe_send_json( websocket, - {"type": "state", "state": "speaking", "voice": voice_preset.openai_voice}, + { + "type": "state", + "state": "speaking", + "voice": voice_preset.openai_voice, + "tts_provider": voice_service.tts_provider(), + }, ) try: n = 0 diff --git a/apps/api/app/services/voice.py b/apps/api/app/services/voice.py index bbb1f47..10c2696 100644 --- a/apps/api/app/services/voice.py +++ b/apps/api/app/services/voice.py @@ -20,6 +20,7 @@ from __future__ import annotations import re from dataclasses import dataclass +from pathlib import Path from typing import AsyncIterator, Optional import httpx @@ -49,6 +50,32 @@ TTS_RESPONSE_FORMAT = "mp3" # End-of-turn readiness default for cascaded STT providers. EOT_SILENCE_THRESHOLD_MS = 1200 +_REPO_ROOT = Path(__file__).resolve().parents[4] +POC_SAMPLE_TTS_PRESET = "soft-young-fem" +POC_SAMPLE_TTS_DEFAULT_DIR = ( + _REPO_ROOT / "docs" / "voice-art" / "p1-seoyeon-higgs-v3-20260627" +) +POC_SAMPLE_TTS_CHUNK_SIZE = 4096 +_POC_SAMPLE_TTS_DEFAULT_SAMPLE = "p1_seoyeon_01_depressed_slow" +_POC_SAMPLE_TTS_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ( + "p1_seoyeon_03_anxious_guarded", + ("엄마", "비밀", "말하지", "불안", "무서", "걱정", "들키", "싫", "갈래"), + ), + ( + "p1_seoyeon_02_tired_flat", + ("잠", "피곤", "무거", "아무것도", "지쳐", "힘들", "에너지"), + ), + ( + "p1_seoyeon_05_recovered_lively", + ("오늘은", "친구", "웃", "괜찮았", "좋았", "해냈"), + ), + ( + "p1_seoyeon_04_rapport_relief", + ("괜찮", "들어", "고마", "선생님", "편해", "조금", "말해"), + ), +) + # OpenAI 공식 voice 풀(2026 기준): alloy, ash, ballad, coral, echo, fable, # nova, onyx, sage, shimmer, verse. 페르소나 톤별로 골라 매핑한다. _OPENAI_VOICES = { @@ -240,9 +267,32 @@ def assess_end_of_turn( class VoiceService: """OpenAI STT/TTS 어댑터. 앱 수명주기 동안 1 인스턴스 재사용(httpx 풀 공유).""" - def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None) -> None: + def __init__( + self, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + *, + poc_sample_tts_enabled: Optional[bool] = None, + environment: Optional[str] = None, + poc_sample_tts_dir: Optional[str | Path] = None, + ) -> None: self._api_key = (api_key if api_key is not None else settings.openai_api_key) or "" self._base_url = (base_url or settings.openai_base_url or OPENAI_BASE_URL).rstrip("/") + self._environment = environment if environment is not None else settings.environment + self._poc_sample_tts_enabled = ( + bool(settings.voice_poc_sample_tts_enabled) + if poc_sample_tts_enabled is None + else bool(poc_sample_tts_enabled) + ) + sample_dir_value: str | Path = ( + poc_sample_tts_dir + if poc_sample_tts_dir is not None + else (settings.voice_poc_sample_tts_dir or POC_SAMPLE_TTS_DEFAULT_DIR) + ) + sample_dir = Path(sample_dir_value) + if not sample_dir.is_absolute(): + sample_dir = _REPO_ROOT / sample_dir + self._poc_sample_tts_dir = sample_dir self._client: Optional[httpx.AsyncClient] = None # ── 수명주기 ────────────────────────────────────────── @@ -264,6 +314,35 @@ class VoiceService: """음성 기능 가용 여부(키 설정됨). 라우트가 핸드셰이크에서 검사.""" return bool(self._api_key) + def tts_provider(self) -> str: + if self._poc_sample_tts_available(): + return "p1-sample-poc" + if self._api_key: + return "openai" + if self._poc_sample_tts_enabled and self._environment != "dev": + return "disabled-non-dev" + return "unavailable" + + def poc_sample_tts_available(self) -> bool: + return self._poc_sample_tts_available() + + def _poc_sample_tts_available(self) -> bool: + return ( + self._poc_sample_tts_enabled + and self._environment == "dev" + and self._poc_sample_path(_POC_SAMPLE_TTS_DEFAULT_SAMPLE).is_file() + ) + + def _should_use_poc_sample_tts(self, voice: VoicePreset) -> bool: + return ( + self._poc_sample_tts_enabled + and self._environment == "dev" + and voice.preset == POC_SAMPLE_TTS_PRESET + ) + + def _poc_sample_path(self, sample_id: str) -> Path: + return self._poc_sample_tts_dir / f"{sample_id}.mp3" + @property def _http(self) -> httpx.AsyncClient: if not self._api_key: @@ -341,6 +420,10 @@ class VoiceService: text = speakable_text(text) if not text: return + if self._should_use_poc_sample_tts(voice): + async for chunk in self._synthesize_poc_sample_tts(text): + yield chunk + return payload = build_tts_payload( text, voice, @@ -375,6 +458,25 @@ class VoiceService: except httpx.HTTPError as e: raise RuntimeError(f"TTS transport error: {e}") from e + async def _synthesize_poc_sample_tts(self, text: str) -> AsyncIterator[TTSChunk]: + sample_id = self._select_poc_sample_id(text) + sample_path = self._poc_sample_path(sample_id) + try: + data = sample_path.read_bytes() + except OSError as e: + raise VoiceUnavailable(f"P1 sample TTS asset is missing: {sample_path}") from e + for i in range(0, len(data), POC_SAMPLE_TTS_CHUNK_SIZE): + chunk = data[i : i + POC_SAMPLE_TTS_CHUNK_SIZE] + if chunk: + yield TTSChunk(audio=chunk) + + def _select_poc_sample_id(self, text: str) -> str: + normalized = text.casefold() + for sample_id, keywords in _POC_SAMPLE_TTS_KEYWORDS: + if any(keyword.casefold() in normalized for keyword in keywords): + return sample_id + return _POC_SAMPLE_TTS_DEFAULT_SAMPLE + async def _synthesize_fallback(self, payload: dict[str, object]) -> AsyncIterator[TTSChunk]: """tts-1 폴백(비스트림 POST → 전체 바이트를 청크로 분할).""" try: diff --git a/apps/api/app/session_persistence.py b/apps/api/app/session_persistence.py index 06f41f1..775ed51 100644 --- a/apps/api/app/session_persistence.py +++ b/apps/api/app/session_persistence.py @@ -18,6 +18,11 @@ from .store import DEFAULT_TURN_VISIBLE_TO, InProcSession, TurnRecord _EVALUATION_CACHE: dict[str, dict[str, Any]] = {} _SESSION_AUDIT_ROLES = {"teacher", "admin"} +_APPROPRIATENESS_SCORE = { + "warn": 1.0, + "neutral": 3.0, + "pos": 5.0, +} _JOINED_CARD_COLUMNS = ( @@ -71,6 +76,248 @@ def _stage(stage: object) -> str: return getattr(stage, "value", str(stage)) +def _clean_text(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip() + return text or None + + +def _safe_float(value: Any) -> float | None: + if isinstance(value, (int, float)): + return float(value) + return None + + +def _evaluation_loop(evaluation: dict[str, Any]) -> str: + loop = _clean_text(evaluation.get("loop")) or "fast" + return loop if loop in {"fast", "deep"} else "fast" + + +def _dict_items(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, dict)] + + +def _evaluation_feedback_rows(evaluation: dict[str, Any] | None) -> list[dict[str, Any]]: + """Normalize scalar/rationale turn-evaluation fields into feedback_scores rows.""" + if not isinstance(evaluation, dict): + return [] + loop = _evaluation_loop(evaluation) + rows: list[dict[str, Any]] = [] + + def add( + dimension: str, + *, + score: float | None = None, + rationale: str | None = None, + top1_score: float | None = None, + ) -> None: + dim = _clean_text(dimension) + if not dim: + return + rows.append( + { + "dimension": dim, + "score": score, + "rationale": rationale, + "top1_score": top1_score, + "loop": loop, + } + ) + + appropriateness = _clean_text(evaluation.get("appropriateness")) or "neutral" + if appropriateness not in _APPROPRIATENESS_SCORE: + appropriateness = "neutral" + add( + "appropriateness", + score=_APPROPRIATENESS_SCORE[appropriateness], + rationale=_clean_text(evaluation.get("appropriateness_note")), + ) + + rapport = _safe_float(evaluation.get("rapport_signal")) + if rapport is not None: + add("rapport_signal", score=max(-1.0, min(1.0, rapport))) + + theory_mode = _clean_text(evaluation.get("theory_mode")) + if theory_mode: + add("theory_mode", rationale=theory_mode) + + error = _clean_text(evaluation.get("error")) + if error: + add("error", rationale=error) + + for tag in _dict_items(evaluation.get("techniques")): + code = _clean_text(tag.get("code")) + rationale = _clean_text(tag.get("rationale")) + if code and rationale: + add(f"technique:{code}", rationale=rationale) + + for state in _dict_items(evaluation.get("client_state_read")): + code = _clean_text(state.get("code")) + rationale = _clean_text(state.get("rationale")) + if code and rationale: + add(f"client_state:{code}", rationale=rationale) + + return rows + + +def _evaluation_technique_rows(evaluation: dict[str, Any] | None) -> list[dict[str, str]]: + if not isinstance(evaluation, dict): + return [] + rows: list[dict[str, str]] = [] + for tag in _dict_items(evaluation.get("techniques")): + code = _clean_text(tag.get("code")) + if not code: + continue + rows.append( + { + "code": code, + "label_ko": _clean_text(tag.get("label_ko")) or code, + "category": _clean_text(tag.get("category")) or "", + } + ) + return rows + + +def _evaluation_client_state_rows(evaluation: dict[str, Any] | None) -> list[dict[str, str]]: + if not isinstance(evaluation, dict): + return [] + rows: list[dict[str, str]] = [] + for state in _dict_items(evaluation.get("client_state_read")): + code = _clean_text(state.get("code")) + if not code: + continue + rows.append( + { + "code": code, + "label_ko": _clean_text(state.get("label_ko")) or code, + } + ) + return rows + + +def _evaluation_comment_rows(evaluation: dict[str, Any] | None) -> list[dict[str, Any]]: + if not isinstance(evaluation, dict): + return [] + deviation = evaluation.get("intent_deviation") + if not isinstance(deviation, dict): + return [] + note = _clean_text(evaluation.get("appropriateness_note")) or "의도와 다른 부분" + return [{"kind": "critique", "text": note, "intent_deviation": deviation}] + + +def _appropriateness_from_score(score: Any) -> str: + value = _safe_float(score) + if value is None: + return "neutral" + if value >= 4.0: + return "pos" + if value <= 2.0: + return "warn" + return "neutral" + + +def _base_turn_evaluation(turn_seq: int, stage: str) -> dict[str, Any]: + return { + "loop": "fast", + "turn_seq": turn_seq, + "stage": stage, + "techniques": [], + "client_state_read": [], + "appropriateness": "neutral", + } + + +def _rebuild_turn_evaluations( + turn_refs: list[tuple[str, int, str]], + *, + feedback_rows: Iterable[Any], + technique_rows: Iterable[Any], + client_state_rows: Iterable[Any], + comment_rows: Iterable[Any], +) -> dict[str, dict[str, Any]]: + """Rehydrate normalized DB rows back into the TurnRecord.evaluation shape.""" + refs = {turn_id: (turn_seq, stage) for turn_id, turn_seq, stage in turn_refs} + evaluations: dict[str, dict[str, Any]] = {} + rationale_by_dimension: dict[str, dict[str, str]] = {} + + def ensure(turn_id: str) -> dict[str, Any]: + if turn_id not in evaluations: + turn_seq, stage = refs[turn_id] + evaluations[turn_id] = _base_turn_evaluation(turn_seq, stage) + return evaluations[turn_id] + + for row in feedback_rows: + turn_id = str(row["turn_id"]) + if turn_id not in refs: + continue + ev = ensure(turn_id) + loop = _clean_text(row["loop"]) + if loop in {"fast", "deep"}: + ev["loop"] = loop + dimension = _clean_text(row["dimension"]) or "" + rationale = _clean_text(row["rationale"]) + if rationale: + rationale_by_dimension.setdefault(turn_id, {})[dimension] = rationale + if dimension == "appropriateness": + ev["appropriateness"] = _appropriateness_from_score(row["score"]) + if rationale: + ev["appropriateness_note"] = rationale + elif dimension == "rapport_signal": + score = _safe_float(row["score"]) + if score is not None: + ev["rapport_signal"] = max(-1.0, min(1.0, score)) + elif dimension == "theory_mode" and rationale: + ev["theory_mode"] = rationale + elif dimension == "error" and rationale: + ev["error"] = rationale + + for row in technique_rows: + turn_id = str(row["turn_id"]) + if turn_id not in refs: + continue + code = _clean_text(row["code"]) + if not code: + continue + item = { + "code": code, + "label_ko": _clean_text(row["label_ko"]) or code, + "category": _clean_text(row["category"]) or "", + } + rationale = rationale_by_dimension.get(turn_id, {}).get(f"technique:{code}") + if rationale: + item["rationale"] = rationale + ensure(turn_id)["techniques"].append(item) + + for row in client_state_rows: + turn_id = str(row["turn_id"]) + if turn_id not in refs: + continue + code = _clean_text(row["code"]) + if not code: + continue + item = { + "code": code, + "label_ko": _clean_text(row["label_ko"]) or code, + } + rationale = rationale_by_dimension.get(turn_id, {}).get(f"client_state:{code}") + if rationale: + item["rationale"] = rationale + ensure(turn_id)["client_state_read"].append(item) + + for row in comment_rows: + turn_id = str(row["turn_id"]) + if turn_id not in refs: + continue + deviation = row["intent_deviation"] + if isinstance(deviation, dict): + ensure(turn_id)["intent_deviation"] = deviation + + return evaluations + + async def _record_session_read_audit( conn: Any, principal: Principal, @@ -113,7 +360,7 @@ def _state_from_row(row, card: PersonaCard) -> state_machine.SessionState: ) -def _turn_from_row(row) -> TurnRecord: +def _turn_from_row(row, evaluation: dict[str, Any] | None = None) -> TurnRecord: created_at = _ts(row["created_at"]) or time.time() return TurnRecord( turn_seq=int(row["seq"]), @@ -121,6 +368,7 @@ def _turn_from_row(row) -> TurnRecord: stage=row["stage"], text=row["text"] or row["text_masked"] or "", text_masked=row["text_masked"] or row["text"] or "", + turn_id=str(_row_value(row, "id")) if _row_value(row, "id") is not None else None, created_at=created_at, llm_provider=_row_value(row, "llm_provider"), model=_row_value(row, "model"), @@ -131,10 +379,179 @@ def _turn_from_row(row) -> TurnRecord: silence_ms=_row_value(row, "silence_ms"), speech_rate=_row_value(row, "speech_rate"), barge_in=_row_value(row, "barge_in"), + evaluation=evaluation, visible_to=tuple(_row_value(row, "visible_to") or DEFAULT_TURN_VISIBLE_TO), ) +async def _persist_turn_evaluation(conn: Any, turn_id: str, evaluation: dict[str, Any] | None) -> None: + if not isinstance(evaluation, dict): + return + await conn.execute("SELECT set_config('app.ai_context', '1', true)") + await conn.execute("SELECT set_config('app.current_ai_view', 'evaluator', true)") + await conn.execute("SELECT set_config('app.current_sens_max', '2', true)") + + for row in _evaluation_feedback_rows(evaluation): + await conn.execute( + """ + INSERT INTO app.feedback_scores ( + turn_id, dimension, score, rationale, top1_score, loop + ) + VALUES ($1::uuid, $2, $3, $4, $5, $6) + ON CONFLICT (turn_id, dimension) DO UPDATE SET + score = EXCLUDED.score, + rationale = EXCLUDED.rationale, + top1_score = EXCLUDED.top1_score, + loop = EXCLUDED.loop + """, + turn_id, + row["dimension"], + row["score"], + row["rationale"], + row["top1_score"], + row["loop"], + ) + + for row in _evaluation_technique_rows(evaluation): + label_id = await conn.fetchval( + """ + INSERT INTO app.technique_label_def (code, display_name, category) + VALUES ($1, $2, $3) + ON CONFLICT (code, version) DO UPDATE SET + display_name = EXCLUDED.display_name, + category = EXCLUDED.category, + is_active = TRUE + RETURNING label_id + """, + row["code"], + row["label_ko"], + row["category"], + ) + await conn.execute( + """ + INSERT INTO app.turn_technique (turn_id, label_id) + VALUES ($1::uuid, $2) + ON CONFLICT DO NOTHING + """, + turn_id, + label_id, + ) + + for row in _evaluation_client_state_rows(evaluation): + label_id = await conn.fetchval( + """ + INSERT INTO app.client_state_def (code, display_name) + VALUES ($1, $2) + ON CONFLICT (code, version) DO UPDATE SET + display_name = EXCLUDED.display_name, + is_active = TRUE + RETURNING label_id + """, + row["code"], + row["label_ko"], + ) + await conn.execute( + """ + INSERT INTO app.turn_client_state (turn_id, label_id) + VALUES ($1::uuid, $2) + ON CONFLICT DO NOTHING + """, + turn_id, + label_id, + ) + + for row in _evaluation_comment_rows(evaluation): + await conn.execute( + """ + INSERT INTO app.supervisor_comment ( + turn_id, kind, text, intent_deviation + ) + VALUES ($1::uuid, $2, $3, $4::jsonb) + """, + turn_id, + row["kind"], + row["text"], + row["intent_deviation"], + ) + + +async def _load_turn_evaluations( + conn: Any, + turn_refs: list[tuple[str, int, str]], +) -> dict[str, dict[str, Any]]: + turn_ids = [turn_id for turn_id, _, _ in turn_refs] + if not turn_ids: + return {} + feedback_rows = await conn.fetch( + """ + SELECT turn_id::text AS turn_id, dimension, score, rationale, top1_score, loop + FROM app.feedback_scores + WHERE turn_id = ANY($1::uuid[]) + ORDER BY created_at, dimension + """, + turn_ids, + ) + technique_rows = await conn.fetch( + """ + SELECT + tt.turn_id::text AS turn_id, + d.code, + d.display_name AS label_ko, + d.category + FROM app.turn_technique tt + JOIN app.technique_label_def d ON d.label_id = tt.label_id + WHERE tt.turn_id = ANY($1::uuid[]) + ORDER BY tt.turn_id, d.code + """, + turn_ids, + ) + client_state_rows = await conn.fetch( + """ + SELECT + ts.turn_id::text AS turn_id, + d.code, + d.display_name AS label_ko + FROM app.turn_client_state ts + JOIN app.client_state_def d ON d.label_id = ts.label_id + WHERE ts.turn_id = ANY($1::uuid[]) + ORDER BY ts.turn_id, d.code + """, + turn_ids, + ) + comment_rows = await conn.fetch( + """ + SELECT turn_id::text AS turn_id, intent_deviation + FROM app.supervisor_comment + WHERE turn_id = ANY($1::uuid[]) + AND intent_deviation IS NOT NULL + ORDER BY created_at + """, + turn_ids, + ) + return _rebuild_turn_evaluations( + turn_refs, + feedback_rows=feedback_rows, + technique_rows=technique_rows, + client_state_rows=client_state_rows, + comment_rows=comment_rows, + ) + + +async def _hydrate_session_turn_evaluations(sess: InProcSession) -> None: + turn_refs = [ + (turn.turn_id, turn.turn_seq, turn.stage) + for turn in sess.turns + if turn.turn_id is not None + ] + if not turn_refs: + return + async with acquire(ai_view="evaluator") as conn: + evaluations = await _load_turn_evaluations(conn, turn_refs) + for turn in sess.turns: + if turn.turn_id and turn.turn_id in evaluations: + turn.evaluation = evaluations[turn.turn_id] + + async def ensure_review_tables() -> None: """Create runtime review/evaluation storage when the DB role allows it.""" try: @@ -441,6 +858,7 @@ async def load_session( principal: Principal, *, allow_ended: bool = False, + include_turn_evaluation: bool = False, ) -> InProcSession | None: try: get_pool() @@ -496,7 +914,7 @@ async def load_session( ) turn_rows = await conn.fetch( """ - SELECT seq, speaker, stage, text, text_masked, created_at, + SELECT id, seq, speaker, stage, text, text_masked, created_at, llm_provider, model, tokens_in, tokens_out, cost_usd, audio_ref, silence_ms, speech_rate, barge_in, visible_to FROM app.turns @@ -518,7 +936,9 @@ async def load_session( "learner_id": sess.learner_id, }, ) - return sess + if sess is not None and include_turn_evaluation: + await _hydrate_session_turn_evaluations(sess) + return sess except Exception: require_runtime_fallback_allowed("session load") return None @@ -546,7 +966,7 @@ async def append_turn( ) or 1 ) - await conn.execute( + inserted_turn_id = await conn.fetchval( """ INSERT INTO app.turns ( session_id, seq, speaker, stage, text, text_masked, actor_kind, @@ -559,6 +979,7 @@ async def append_turn( $13, $14, $15, $16, $17::text[] ) ON CONFLICT (session_id, seq) DO NOTHING + RETURNING id """, session_id, seq, @@ -578,6 +999,11 @@ async def append_turn( turn.barge_in, list(turn.visible_to or DEFAULT_TURN_VISIBLE_TO), ) + if inserted_turn_id is None: + return False + turn.turn_id = str(inserted_turn_id) + # 원시 평가 row는 학습자 축어록이 아니라 evaluator 전용 데이터다. + await _persist_turn_evaluation(conn, turn.turn_id, turn.evaluation) return True except Exception: require_runtime_fallback_allowed("session turn append") @@ -703,7 +1129,7 @@ async def list_sessions(principal: Principal) -> tuple[list[InProcSession], bool ) turn_rows = await conn.fetch( """ - SELECT seq, speaker, stage, text, text_masked, created_at, + SELECT id, seq, speaker, stage, text, text_masked, created_at, llm_provider, model, tokens_in, tokens_out, cost_usd, audio_ref, silence_ms, speech_rate, barge_in, visible_to FROM app.turns diff --git a/apps/api/app/store.py b/apps/api/app/store.py index e4fe484..519658a 100644 --- a/apps/api/app/store.py +++ b/apps/api/app/store.py @@ -31,6 +31,7 @@ class TurnRecord: stage: str text: str # 원문(개발용; 실제 저장은 마스킹본) text_masked: str + turn_id: str | None = None # DB app.turns.id. 런타임 폴백 턴은 None. created_at: float = field(default_factory=time.time) llm_provider: str | None = None model: str | None = None diff --git a/apps/api/app/test_auth_providers.py b/apps/api/app/test_auth_providers.py index feea627..c90f8fe 100644 --- a/apps/api/app/test_auth_providers.py +++ b/apps/api/app/test_auth_providers.py @@ -29,13 +29,13 @@ def patched_settings(**values: Any): setattr(settings, key, value) -def _request() -> Request: +def _request(headers: list[tuple[bytes, bytes]] | None = None) -> Request: return Request( { "type": "http", "method": "GET", "path": "/auth/login", - "headers": [(b"host", b"localhost:8000")], + "headers": headers or [(b"host", b"localhost:8000")], } ) @@ -111,6 +111,64 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase): self.assertTrue(providers["saml"].enabled) self.assertEqual(providers["saml"].login_path, "/auth/login?provider=saml") + async def test_auth_config_allows_dev_login_from_configured_tailnet_origin(self) -> None: + request = _request( + [ + (b"host", b"127.0.0.1:8000"), + (b"origin", b"https://alpaca-home.taile93291.ts.net"), + ] + ) + + with patched_settings( + environment="dev", + auth_dev_login_enabled=True, + auth_dev_login_extra_origins=["https://alpaca-home.taile93291.ts.net"], + ): + config = await auth_routes.auth_config(request) + + self.assertTrue(config.dev_login_enabled) + + async def test_auth_config_allows_dev_login_from_configured_tailnet_forwarded_host(self) -> None: + request = _request( + [ + (b"host", b"127.0.0.1:8000"), + (b"x-forwarded-host", b"alpaca-home.taile93291.ts.net"), + ] + ) + + with patched_settings( + environment="dev", + auth_dev_login_enabled=True, + auth_dev_login_extra_origins=["https://alpaca-home.taile93291.ts.net"], + ): + config = await auth_routes.auth_config(request) + + self.assertTrue(config.dev_login_enabled) + + async def test_auth_config_allows_dev_login_config_probe_without_origin_when_extra_origin_is_set(self) -> None: + request = _request([(b"host", b"127.0.0.1:8000")]) + + with patched_settings( + environment="dev", + auth_dev_login_enabled=True, + auth_dev_login_extra_origins=["https://alpaca-home.taile93291.ts.net"], + ): + config = await auth_routes.auth_config(request) + + self.assertTrue(config.dev_login_enabled) + + async def test_frontend_origin_map_routes_vnet_api_callbacks_to_vnet_frontend(self) -> None: + request = _request([(b"host", b"api-vnet.18ka.net")]) + + with patched_settings( + frontend_base_url="https://vignette.chanpaca.net", + frontend_origin_map={"api-vnet.18ka.net": "https://vnet.18ka.net"}, + cors_origins=["https://vignette.chanpaca.net", "https://vnet.18ka.net"], + ): + origin = auth_routes._frontend_origin_for_request(request) + + self.assertEqual(origin, "https://vnet.18ka.net") + async def test_saml_login_builds_redirect_authn_request_and_relay_state(self) -> None: with patched_settings( auth_saml_enabled=True, diff --git a/apps/api/app/test_evaluation_persistence.py b/apps/api/app/test_evaluation_persistence.py new file mode 100644 index 0000000..7461d6a --- /dev/null +++ b/apps/api/app/test_evaluation_persistence.py @@ -0,0 +1,247 @@ +"""Regression tests for turn-evaluation persistence mapping.""" + +from __future__ import annotations + +from pathlib import Path +import unittest +from unittest.mock import patch + +from .deps import Principal, Role +from . import session_persistence +from .routes import sessions +from .services import persona as persona_service +from .services import state_machine +from .store import InProcSession + + +class FakeEvaluationConn: + def __init__(self) -> None: + self.executed: list[tuple[str, tuple[object, ...]]] = [] + self.fetchvals: list[tuple[str, tuple[object, ...]]] = [] + + async def execute(self, query: str, *args: object) -> str: + self.executed.append((query, args)) + return "INSERT 0 1" + + async def fetchval(self, query: str, *args: object) -> int: + self.fetchvals.append((query, args)) + if "app.technique_label_def" in query: + return 101 + if "app.client_state_def" in query: + return 202 + raise AssertionError(f"unexpected fetchval query: {query}") + + +class EvaluationPersistenceMappingTest(unittest.TestCase): + def test_feedback_rows_preserve_review_scalar_contract(self) -> None: + evaluation = { + "loop": "fast", + "turn_seq": 2, + "stage": "탐색", + "appropriateness": "pos", + "appropriateness_note": "정서를 먼저 반영했다.", + "rapport_signal": 0.75, + "theory_mode": "humanistic", + "techniques": [ + { + "code": "empathy", + "label_ko": "공감", + "category": "relational", + "rationale": "감정을 명시적으로 반영했다.", + } + ], + "client_state_read": [ + { + "code": "affect_contact", + "label_ko": "정서 접촉/표현", + "rationale": "내담자가 감정을 언급했다.", + } + ], + } + + rows = { + row["dimension"]: row + for row in session_persistence._evaluation_feedback_rows(evaluation) + } + + self.assertEqual(rows["appropriateness"]["score"], 5.0) + self.assertEqual(rows["appropriateness"]["rationale"], "정서를 먼저 반영했다.") + self.assertEqual(rows["rapport_signal"]["score"], 0.75) + self.assertEqual(rows["theory_mode"]["rationale"], "humanistic") + self.assertEqual(rows["technique:empathy"]["rationale"], "감정을 명시적으로 반영했다.") + self.assertEqual(rows["client_state:affect_contact"]["rationale"], "내담자가 감정을 언급했다.") + + def test_rebuild_turn_evaluation_restores_review_shape(self) -> None: + rebuilt = session_persistence._rebuild_turn_evaluations( + [("11111111-1111-1111-1111-111111111111", 2, "탐색")], + feedback_rows=[ + { + "turn_id": "11111111-1111-1111-1111-111111111111", + "dimension": "appropriateness", + "score": 1.0, + "rationale": "조언이 너무 빨랐다.", + "top1_score": None, + "loop": "fast", + }, + { + "turn_id": "11111111-1111-1111-1111-111111111111", + "dimension": "technique:empathy", + "score": None, + "rationale": "정서 반영이 포함됐다.", + "top1_score": None, + "loop": "fast", + }, + { + "turn_id": "11111111-1111-1111-1111-111111111111", + "dimension": "rapport_signal", + "score": -0.4, + "rationale": None, + "top1_score": None, + "loop": "fast", + }, + ], + technique_rows=[ + { + "turn_id": "11111111-1111-1111-1111-111111111111", + "code": "empathy", + "label_ko": "공감", + "category": "relational", + } + ], + client_state_rows=[ + { + "turn_id": "11111111-1111-1111-1111-111111111111", + "code": "defensive", + "label_ko": "방어", + } + ], + comment_rows=[ + { + "turn_id": "11111111-1111-1111-1111-111111111111", + "intent_deviation": { + "dimension": "pacing", + "expected": "감정 탐색", + "actual": "해결 조언", + "severity": "moderate", + }, + } + ], + ) + + ev = rebuilt["11111111-1111-1111-1111-111111111111"] + self.assertEqual(ev["turn_seq"], 2) + self.assertEqual(ev["stage"], "탐색") + self.assertEqual(ev["appropriateness"], "warn") + self.assertEqual(ev["appropriateness_note"], "조언이 너무 빨랐다.") + self.assertEqual(ev["rapport_signal"], -0.4) + self.assertEqual(ev["techniques"][0]["rationale"], "정서 반영이 포함됐다.") + self.assertEqual(ev["client_state_read"][0]["label_ko"], "방어") + self.assertEqual(ev["intent_deviation"]["dimension"], "pacing") + + def test_evaluation_rls_blocks_raw_learner_writes(self) -> None: + root = Path(__file__).resolve().parents[3] + sql = (root / "infra/db/init/04_audit_eval_rls.sql").read_text(encoding="utf-8") + + self.assertIn("ALTER TABLE app.feedback_scores ENABLE ROW LEVEL SECURITY", sql) + self.assertIn("ALTER TABLE app.turn_technique ENABLE ROW LEVEL SECURITY", sql) + self.assertIn("ALTER TABLE app.turn_client_state ENABLE ROW LEVEL SECURITY", sql) + self.assertIn("ALTER TABLE app.supervisor_comment ENABLE ROW LEVEL SECURITY", sql) + self.assertIn("ALTER TABLE app.alternative_utterance ENABLE ROW LEVEL SECURITY", sql) + feedback_insert = sql.split("CREATE POLICY p_feedback_insert", 1)[1].split(");", 1)[0] + self.assertNotIn("learner_id = app.current_uid()", feedback_insert) + + def test_append_turn_requires_inserted_turn_id(self) -> None: + source = Path(session_persistence.__file__).read_text(encoding="utf-8") + + self.assertIn("RETURNING id", source) + self.assertIn("if inserted_turn_id is None:", source) + + +class EvaluationPersistenceIOTest(unittest.IsolatedAsyncioTestCase): + async def test_persist_turn_evaluation_uses_evaluator_context_and_real_fast_tables(self) -> None: + conn = FakeEvaluationConn() + evaluation = { + "loop": "fast", + "turn_seq": 3, + "stage": "탐색", + "appropriateness": "warn", + "appropriateness_note": "해결 제안이 빨랐다.", + "techniques": [ + { + "code": "empathy", + "label_ko": "공감", + "category": "relational", + "rationale": "정서 반영.", + } + ], + "client_state_read": [ + { + "code": "defensive", + "label_ko": "방어", + "rationale": "짧은 회피 반응.", + } + ], + "intent_deviation": { + "dimension": "pacing", + "expected": "탐색", + "actual": "조언", + "severity": "minor", + }, + } + + await session_persistence._persist_turn_evaluation( + conn, + "11111111-1111-1111-1111-111111111111", + evaluation, + ) + + executed_sql = "\n".join(query for query, _ in conn.executed) + self.assertIn("set_config('app.ai_context', '1', true)", executed_sql) + self.assertIn("set_config('app.current_ai_view', 'evaluator', true)", executed_sql) + self.assertIn("INSERT INTO app.feedback_scores", executed_sql) + self.assertIn("INSERT INTO app.turn_technique", executed_sql) + self.assertIn("INSERT INTO app.turn_client_state", executed_sql) + self.assertIn("INSERT INTO app.supervisor_comment", executed_sql) + self.assertNotIn("app.alternative_utterance", executed_sql) + + async def test_route_loader_only_hydrates_when_requested(self) -> None: + principal = Principal( + user_id="00000000-0000-0000-0000-000000000101", + role=Role.LEARNER, + cohort_ids=[], + email="eval-map@hs.ac.kr", + display_name="Eval Map", + ) + card = persona_service.P1 + sess = InProcSession( + session_id="eval-map-session", + case_id="eval-map-case", + learner_id=principal.user_id, + persona_code=card.code, + theory_mode="humanistic", + persona=card, + state=state_machine.SessionState( + resistance=card.base_resistance(), + ideation_stage=card.ideation_baseline(), + ), + ) + calls: list[bool] = [] + + async def fake_load_session(*args, **kwargs): + calls.append(bool(kwargs.get("include_turn_evaluation"))) + return sess + + with patch.object(sessions.session_persistence, "load_session", fake_load_session): + await sessions._load_session_or_404(sess.session_id, principal) + await sessions._load_session_or_404( + sess.session_id, + principal, + allow_ended=True, + include_turn_evaluation=True, + ) + + self.assertEqual(calls, [False, True]) + + +if __name__ == "__main__": + unittest.main() diff --git a/apps/api/app/test_runtime_policy.py b/apps/api/app/test_runtime_policy.py index 04458b4..468ed68 100644 --- a/apps/api/app/test_runtime_policy.py +++ b/apps/api/app/test_runtime_policy.py @@ -222,11 +222,13 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase): auth_dev_login_enabled=True, auto_seed_personas=True, allow_seed_persona_fallback=True, + voice_poc_sample_tts_enabled=True, ) self.assertIn("AUTH_DEV_LOGIN_ENABLED", str(caught.exception)) self.assertIn("AUTO_SEED_PERSONAS", str(caught.exception)) self.assertIn("ALLOW_SEED_PERSONA_FALLBACK", str(caught.exception)) + self.assertIn("VIGNETTE_VOICE_POC_SAMPLE_TTS", str(caught.exception)) def test_non_dev_rejects_missing_public_runtime_config(self) -> None: with self.assertRaises(ValueError) as caught: @@ -264,6 +266,43 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(cfg.environment, "staging") self.assertEqual(cfg.cors_origins, ["https://vignette.chanpaca.net"]) + def test_non_dev_accepts_vnet_frontend_origin_map(self) -> None: + cfg = Settings( + environment="prod", + auth_dev_login_enabled=False, + auto_seed_personas=False, + allow_seed_persona_fallback=False, + session_secret="prod-secret-change-me", + oauth_google_client_id="google-client-id", + oauth_google_client_secret="google-client-secret", + frontend_base_url="https://vignette.chanpaca.net", + frontend_origin_map={ + "api-vignette.chanpaca.net": "https://vignette.chanpaca.net", + "api-vnet.18ka.net": "https://vnet.18ka.net", + }, + cors_origins=["https://vignette.chanpaca.net", "https://vnet.18ka.net"], + ) + + self.assertEqual(cfg.frontend_origin_map["api-vnet.18ka.net"], "https://vnet.18ka.net") + self.assertIn("https://vnet.18ka.net", cfg.cors_origins) + + def test_non_dev_rejects_local_frontend_origin_map(self) -> None: + with self.assertRaises(ValueError) as caught: + Settings( + environment="prod", + auth_dev_login_enabled=False, + auto_seed_personas=False, + allow_seed_persona_fallback=False, + session_secret="prod-secret-change-me", + oauth_google_client_id="google-client-id", + oauth_google_client_secret="google-client-secret", + frontend_base_url="https://vignette.chanpaca.net", + frontend_origin_map={"api.example.test": "http://localhost:9999"}, + cors_origins=["https://vignette.chanpaca.net"], + ) + + self.assertIn("FRONTEND_ORIGIN_MAP", str(caught.exception)) + def test_non_dev_accepts_explicit_local_vite_cors_ports(self) -> None: cfg = Settings( environment="prod", diff --git a/apps/api/app/test_voice_service.py b/apps/api/app/test_voice_service.py index 0504817..2cec434 100644 --- a/apps/api/app/test_voice_service.py +++ b/apps/api/app/test_voice_service.py @@ -3,6 +3,8 @@ from __future__ import annotations import unittest +from pathlib import Path +from tempfile import TemporaryDirectory from .services.voice import ( DEFAULT_OPENAI_VOICE, @@ -173,6 +175,49 @@ class VoiceServiceStreamTest(unittest.IsolatedAsyncioTestCase): self.assertEqual(payload["instructions"], "Keep the tone grounded.") self.assertEqual([chunk.audio for chunk in chunks], [b"\x80\x80", b"\xff\x00"]) + async def test_dev_p1_sample_tts_streams_local_mp3_without_openai_key(self) -> None: + with TemporaryDirectory() as tmp: + sample_dir = Path(tmp) + default_audio = b"default-mp3" + anxious_audio = (b"anxious-mp3-" * 500) + (sample_dir / "p1_seoyeon_01_depressed_slow.mp3").write_bytes(default_audio) + (sample_dir / "p1_seoyeon_03_anxious_guarded.mp3").write_bytes(anxious_audio) + service = VoiceService( + api_key="", + poc_sample_tts_enabled=True, + environment="dev", + poc_sample_tts_dir=sample_dir, + ) + voice = VoicePreset(preset="soft-young-fem", openai_voice="coral") + + chunks = [ + chunk + async for chunk in service.synthesize_stream( + "엄마한테 말하지 않는 거죠? 진짜 불안해요.", + voice, + ) + ] + + self.assertFalse(service.is_available()) + self.assertTrue(service.poc_sample_tts_available()) + self.assertEqual(service.tts_provider(), "p1-sample-poc") + self.assertEqual(b"".join(chunk.audio for chunk in chunks), anxious_audio) + + async def test_p1_sample_tts_is_disabled_outside_dev(self) -> None: + with TemporaryDirectory() as tmp: + sample_dir = Path(tmp) + (sample_dir / "p1_seoyeon_01_depressed_slow.mp3").write_bytes(b"default-mp3") + service = VoiceService( + api_key="", + poc_sample_tts_enabled=True, + environment="prod", + poc_sample_tts_dir=sample_dir, + ) + + self.assertFalse(service.is_available()) + self.assertFalse(service.poc_sample_tts_available()) + self.assertEqual(service.tts_provider(), "disabled-non-dev") + class EndOfTurnDecisionTest(unittest.TestCase): def test_end_of_turn_requires_silence_threshold(self) -> None: diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/blush-left.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/blush-left.png new file mode 100644 index 0000000..8daa4f4 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/blush-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/blush-right.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/blush-right.png new file mode 100644 index 0000000..da7302e Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/blush-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/body.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/body.png new file mode 100644 index 0000000..62ed067 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/body.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/brow-left.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/brow-left.png new file mode 100644 index 0000000..e46628d Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/brow-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/brow-right.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/brow-right.png new file mode 100644 index 0000000..31a23cf Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/brow-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/clavicle.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/clavicle.png new file mode 100644 index 0000000..c27643b Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/clavicle.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/ear-left.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/ear-left.png new file mode 100644 index 0000000..3c835fd Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/ear-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/ear-right.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/ear-right.png new file mode 100644 index 0000000..9b6d2a9 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/ear-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/eye-left.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eye-left.png new file mode 100644 index 0000000..977b438 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eye-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/eye-right.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eye-right.png new file mode 100644 index 0000000..70d0c9b Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eye-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/eye-white-left.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eye-white-left.png new file mode 100644 index 0000000..7648743 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eye-white-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/eye-white-right.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eye-white-right.png new file mode 100644 index 0000000..28dd0ad Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eye-white-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-joy-brow-left.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-joy-brow-left.png new file mode 100644 index 0000000..65fd964 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-joy-brow-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-joy-brow-right.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-joy-brow-right.png new file mode 100644 index 0000000..3b46c09 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-joy-brow-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-joy-eye-left.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-joy-eye-left.png new file mode 100644 index 0000000..8ac14d9 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-joy-eye-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-joy-eye-right.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-joy-eye-right.png new file mode 100644 index 0000000..4c6cb05 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-joy-eye-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-sad-brow-left.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-sad-brow-left.png new file mode 100644 index 0000000..8ad3225 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-sad-brow-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-sad-brow-right.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-sad-brow-right.png new file mode 100644 index 0000000..652898e Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-sad-brow-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-sad-eye-left.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-sad-eye-left.png new file mode 100644 index 0000000..c769e2c Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-sad-eye-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-sad-eye-right.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-sad-eye-right.png new file mode 100644 index 0000000..89e169a Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-sad-eye-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-startled-brow-left.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-startled-brow-left.png new file mode 100644 index 0000000..16803b0 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-startled-brow-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-startled-brow-right.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-startled-brow-right.png new file mode 100644 index 0000000..176b12c Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-startled-brow-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-startled-eye-left.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-startled-eye-left.png new file mode 100644 index 0000000..297a3e0 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-startled-eye-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-startled-eye-right.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-startled-eye-right.png new file mode 100644 index 0000000..e935c41 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyegen-startled-eye-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyelid-lower-left.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyelid-lower-left.png new file mode 100644 index 0000000..3828e2a Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyelid-lower-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyelid-lower-right.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyelid-lower-right.png new file mode 100644 index 0000000..b42c910 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyelid-lower-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyelid-upper-left.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyelid-upper-left.png new file mode 100644 index 0000000..44b885b Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyelid-upper-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyelid-upper-right.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyelid-upper-right.png new file mode 100644 index 0000000..1a4d864 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/eyelid-upper-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/face-base.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/face-base.png new file mode 100644 index 0000000..b50d8ef Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/face-base.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/face.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/face.png new file mode 100644 index 0000000..9c6a0d2 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/face.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-back-base.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-back-base.png new file mode 100644 index 0000000..c781559 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-back-base.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-back-left.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-back-left.png new file mode 100644 index 0000000..668c2c2 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-back-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-back-right.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-back-right.png new file mode 100644 index 0000000..92d2f00 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-back-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-back.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-back.png new file mode 100644 index 0000000..2ce81aa Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-back.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-bangs.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-bangs.png new file mode 100644 index 0000000..89f5690 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-bangs.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-front.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-front.png new file mode 100644 index 0000000..563006f Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-front.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-side-left-1.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-side-left-1.png new file mode 100644 index 0000000..446099d Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-side-left-1.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-side-left-2.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-side-left-2.png new file mode 100644 index 0000000..6b56e02 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-side-left-2.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-side-right-1.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-side-right-1.png new file mode 100644 index 0000000..f04c72c Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-side-right-1.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-side-right-2.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-side-right-2.png new file mode 100644 index 0000000..984f7eb Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-side-right-2.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/highlight-left.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/highlight-left.png new file mode 100644 index 0000000..ddb4163 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/highlight-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/highlight-right.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/highlight-right.png new file mode 100644 index 0000000..fe3dd9f Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/highlight-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/iris-left.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/iris-left.png new file mode 100644 index 0000000..c1e1bfc Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/iris-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/iris-right.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/iris-right.png new file mode 100644 index 0000000..14d3eb0 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/iris-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/lash-left.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/lash-left.png new file mode 100644 index 0000000..66029e9 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/lash-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/lash-right.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/lash-right.png new file mode 100644 index 0000000..eaa9591 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/lash-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-anxious.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-anxious.png new file mode 100644 index 0000000..587546f Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-anxious.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-neutral.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-neutral.png new file mode 100644 index 0000000..faef23e Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-neutral.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-open-small.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-open-small.png new file mode 100644 index 0000000..7d4becc Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-open-small.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-open.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-open.png new file mode 100644 index 0000000..8b8b5b9 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-open.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-sad.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-sad.png new file mode 100644 index 0000000..733455d Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-sad.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-startled.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-startled.png new file mode 100644 index 0000000..bd2ad21 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-startled.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-tired.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-tired.png new file mode 100644 index 0000000..a402e2c Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-tired.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-warm.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-warm.png new file mode 100644 index 0000000..d8382df Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-warm.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth.png new file mode 100644 index 0000000..faef23e Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/neck.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/neck.png new file mode 100644 index 0000000..73d23ca Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/neck.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/nose.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/nose.png new file mode 100644 index 0000000..a578365 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/nose.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/outfit-inner.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/outfit-inner.png new file mode 100644 index 0000000..4617c89 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/outfit-inner.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/outfit-outer.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/outfit-outer.png new file mode 100644 index 0000000..3fe8305 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/outfit-outer.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/outfit.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/outfit.png new file mode 100644 index 0000000..c6d6d1b Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/outfit.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/pupil-left.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/pupil-left.png new file mode 100644 index 0000000..84e96ea Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/pupil-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/pupil-right.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/pupil-right.png new file mode 100644 index 0000000..b39e053 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/pupil-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-psb/parts/ribbon.png b/apps/web/public/avatar/seoyeon-live2d-psb/parts/ribbon.png new file mode 100644 index 0000000..0576c3f Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-psb/parts/ribbon.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/brow-left.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/brow-left.png new file mode 100644 index 0000000..553f30c Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/brow-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/brow-right.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/brow-right.png new file mode 100644 index 0000000..a6f7b80 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/brow-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/eye-white-left.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/eye-white-left.png new file mode 100644 index 0000000..1131b0d Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/eye-white-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/eye-white-right.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/eye-white-right.png new file mode 100644 index 0000000..6225bc2 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/eye-white-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/eyelid-left-closed.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/eyelid-left-closed.png new file mode 100644 index 0000000..f561af6 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/eyelid-left-closed.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/eyelid-right-closed.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/eyelid-right-closed.png new file mode 100644 index 0000000..f3a6450 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/eyelid-right-closed.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/hair-back.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/hair-back.png new file mode 100644 index 0000000..dedb0ea Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/hair-back.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/hair-front.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/hair-front.png new file mode 100644 index 0000000..ee30e52 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/hair-front.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/hair-left.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/hair-left.png new file mode 100644 index 0000000..1ab0cb9 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/hair-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/hair-right.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/hair-right.png new file mode 100644 index 0000000..20bcb21 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/hair-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/head-faceless.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/head-faceless.png new file mode 100644 index 0000000..f30905f Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/head-faceless.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/highlight-left.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/highlight-left.png new file mode 100644 index 0000000..dfd7240 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/highlight-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/highlight-right.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/highlight-right.png new file mode 100644 index 0000000..8c57287 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/highlight-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/iris-left.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/iris-left.png new file mode 100644 index 0000000..14db3e7 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/iris-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/iris-right.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/iris-right.png new file mode 100644 index 0000000..26497eb Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/iris-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/lash-left.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/lash-left.png new file mode 100644 index 0000000..20e6e81 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/lash-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/lash-right.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/lash-right.png new file mode 100644 index 0000000..8ae8efa Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/lash-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/mouth-neutral.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/mouth-neutral.png new file mode 100644 index 0000000..9602979 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/mouth-neutral.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/mouth-open.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/mouth-open.png new file mode 100644 index 0000000..2c86a34 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/mouth-open.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/mouth-sad.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/mouth-sad.png new file mode 100644 index 0000000..9a6318f Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/mouth-sad.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/mouth-warm.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/mouth-warm.png new file mode 100644 index 0000000..13b7c16 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/mouth-warm.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/neck-fill.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/neck-fill.png new file mode 100644 index 0000000..7b82c77 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/neck-fill.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/nose.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/nose.png new file mode 100644 index 0000000..a1beb63 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/nose.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/pupil-left.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/pupil-left.png new file mode 100644 index 0000000..8baf5eb Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/pupil-left.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/pupil-right.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/pupil-right.png new file mode 100644 index 0000000..74958db Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/pupil-right.png differ diff --git a/apps/web/public/avatar/seoyeon-live2d-v3/parts/torso.png b/apps/web/public/avatar/seoyeon-live2d-v3/parts/torso.png new file mode 100644 index 0000000..0e3bd09 Binary files /dev/null and b/apps/web/public/avatar/seoyeon-live2d-v3/parts/torso.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/base-faceless.png b/apps/web/public/avatar/seoyeon/parts/base-faceless.png index 24ed061..f174537 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/base-faceless.png and b/apps/web/public/avatar/seoyeon/parts/base-faceless.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/brow-anxious.png b/apps/web/public/avatar/seoyeon/parts/brow-anxious.png index fa80c9d..63de200 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/brow-anxious.png and b/apps/web/public/avatar/seoyeon/parts/brow-anxious.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/brow-neutral.png b/apps/web/public/avatar/seoyeon/parts/brow-neutral.png index fa80c9d..63de200 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/brow-neutral.png and b/apps/web/public/avatar/seoyeon/parts/brow-neutral.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/brow-sad.png b/apps/web/public/avatar/seoyeon/parts/brow-sad.png index fa80c9d..63de200 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/brow-sad.png and b/apps/web/public/avatar/seoyeon/parts/brow-sad.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/brow-startled.png b/apps/web/public/avatar/seoyeon/parts/brow-startled.png index fa80c9d..63de200 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/brow-startled.png and b/apps/web/public/avatar/seoyeon/parts/brow-startled.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/brow-tired.png b/apps/web/public/avatar/seoyeon/parts/brow-tired.png index fa80c9d..63de200 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/brow-tired.png and b/apps/web/public/avatar/seoyeon/parts/brow-tired.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/brow-warm.png b/apps/web/public/avatar/seoyeon/parts/brow-warm.png index fa80c9d..63de200 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/brow-warm.png and b/apps/web/public/avatar/seoyeon/parts/brow-warm.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/eyes-anxious.png b/apps/web/public/avatar/seoyeon/parts/eyes-anxious.png index d340e20..f490158 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/eyes-anxious.png and b/apps/web/public/avatar/seoyeon/parts/eyes-anxious.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/eyes-neutral.png b/apps/web/public/avatar/seoyeon/parts/eyes-neutral.png index d340e20..f490158 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/eyes-neutral.png and b/apps/web/public/avatar/seoyeon/parts/eyes-neutral.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/eyes-sad.png b/apps/web/public/avatar/seoyeon/parts/eyes-sad.png index d340e20..f490158 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/eyes-sad.png and b/apps/web/public/avatar/seoyeon/parts/eyes-sad.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/eyes-startled.png b/apps/web/public/avatar/seoyeon/parts/eyes-startled.png index d340e20..f490158 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/eyes-startled.png and b/apps/web/public/avatar/seoyeon/parts/eyes-startled.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/eyes-tired.png b/apps/web/public/avatar/seoyeon/parts/eyes-tired.png index d340e20..f490158 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/eyes-tired.png and b/apps/web/public/avatar/seoyeon/parts/eyes-tired.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/eyes-warm.png b/apps/web/public/avatar/seoyeon/parts/eyes-warm.png index d340e20..f490158 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/eyes-warm.png and b/apps/web/public/avatar/seoyeon/parts/eyes-warm.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/face-faceless.png b/apps/web/public/avatar/seoyeon/parts/face-faceless.png index 8345afa..f9beabe 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/face-faceless.png and b/apps/web/public/avatar/seoyeon/parts/face-faceless.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/forehead.png b/apps/web/public/avatar/seoyeon/parts/forehead.png index 1570de6..3d5cbff 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/forehead.png and b/apps/web/public/avatar/seoyeon/parts/forehead.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/mouth-anxious.png b/apps/web/public/avatar/seoyeon/parts/mouth-anxious.png index 4181281..6a229cc 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/mouth-anxious.png and b/apps/web/public/avatar/seoyeon/parts/mouth-anxious.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/mouth-open.png b/apps/web/public/avatar/seoyeon/parts/mouth-open.png index 6e5ac0d..f579e00 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/mouth-open.png and b/apps/web/public/avatar/seoyeon/parts/mouth-open.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/mouth-sad.png b/apps/web/public/avatar/seoyeon/parts/mouth-sad.png index 4181281..6a229cc 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/mouth-sad.png and b/apps/web/public/avatar/seoyeon/parts/mouth-sad.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/mouth-startled.png b/apps/web/public/avatar/seoyeon/parts/mouth-startled.png index 6e5ac0d..f579e00 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/mouth-startled.png and b/apps/web/public/avatar/seoyeon/parts/mouth-startled.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/mouth-tired.png b/apps/web/public/avatar/seoyeon/parts/mouth-tired.png index 4181281..6a229cc 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/mouth-tired.png and b/apps/web/public/avatar/seoyeon/parts/mouth-tired.png differ diff --git a/apps/web/public/avatar/seoyeon/parts/mouth-warm.png b/apps/web/public/avatar/seoyeon/parts/mouth-warm.png index 0d89095..b5d03cf 100644 Binary files a/apps/web/public/avatar/seoyeon/parts/mouth-warm.png and b/apps/web/public/avatar/seoyeon/parts/mouth-warm.png differ diff --git a/apps/web/src/components/avatar/RasterBust.tsx b/apps/web/src/components/avatar/RasterBust.tsx index 25848a4..f1cd35f 100644 --- a/apps/web/src/components/avatar/RasterBust.tsx +++ b/apps/web/src/components/avatar/RasterBust.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, type CSSProperties } from "react"; import type { AvatarExpression, AvatarState } from "./persona"; export type RasterVariant = "neutral" | "sad" | "tired" | "anxious" | "warm" | "startled"; @@ -34,6 +34,9 @@ const VARIANT_FOR_EXPRESSION: Record = { tired: "tired", }; +const LIVE2D_PARTS_ART_SETS = new Set(["seoyeon-live2d-v3"]); +const PSB_GROUP_ART_SETS = new Set(["seoyeon-live2d-psb"]); + export function rasterVariantFor(expression: AvatarExpression): RasterVariant { return VARIANT_FOR_EXPRESSION[expression]; } @@ -51,37 +54,181 @@ export interface RasterBustProps { reduced: boolean; } -const TX = "opacity 220ms cubic-bezier(0.22,0.61,0.36,1), transform 220ms cubic-bezier(0.22,0.61,0.36,1)"; - -function clamp01(value: number): number { - return Math.min(1, Math.max(0, value)); -} - function layerTransform(extra = ""): string { return `translateX(-50%)${extra ? ` ${extra}` : ""}`; } -function mouthPartFor(variant: RasterVariant): string { - return variant === "startled" ? "mouth-startled" : `mouth-${variant}`; +function eyeClipStyle(maskUrl: string): CSSProperties { + return { + WebkitMaskImage: `url("${maskUrl}")`, + maskImage: `url("${maskUrl}")`, + WebkitMaskRepeat: "no-repeat", + maskRepeat: "no-repeat", + WebkitMaskSize: "100% 100%", + maskSize: "100% 100%", + }; } -function browPoseFor(variant: RasterVariant): { y: number; rotate: number } { +function eyeApertureClipStyle( + side: "left" | "right", + upperLidY: number, + lowerLidY: number, + closedEyes: number, +): CSSProperties { + const baseTop = side === "left" ? 30.25 : 29.0; + const baseBottom = side === "left" ? 65.85 : 67.05; + const top = clamp(baseTop + upperLidY * 0.18 + closedEyes * 2.4, 0, 48); + const bottom = clamp(baseBottom - lowerLidY * 0.12 + closedEyes * 2.1, 45, 82); + const clipPath = `inset(${top.toFixed(2)}% 0 ${bottom.toFixed(2)}% 0)`; + return { + WebkitClipPath: clipPath, + clipPath, + }; +} + +function clamp01(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function mouthForVariant(variant: RasterVariant): string { + if (variant === "sad" || variant === "tired" || variant === "anxious") return "mouth-sad"; + if (variant === "warm") return "mouth-warm"; + return "mouth-neutral"; +} + +function browPoseForVariant(variant: RasterVariant): { y: number; leftRotate: number; rightRotate: number } { switch (variant) { case "sad": - return { y: 1.4, rotate: -0.35 }; case "tired": - return { y: 1.8, rotate: -0.2 }; + return { y: 1.4, leftRotate: 1.1, rightRotate: -1.1 }; case "anxious": - return { y: -1.1, rotate: 0.25 }; - case "warm": - return { y: -0.7, rotate: 0.12 }; + return { y: -1.2, leftRotate: -1.4, rightRotate: 1.4 }; case "startled": - return { y: -3.2, rotate: 0.1 }; + return { y: -2.4, leftRotate: 0, rightRotate: 0 }; + case "warm": + return { y: -0.6, leftRotate: -0.4, rightRotate: 0.4 }; default: - return { y: 0, rotate: 0 }; + return { y: 0, leftRotate: 0, rightRotate: 0 }; } } +function psbMouthForVariant(variant: RasterVariant): string { + switch (variant) { + case "warm": + return "mouth-warm"; + case "sad": + return "mouth-sad"; + case "tired": + return "mouth-tired"; + case "anxious": + return "mouth-anxious"; + case "startled": + return "mouth-startled"; + default: + return "mouth-neutral"; + } +} + +function psbBrowPoseForVariant(variant: RasterVariant): { y: number; leftRotate: number; rightRotate: number } { + switch (variant) { + case "sad": + return { y: 2.6, leftRotate: 2.2, rightRotate: -2.2 }; + case "tired": + return { y: 2.0, leftRotate: 0.8, rightRotate: -0.8 }; + case "anxious": + return { y: -2.2, leftRotate: -3.0, rightRotate: 3.0 }; + case "startled": + return { y: -4.2, leftRotate: 0, rightRotate: 0 }; + case "warm": + return { y: -1.0, leftRotate: -0.8, rightRotate: 0.8 }; + default: + return { y: 0, leftRotate: 0, rightRotate: 0 }; + } +} + +function psbEyePoseForVariant(variant: RasterVariant): { + scaleY: number; + upperY: number; + lowerY: number; + lashY: number; + leftLashRotate: number; + rightLashRotate: number; + detailY: number; +} { + switch (variant) { + case "warm": + return { + scaleY: 0.72, + upperY: 5.4, + lowerY: -1.4, + lashY: 3.0, + leftLashRotate: -1.4, + rightLashRotate: 1.4, + detailY: 0.9, + }; + case "sad": + return { + scaleY: 0.92, + upperY: 1.8, + lowerY: -0.5, + lashY: 1.0, + leftLashRotate: 1.2, + rightLashRotate: -1.2, + detailY: 0.4, + }; + case "tired": + return { + scaleY: 0.56, + upperY: 7.2, + lowerY: -1.9, + lashY: 4.0, + leftLashRotate: 0.4, + rightLashRotate: -0.4, + detailY: 1.0, + }; + case "anxious": + return { + scaleY: 1.04, + upperY: -1.2, + lowerY: 0.4, + lashY: -0.8, + leftLashRotate: -1.5, + rightLashRotate: 1.5, + detailY: -0.2, + }; + case "startled": + return { + scaleY: 1.18, + upperY: -5.8, + lowerY: 1.6, + lashY: -3.2, + leftLashRotate: 0, + rightLashRotate: 0, + detailY: -0.5, + }; + default: + return { + scaleY: 1, + upperY: 0, + lowerY: 0, + lashY: 0, + leftLashRotate: 0, + rightLashRotate: 0, + detailY: 0, + }; + } +} + +function psbGeneratedEyeVariantFor(variant: RasterVariant): "joy" | "sad" | "startled" | null { + // Imagegen v1 eye parts drifted too far from the source PSB style. + void variant; + return null; +} + export function RasterBust({ artSet, affect, @@ -96,43 +243,445 @@ export function RasterBust({ }: RasterBustProps) { const variant = rasterVariantFor(affect); const speaking = state === "speaking" && !reduced; - const blinkClosed = reduced ? 0 : clamp01((1 - blink) / 0.92); - const speakOverlay = speaking ? clamp01(mouth) : 0; - const browPose = browPoseFor(variant); const stackTransform = `translate(${gazeX.toFixed(2)}px, ${(gazeY - breath).toFixed( 2, )}px) rotate(${headTilt.toFixed(2)}deg)`; - const part = useMemo( - () => (name: string) => `${import.meta.env.BASE_URL}avatar/${artSet}/parts/${name}.png`, + const raster = useMemo( + () => (name: string) => `${import.meta.env.BASE_URL}avatar/${artSet}/${name}.png`, [artSet], ); - const hairPhase = reduced ? 0 : breath; - const hairLeftTransform = layerTransform( - `translate(${(hairPhase * 0.36).toFixed(2)}px, ${(hairPhase * -0.05).toFixed( - 2, - )}px) rotate(${(hairPhase * -0.16).toFixed(2)}deg)`, - ); - const hairRightTransform = layerTransform( - `translate(${(hairPhase * -0.32).toFixed(2)}px, ${(hairPhase * -0.04).toFixed( - 2, - )}px) rotate(${(hairPhase * 0.15).toFixed(2)}deg)`, - ); - const bangsTransform = layerTransform( - `translate(${(hairPhase * 0.12).toFixed(2)}px, ${(hairPhase * 0.08).toFixed( - 2, - )}px) rotate(${(hairPhase * 0.08).toFixed(2)}deg)`, - ); - const eyesTransform = layerTransform( - `translate(${(gazeX * 0.22).toFixed(2)}px, ${(gazeY * 0.16).toFixed(2)}px)`, - ); - const browTransform = layerTransform( - `translate(${(gazeX * 0.08).toFixed(2)}px, ${(browPose.y + gazeY * 0.05).toFixed( - 2, - )}px) rotate(${browPose.rotate.toFixed(2)}deg)`, - ); + if (PSB_GROUP_ART_SETS.has(artSet)) { + const part = (name: string) => `${import.meta.env.BASE_URL}avatar/${artSet}/parts/${name}.png`; + const speakingOpen = speaking ? clamp01(Math.max(mouth, 0.1)) : 0; + const wideOpen = clamp01((speakingOpen - 0.34) / 0.5); + const smallOpen = speakingOpen > 0 ? 1 - wideOpen : 0; + const closedEyes = reduced ? 0 : clamp01((0.55 - blink) / 0.47); + const openEyes = 1 - closedEyes; + const irisX = reduced ? 0 : gazeX * 0.75; + const irisY = reduced ? 0 : gazeY * 0.48; + const hairSway = reduced ? 0 : clamp(breath * 0.48 + headTilt * 0.22 + gazeX * 0.08, -2.2, 2.2); + const hairLag = reduced ? 0 : clamp(breath * 0.34 + headTilt * 0.18, -1.8, 1.8); + const talkBounce = speakingOpen * 1.25; + const browFloat = reduced ? 0 : breath * 0.22; + const brow = psbBrowPoseForVariant(variant); + const eyePose = psbEyePoseForVariant(variant); + const generatedEyeVariant = psbGeneratedEyeVariantFor(variant); + const blinkScale = 1 - closedEyes * 0.78; + const eyeScaleY = Math.max(0.06, eyePose.scaleY * blinkScale); + const upperLidY = eyePose.upperY + closedEyes * 10.8; + const lowerLidY = eyePose.lowerY - closedEyes * 2.4; + const lashY = eyePose.lashY + closedEyes * 2.1; + const expressionMouth = psbMouthForVariant(variant); + const eyeDetailLayers: Array<{ name: string; mask: string; zIndex: number }> = [ + { name: "iris-left", mask: "eye-white-left", zIndex: 9 }, + { name: "iris-right", mask: "eye-white-right", zIndex: 9 }, + { name: "pupil-left", mask: "eye-white-left", zIndex: 10 }, + { name: "pupil-right", mask: "eye-white-right", zIndex: 10 }, + { name: "highlight-left", mask: "eye-white-left", zIndex: 11 }, + { name: "highlight-right", mask: "eye-white-right", zIndex: 11 }, + ]; + const renderLayer = ( + name: string, + className: string, + zIndex: number, + style: CSSProperties = {}, + ) => ( + + ); + + return ( + + ); + } + + if (LIVE2D_PARTS_ART_SETS.has(artSet)) { + const part = (name: string) => `${import.meta.env.BASE_URL}avatar/${artSet}/parts/${name}.png`; + const speakingOpen = speaking ? clamp01(Math.max(mouth, 0.08)) : 0; + const closedEyes = reduced ? 0 : clamp01((0.55 - blink) / 0.47); + const openEyes = 1 - closedEyes; + const irisX = reduced ? 0 : gazeX * 0.42; + const irisY = reduced ? 0 : gazeY * 0.28; + const hairSway = reduced ? 0 : breath * 0.24 + headTilt * 0.18; + const brow = browPoseForVariant(variant); + const expressionMouth = mouthForVariant(variant); + const eyeDetailLayers: Array<{ name: string; mask: string; zIndex: number }> = [ + { name: "iris-left", mask: "eye-white-left", zIndex: 9 }, + { name: "iris-right", mask: "eye-white-right", zIndex: 9 }, + { name: "pupil-left", mask: "eye-white-left", zIndex: 10 }, + { name: "pupil-right", mask: "eye-white-right", zIndex: 10 }, + { name: "highlight-left", mask: "eye-white-left", zIndex: 11 }, + { name: "highlight-right", mask: "eye-white-right", zIndex: 11 }, + ]; + + return ( + + ); + } return (
- - - - - - - - - - -
); } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index c5771a3..cc2baad 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -7,10 +7,17 @@ ===================================================================== */ // Vite 환경변수. 기본 "/api" (vite proxy 또는 nginx 가 백엔드로 라우팅). +const PUBLIC_API_ORIGIN_BY_HOST: Record = { + "vignette.chanpaca.net": "https://api-vignette.chanpaca.net", + "vnet.18ka.net": "https://api-vnet.18ka.net", +}; + function defaultApiBase(): string { if (typeof window !== "undefined") { const host = window.location.hostname; - if (host === "vignette.chanpaca.net" || host.endsWith(".pages.dev")) { + const mapped = PUBLIC_API_ORIGIN_BY_HOST[host]; + if (mapped) return mapped; + if (host.endsWith(".pages.dev")) { return "https://api-vignette.chanpaca.net"; } } diff --git a/apps/web/src/pages/AvatarPreview.tsx b/apps/web/src/pages/AvatarPreview.tsx index 5a50016..b55a85e 100644 --- a/apps/web/src/pages/AvatarPreview.tsx +++ b/apps/web/src/pages/AvatarPreview.tsx @@ -38,25 +38,40 @@ const PREVIEW_EXPRS: AvatarExpression[] = [ ]; export default function AvatarPreview() { + const rig = typeof window === "undefined" ? "" : new URLSearchParams(window.location.search).get("rig"); + const previewPersona: AvatarPersona = { + ...SEOYEON, + rasterArtSet: + rig === "psb" + ? "seoyeon-live2d-psb" + : rig === "v3" + ? "seoyeon-live2d-v3" + : SEOYEON.rasterArtSet, + }; + return ( -
+

서연 아바타 미리보기 (raster / Live2D식)

imagegen gpt-image-2 + BiRefNet 컷아웃 레이어 합성. 호흡·깜빡임·시선 모션 확인.

- - - - + + + +
{PREVIEW_EXPRS.map((expr) => (
host.trim()) + .filter(Boolean); +const apiProxyTarget = nodeEnv?.VITE_API_PROXY_TARGET?.trim() || "http://127.0.0.1:8000"; + export default defineConfig({ plugins: [react()], server: { port: 5173, + ...(allowedHosts.length ? { allowedHosts } : {}), proxy: { "/api": { - target: "http://127.0.0.1:8000", + target: apiProxyTarget, changeOrigin: true, ws: true, // 백엔드 라우트는 /auth, /sessions, /health 로 노출되므로 /api 프리픽스 제거. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 8a28a4b..1937c01 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -37,6 +37,7 @@ docker compose up -d | `POSTGRES_PASSWORD` | DB 비밀번호(필수) | | `SESSION_SECRET` | 세션 서명키(필수, 랜덤) | | `AUTH_ALLOWED_EMAIL_DOMAINS` | Google 로그인 허용 이메일 도메인(JSON 배열). 기본 `["hs.ac.kr","twentyoz.kr"]`. Google Console authorized domains가 아니라 서버에서 `email`/`hd` claim으로 강제 | +| `FRONTEND_ORIGIN_MAP` | API callback host → frontend origin 매핑(JSON 객체). 기본 `api-vignette.chanpaca.net → vignette.chanpaca.net`, `api-vnet.18ka.net → vnet.18ka.net` | | `OPENAI_API_KEY` | 음성(STT/TTS) | | `ANTHROPIC_API_KEY` | 엔진 폴백/클라우드 모드 | | `ENGINE_MODE` / `ENGINE_URL` | 엔진 위치(위 표) | @@ -48,6 +49,16 @@ docker compose up -d 두 가지 — 호스트 상황에 맞게: - **Caddy 자동 TLS**: 공인 IP·도메인 있으면 `SITE_ADDRESS=vignette.chanpaca.net` + `ACME_EMAIL` → Caddy가 인증서 자동 발급. + +## 4.1 vnet.18ka.net 공개 경로 + +`vnet.18ka.net`을 공개 Google OAuth로 쓰려면 세 조건이 같이 맞아야 한다. + +1. DNS: `vnet.18ka.net`은 웹, `api-vnet.18ka.net`은 API로 라우팅되어야 한다. +2. API 설정: `CORS_ORIGINS`에 `https://vnet.18ka.net`, `FRONTEND_ORIGIN_MAP`에 `api-vnet.18ka.net=https://vnet.18ka.net`이 있어야 한다. +3. Google Cloud Console: Authorized redirect URI에 `https://api-vnet.18ka.net/auth/callback`을 추가해야 한다. + +현재 이 저장소의 코드와 `scripts/start-public-runtime.ps1`은 2번을 회귀 테스트와 기본값으로 고정한다. 1번과 3번은 외부 콘솔/도메인 소유자 설정이다. - **Cloudflare/Tailscale 터널**: 공인 IP 없을 때(네 PC). 단 **음성 SSE/WSS는 버퍼링 충돌**이 있어 Caddyfile에서 `flush_interval -1`로 흘려보냄(red team F-07 반영). 터널도 SSE 무버퍼 경로 분리 권장. ## 5. 스케일업 / 이전 diff --git a/docs/avatar-art/seoyeon/character-sheet-grid.png b/docs/avatar-art/seoyeon/character-sheet-grid.png new file mode 100644 index 0000000..8083430 Binary files /dev/null and b/docs/avatar-art/seoyeon/character-sheet-grid.png differ diff --git a/docs/avatar-art/seoyeon/character-sheet-recompose-preview.png b/docs/avatar-art/seoyeon/character-sheet-recompose-preview.png index 71ea43f..6b9f41e 100644 Binary files a/docs/avatar-art/seoyeon/character-sheet-recompose-preview.png and b/docs/avatar-art/seoyeon/character-sheet-recompose-preview.png differ diff --git a/docs/avatar-art/seoyeon/character-sheet-rig-preview-v2.png b/docs/avatar-art/seoyeon/character-sheet-rig-preview-v2.png index 71ea43f..6b9f41e 100644 Binary files a/docs/avatar-art/seoyeon/character-sheet-rig-preview-v2.png and b/docs/avatar-art/seoyeon/character-sheet-rig-preview-v2.png differ diff --git a/docs/avatar-art/seoyeon/extract-character-sheet.py b/docs/avatar-art/seoyeon/extract-character-sheet.py index 7b52185..321efd3 100644 --- a/docs/avatar-art/seoyeon/extract-character-sheet.py +++ b/docs/avatar-art/seoyeon/extract-character-sheet.py @@ -79,17 +79,19 @@ def shape_mask( def erase_features(neutral: Image.Image) -> Image.Image: base = neutral.convert("RGBA") patches = [ - ((286, 284, 424, 388), (418, 360, 476, 392), "round", 11), - ((476, 284, 614, 388), (418, 360, 476, 392), "round", 11), - ((404, 374, 498, 434), (392, 360, 512, 410), "ellipse", 9), - ((370, 404, 530, 476), (388, 382, 512, 428), "ellipse", 10), + ((318, 292, 410, 320), (418, 360, 476, 392), "round", 5), + ((490, 292, 582, 320), (418, 360, 476, 392), "round", 5), + ((306, 314, 412, 372), (418, 360, 476, 392), "ellipse", 5), + ((488, 314, 594, 372), (418, 360, 476, 392), "ellipse", 5), + ((412, 382, 490, 430), (392, 360, 512, 410), "ellipse", 5), + ((382, 414, 518, 466), (388, 382, 512, 428), "ellipse", 6), ] for erase_box, sample_box, kind, feather in patches: color = median_skin(base, sample_box) fill = Image.new("RGBA", CANVAS, color) mask = Image.new("L", CANVAS, 0) - draw_shape(mask, erase_box, kind, 30) + draw_shape(mask, erase_box, kind, 18) mask = mask.filter(ImageFilter.GaussianBlur(feather)) base = Image.composite(fill, base, mask) return base @@ -333,7 +335,7 @@ def main() -> int: sheet, "brow-neutral", (600, 424, 848, 482), - (312, 288, 588, 326), + (315, 272, 591, 310), matte_lo=10, matte_hi=42, ) @@ -341,7 +343,7 @@ def main() -> int: sheet, "eyes-neutral", (594, 458, 856, 548), - (306, 314, 594, 376), + (311, 314, 599, 376), matte_lo=10, matte_hi=44, ) @@ -370,7 +372,7 @@ def main() -> int: sheet, "mouth-sad", (888, 520, 1016, 586), - (383, 414, 517, 468), + (397, 414, 531, 468), matte_lo=10, matte_hi=44, ) @@ -378,7 +380,7 @@ def main() -> int: sheet, "mouth-warm", (888, 596, 1016, 662), - (383, 414, 517, 468), + (397, 414, 531, 468), matte_lo=10, matte_hi=44, ) @@ -386,7 +388,7 @@ def main() -> int: sheet, "mouth-open", (888, 672, 1016, 752), - (380, 407, 520, 475), + (394, 407, 534, 475), matte_lo=10, matte_hi=44, ) diff --git a/docs/avatar-art/seoyeon/live2d-psb/app-render/compare-source-vs-app-grid.png b/docs/avatar-art/seoyeon/live2d-psb/app-render/compare-source-vs-app-grid.png new file mode 100644 index 0000000..64b0e8e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/app-render/compare-source-vs-app-grid.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/app-render/compare-source-vs-app-hero.png b/docs/avatar-art/seoyeon/live2d-psb/app-render/compare-source-vs-app-hero.png new file mode 100644 index 0000000..9df7f4d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/app-render/compare-source-vs-app-hero.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/app-render/compare-source-vs-app-neutral.png b/docs/avatar-art/seoyeon/live2d-psb/app-render/compare-source-vs-app-neutral.png new file mode 100644 index 0000000..d169b0e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/app-render/compare-source-vs-app-neutral.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/app-render/render-grid.png b/docs/avatar-art/seoyeon/live2d-psb/app-render/render-grid.png new file mode 100644 index 0000000..3ea8572 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/app-render/render-grid.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/app-render/render-hero-0.png b/docs/avatar-art/seoyeon/live2d-psb/app-render/render-hero-0.png new file mode 100644 index 0000000..4e7e451 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/app-render/render-hero-0.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/app-render/render-hero-1.png b/docs/avatar-art/seoyeon/live2d-psb/app-render/render-hero-1.png new file mode 100644 index 0000000..dcc46be Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/app-render/render-hero-1.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/app-render/render-hero-2.png b/docs/avatar-art/seoyeon/live2d-psb/app-render/render-hero-2.png new file mode 100644 index 0000000..109b7dc Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/app-render/render-hero-2.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/app-render/render-hero-3.png b/docs/avatar-art/seoyeon/live2d-psb/app-render/render-hero-3.png new file mode 100644 index 0000000..b20fdc7 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/app-render/render-hero-3.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/app-render/render-hero.png b/docs/avatar-art/seoyeon/live2d-psb/app-render/render-hero.png new file mode 100644 index 0000000..c18f265 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/app-render/render-hero.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/app-render/render-neutral-cell.png b/docs/avatar-art/seoyeon/live2d-psb/app-render/render-neutral-cell.png new file mode 100644 index 0000000..5ae7b14 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/app-render/render-neutral-cell.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/debug-face-group.png b/docs/avatar-art/seoyeon/live2d-psb/debug-face-group.png new file mode 100644 index 0000000..d319e09 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/debug-face-group.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/detailed-parts-contact.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/detailed-parts-contact.png new file mode 100644 index 0000000..142d355 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/detailed-parts-contact.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/detailed-parts-manifest.json b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/detailed-parts-manifest.json new file mode 100644 index 0000000..930a56f --- /dev/null +++ b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/detailed-parts-manifest.json @@ -0,0 +1,792 @@ +{ + "source": "C:\\Users\\encep\\OneDrive\\문서\\카카오톡 받은 파일\\라투디 여캐.psb", + "sourceCanvas": [ + 1080, + 1920 + ], + "targetCanvas": [ + 900, + 1125 + ], + "cropBox": [ + 0, + 240, + 1080, + 1590 + ], + "layerOrder": [ + "hair-back-base", + "hair-back-left", + "hair-back-right", + "body", + "outfit", + "ear-left", + "ear-right", + "face-base", + "blush-left", + "blush-right", + "eye-white-left", + "eye-white-right", + "iris-left", + "iris-right", + "pupil-left", + "pupil-right", + "highlight-left", + "highlight-right", + "lash-left", + "lash-right", + "eyelid-upper-left", + "eyelid-upper-right", + "eyelid-lower-left", + "eyelid-lower-right", + "brow-left", + "brow-right", + "nose", + "hair-side-left-1", + "hair-side-right-1", + "hair-side-left-2", + "hair-side-right-2", + "hair-bangs", + "mouth-neutral" + ], + "parts": [ + { + "id": "hair-back-left", + "source": "back hair left group", + "file": "hair-back-left.png", + "alphaBox": [ + 0, + 0, + 463, + 1125 + ], + "originPercent": { + "x": 25.72, + "y": 8.0 + } + }, + { + "id": "hair-back-right", + "source": "back hair right group", + "file": "hair-back-right.png", + "alphaBox": [ + 437, + 0, + 900, + 1125 + ], + "originPercent": { + "x": 74.28, + "y": 8.0 + } + }, + { + "id": "body", + "source": "body group", + "file": "body.png", + "alphaBox": [ + 339, + 439, + 605, + 759 + ], + "originPercent": { + "x": 52.44, + "y": 53.24 + } + }, + { + "id": "neck", + "source": "neck group", + "file": "neck.png", + "alphaBox": [ + 339, + 439, + 605, + 759 + ], + "originPercent": { + "x": 52.44, + "y": 53.24 + } + }, + { + "id": "clavicle", + "source": "clavicle group", + "file": "clavicle.png", + "alphaBox": [ + 368, + 632, + 560, + 664 + ], + "originPercent": { + "x": 51.56, + "y": 57.6 + } + }, + { + "id": "outfit", + "source": "outfit group", + "file": "outfit.png", + "alphaBox": [ + 130, + 521, + 806, + 1015 + ], + "originPercent": { + "x": 52.0, + "y": 68.27 + } + }, + { + "id": "outfit-inner", + "source": "inner outfit group", + "file": "outfit-inner.png", + "alphaBox": [ + 232, + 521, + 705, + 1009 + ], + "originPercent": { + "x": 52.06, + "y": 68.0 + } + }, + { + "id": "outfit-outer", + "source": "outer outfit group", + "file": "outfit-outer.png", + "alphaBox": [ + 130, + 541, + 806, + 1015 + ], + "originPercent": { + "x": 52.0, + "y": 69.16 + } + }, + { + "id": "ribbon", + "source": "ribbon group", + "file": "ribbon.png", + "alphaBox": [ + 368, + 636, + 570, + 832 + ], + "originPercent": { + "x": 52.11, + "y": 65.24 + } + }, + { + "id": "face", + "source": "face group", + "file": "face.png", + "alphaBox": [ + 271, + 31, + 650, + 532 + ], + "originPercent": { + "x": 51.17, + "y": 25.02 + } + }, + { + "id": "face-base", + "source": "face base group", + "file": "face-base.png", + "alphaBox": [ + 276, + 31, + 630, + 532 + ], + "originPercent": { + "x": 50.33, + "y": 25.02 + } + }, + { + "id": "ear-left", + "source": "left ear group", + "file": "ear-left.png", + "alphaBox": [ + 271, + 362, + 349, + 445 + ], + "originPercent": { + "x": 34.44, + "y": 35.87 + } + }, + { + "id": "ear-right", + "source": "right ear group", + "file": "ear-right.png", + "alphaBox": [ + 572, + 355, + 650, + 438 + ], + "originPercent": { + "x": 67.89, + "y": 35.24 + } + }, + { + "id": "blush-left", + "source": "left blush group", + "file": "blush-left.png", + "alphaBox": [ + 301, + 338, + 449, + 446 + ], + "originPercent": { + "x": 41.67, + "y": 34.84 + } + }, + { + "id": "blush-right", + "source": "right blush group", + "file": "blush-right.png", + "alphaBox": [ + 467, + 330, + 615, + 438 + ], + "originPercent": { + "x": 60.11, + "y": 34.13 + } + }, + { + "id": "nose", + "source": "nose group", + "file": "nose.png", + "alphaBox": [ + 448, + 410, + 461, + 426 + ], + "originPercent": { + "x": 50.5, + "y": 37.16 + } + }, + { + "id": "eye-left", + "source": "left eye group", + "file": "eye-left.png", + "alphaBox": [ + 296, + 292, + 436, + 392 + ], + "originPercent": { + "x": 40.67, + "y": 30.4 + } + }, + { + "id": "eye-right", + "source": "right eye group", + "file": "eye-right.png", + "alphaBox": [ + 472, + 283, + 619, + 377 + ], + "originPercent": { + "x": 60.61, + "y": 29.33 + } + }, + { + "id": "eye-white-left", + "source": "left eye white group", + "file": "eye-white-left.png", + "alphaBox": [ + 327, + 341, + 413, + 384 + ], + "originPercent": { + "x": 41.11, + "y": 32.22 + } + }, + { + "id": "eye-white-right", + "source": "right eye white group", + "file": "eye-white-right.png", + "alphaBox": [ + 503, + 327, + 588, + 370 + ], + "originPercent": { + "x": 60.61, + "y": 30.98 + } + }, + { + "id": "iris-left", + "source": "left iris group", + "file": "iris-left.png", + "alphaBox": [ + 349, + 334, + 404, + 384 + ], + "originPercent": { + "x": 41.83, + "y": 31.91 + } + }, + { + "id": "iris-right", + "source": "right iris group", + "file": "iris-right.png", + "alphaBox": [ + 510, + 321, + 566, + 372 + ], + "originPercent": { + "x": 59.78, + "y": 30.8 + } + }, + { + "id": "pupil-left", + "source": "left pupil group", + "file": "pupil-left.png", + "alphaBox": [ + 374, + 350, + 389, + 369 + ], + "originPercent": { + "x": 42.39, + "y": 31.96 + } + }, + { + "id": "pupil-right", + "source": "right pupil group", + "file": "pupil-right.png", + "alphaBox": [ + 525, + 339, + 540, + 357 + ], + "originPercent": { + "x": 59.17, + "y": 30.93 + } + }, + { + "id": "highlight-left", + "source": "left eye highlight group", + "file": "highlight-left.png", + "alphaBox": [ + 367, + 349, + 389, + 378 + ], + "originPercent": { + "x": 42.0, + "y": 32.31 + } + }, + { + "id": "highlight-right", + "source": "right eye highlight group", + "file": "highlight-right.png", + "alphaBox": [ + 528, + 337, + 552, + 367 + ], + "originPercent": { + "x": 60.0, + "y": 31.29 + } + }, + { + "id": "lash-left", + "source": "left lash group", + "file": "lash-left.png", + "alphaBox": [ + 324, + 316, + 421, + 355 + ], + "originPercent": { + "x": 41.39, + "y": 29.82 + } + }, + { + "id": "lash-right", + "source": "right lash group", + "file": "lash-right.png", + "alphaBox": [ + 490, + 305, + 588, + 347 + ], + "originPercent": { + "x": 59.89, + "y": 28.98 + } + }, + { + "id": "eyelid-upper-left", + "source": "left upper eyelid group", + "file": "eyelid-upper-left.png", + "alphaBox": [ + 296, + 330, + 418, + 381 + ], + "originPercent": { + "x": 39.67, + "y": 31.6 + } + }, + { + "id": "eyelid-upper-right", + "source": "right upper eyelid group", + "file": "eyelid-upper-right.png", + "alphaBox": [ + 498, + 314, + 619, + 365 + ], + "originPercent": { + "x": 62.06, + "y": 30.18 + } + }, + { + "id": "eyelid-lower-left", + "source": "left lower eyelid group", + "file": "eyelid-lower-left.png", + "alphaBox": [ + 323, + 371, + 415, + 392 + ], + "originPercent": { + "x": 41.0, + "y": 33.91 + } + }, + { + "id": "eyelid-lower-right", + "source": "right lower eyelid group", + "file": "eyelid-lower-right.png", + "alphaBox": [ + 502, + 353, + 593, + 377 + ], + "originPercent": { + "x": 60.83, + "y": 32.44 + } + }, + { + "id": "brow-left", + "source": "left brow group", + "file": "brow-left.png", + "alphaBox": [ + 308, + 292, + 436, + 317 + ], + "originPercent": { + "x": 41.33, + "y": 27.07 + } + }, + { + "id": "brow-right", + "source": "right brow group", + "file": "brow-right.png", + "alphaBox": [ + 472, + 283, + 601, + 300 + ], + "originPercent": { + "x": 59.61, + "y": 25.91 + } + }, + { + "id": "hair-front", + "source": "front hair group", + "file": "hair-front.png", + "alphaBox": [ + 212, + 20, + 692, + 829 + ], + "originPercent": { + "x": 50.22, + "y": 37.73 + } + }, + { + "id": "hair-bangs", + "source": "bangs group", + "file": "hair-bangs.png", + "alphaBox": [ + 290, + 52, + 622, + 386 + ], + "originPercent": { + "x": 50.67, + "y": 7.0 + } + }, + { + "id": "hair-side-left-1", + "source": "left side hair 1 group", + "file": "hair-side-left-1.png", + "alphaBox": [ + 212, + 21, + 457, + 441 + ], + "originPercent": { + "x": 37.17, + "y": 6.35 + } + }, + { + "id": "hair-side-left-2", + "source": "left side hair 2 group", + "file": "hair-side-left-2.png", + "alphaBox": [ + 219, + 299, + 351, + 813 + ], + "originPercent": { + "x": 31.67, + "y": 32.06 + } + }, + { + "id": "hair-side-right-1", + "source": "right side hair 1 group", + "file": "hair-side-right-1.png", + "alphaBox": [ + 447, + 20, + 692, + 440 + ], + "originPercent": { + "x": 63.28, + "y": 6.26 + } + }, + { + "id": "hair-side-right-2", + "source": "right side hair 2 group", + "file": "hair-side-right-2.png", + "alphaBox": [ + 529, + 315, + 660, + 829 + ], + "originPercent": { + "x": 66.06, + "y": 33.48 + } + }, + { + "id": "mouth-neutral", + "source": "mouth smile group", + "file": "mouth-neutral.png", + "alphaBox": [ + 431, + 454, + 498, + 474 + ], + "originPercent": { + "x": 51.61, + "y": 41.24 + } + }, + { + "id": "hair-back-base", + "source": "top extra back hair layers", + "file": "hair-back-base.png", + "alphaBox": null, + "originPercent": { + "x": 50.0, + "y": 50.0 + } + }, + { + "id": "mouth-open", + "source": "hidden open mouth groups", + "file": "mouth-open.png", + "alphaBox": [ + 405, + 424, + 524, + 539 + ], + "originPercent": { + "x": 51.61, + "y": 42.8 + } + }, + { + "id": "mouth-warm", + "source": "derived from mouth-neutral bbox", + "file": "mouth-warm.png", + "alphaBox": [ + 427, + 453, + 502, + 473 + ], + "originPercent": { + "x": 51.61, + "y": 41.16 + } + }, + { + "id": "mouth-tired", + "source": "derived from mouth-neutral bbox", + "file": "mouth-tired.png", + "alphaBox": [ + 435, + 464, + 494, + 470 + ], + "originPercent": { + "x": 51.61, + "y": 41.51 + } + }, + { + "id": "mouth-sad", + "source": "derived from mouth-neutral bbox", + "file": "mouth-sad.png", + "alphaBox": [ + 433, + 454, + 496, + 469 + ], + "originPercent": { + "x": 51.61, + "y": 41.02 + } + }, + { + "id": "mouth-anxious", + "source": "derived from mouth-neutral bbox", + "file": "mouth-anxious.png", + "alphaBox": [ + 439, + 456, + 490, + 472 + ], + "originPercent": { + "x": 51.61, + "y": 41.24 + } + }, + { + "id": "mouth-startled", + "source": "derived from mouth-open bbox", + "file": "mouth-startled.png", + "alphaBox": [ + 424, + 439, + 505, + 522 + ], + "originPercent": { + "x": 51.61, + "y": 42.71 + } + }, + { + "id": "mouth-open-small", + "source": "derived from mouth-open bbox", + "file": "mouth-open-small.png", + "alphaBox": [ + 413, + 445, + 515, + 516 + ], + "originPercent": { + "x": 51.56, + "y": 42.71 + } + } + ], + "imageGenerationHarness": { + "canvas": [ + 900, + 1125 + ], + "rule": "Generate each replacement part on this exact canvas, transparent outside the part bbox, matching alphaBox and originPercent.", + "file": "imagegen-coordinate-harness.png" + } +} \ No newline at end of file diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/imagegen-coordinate-harness.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/imagegen-coordinate-harness.png new file mode 100644 index 0000000..917013d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/imagegen-coordinate-harness.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/blush-left.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/blush-left.png new file mode 100644 index 0000000..8daa4f4 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/blush-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/blush-right.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/blush-right.png new file mode 100644 index 0000000..da7302e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/blush-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/body.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/body.png new file mode 100644 index 0000000..62ed067 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/body.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/brow-left.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/brow-left.png new file mode 100644 index 0000000..e46628d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/brow-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/brow-right.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/brow-right.png new file mode 100644 index 0000000..31a23cf Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/brow-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/clavicle.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/clavicle.png new file mode 100644 index 0000000..c27643b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/clavicle.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/ear-left.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/ear-left.png new file mode 100644 index 0000000..3c835fd Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/ear-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/ear-right.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/ear-right.png new file mode 100644 index 0000000..9b6d2a9 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/ear-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eye-left.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eye-left.png new file mode 100644 index 0000000..977b438 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eye-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eye-right.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eye-right.png new file mode 100644 index 0000000..70d0c9b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eye-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eye-white-left.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eye-white-left.png new file mode 100644 index 0000000..7648743 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eye-white-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eye-white-right.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eye-white-right.png new file mode 100644 index 0000000..28dd0ad Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eye-white-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eyelid-lower-left.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eyelid-lower-left.png new file mode 100644 index 0000000..3828e2a Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eyelid-lower-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eyelid-lower-right.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eyelid-lower-right.png new file mode 100644 index 0000000..b42c910 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eyelid-lower-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eyelid-upper-left.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eyelid-upper-left.png new file mode 100644 index 0000000..44b885b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eyelid-upper-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eyelid-upper-right.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eyelid-upper-right.png new file mode 100644 index 0000000..1a4d864 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/eyelid-upper-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/face-base.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/face-base.png new file mode 100644 index 0000000..b50d8ef Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/face-base.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/face.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/face.png new file mode 100644 index 0000000..9c6a0d2 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/face.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-back-base.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-back-base.png new file mode 100644 index 0000000..c781559 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-back-base.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-back-left.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-back-left.png new file mode 100644 index 0000000..668c2c2 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-back-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-back-right.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-back-right.png new file mode 100644 index 0000000..92d2f00 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-back-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-bangs.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-bangs.png new file mode 100644 index 0000000..89f5690 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-bangs.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-front.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-front.png new file mode 100644 index 0000000..563006f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-front.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-side-left-1.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-side-left-1.png new file mode 100644 index 0000000..446099d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-side-left-1.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-side-left-2.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-side-left-2.png new file mode 100644 index 0000000..6b56e02 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-side-left-2.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-side-right-1.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-side-right-1.png new file mode 100644 index 0000000..f04c72c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-side-right-1.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-side-right-2.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-side-right-2.png new file mode 100644 index 0000000..984f7eb Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/hair-side-right-2.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/highlight-left.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/highlight-left.png new file mode 100644 index 0000000..ddb4163 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/highlight-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/highlight-right.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/highlight-right.png new file mode 100644 index 0000000..fe3dd9f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/highlight-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/iris-left.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/iris-left.png new file mode 100644 index 0000000..c1e1bfc Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/iris-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/iris-right.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/iris-right.png new file mode 100644 index 0000000..14d3eb0 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/iris-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/lash-left.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/lash-left.png new file mode 100644 index 0000000..66029e9 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/lash-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/lash-right.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/lash-right.png new file mode 100644 index 0000000..eaa9591 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/lash-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-anxious.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-anxious.png new file mode 100644 index 0000000..587546f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-anxious.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-neutral.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-neutral.png new file mode 100644 index 0000000..faef23e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-neutral.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-open-small.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-open-small.png new file mode 100644 index 0000000..7d4becc Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-open-small.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-open.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-open.png new file mode 100644 index 0000000..8b8b5b9 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-open.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-sad.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-sad.png new file mode 100644 index 0000000..733455d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-sad.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-startled.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-startled.png new file mode 100644 index 0000000..bd2ad21 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-startled.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-tired.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-tired.png new file mode 100644 index 0000000..a402e2c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-tired.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-warm.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-warm.png new file mode 100644 index 0000000..d8382df Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/mouth-warm.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/neck.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/neck.png new file mode 100644 index 0000000..73d23ca Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/neck.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/nose.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/nose.png new file mode 100644 index 0000000..a578365 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/nose.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/outfit-inner.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/outfit-inner.png new file mode 100644 index 0000000..4617c89 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/outfit-inner.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/outfit-outer.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/outfit-outer.png new file mode 100644 index 0000000..3fe8305 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/outfit-outer.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/outfit.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/outfit.png new file mode 100644 index 0000000..c6d6d1b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/outfit.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/pupil-left.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/pupil-left.png new file mode 100644 index 0000000..84e96ea Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/pupil-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/pupil-right.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/pupil-right.png new file mode 100644 index 0000000..b39e053 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/pupil-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/ribbon.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/ribbon.png new file mode 100644 index 0000000..0576c3f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/parts/ribbon.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/preview-detailed-parts-transparent.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/preview-detailed-parts-transparent.png new file mode 100644 index 0000000..3bb8561 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/preview-detailed-parts-transparent.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/preview-detailed-parts.png b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/preview-detailed-parts.png new file mode 100644 index 0000000..b1d879c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/detailed-parts/preview-detailed-parts.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/export_detailed_parts.py b/docs/avatar-art/seoyeon/live2d-psb/export_detailed_parts.py new file mode 100644 index 0000000..f82e8a3 --- /dev/null +++ b/docs/avatar-art/seoyeon/live2d-psb/export_detailed_parts.py @@ -0,0 +1,517 @@ +#!/usr/bin/env python3 +"""Export detailed app-ready Live2D-style raster parts from the source PSB. + +All exported PNGs keep the same normalized 900x1125 canvas. That makes every +part usable by the renderer without per-image CSS sizing hacks, and gives +image-generation follow-up work a stable coordinate harness. +""" +from __future__ import annotations + +import json +import math +import shutil +from collections.abc import Iterable, Sequence +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw +from psd_tools import PSDImage + +HERE = Path(__file__).resolve().parent +REPO_ROOT = HERE.parents[3] +PSB = Path( + "C:/Users/encep/OneDrive/" + "\ubb38\uc11c/\uce74\uce74\uc624\ud1a1 \ubc1b\uc740 \ud30c\uc77c/" + "\ub77c\ud22c\ub514 \uc5ec\uce90.psb" +) + +OUT = HERE / "detailed-parts" +PARTS = OUT / "parts" +APP_PARTS = REPO_ROOT / "apps/web/public/avatar/seoyeon-live2d-psb/parts" + +SOURCE_CANVAS = (1080, 1920) +TARGET_CANVAS = (900, 1125) +CROP_BOX = (0, 240, 1080, 1590) + + +def k(name: str) -> str: + """Keep the source file ASCII while addressing Korean PSB layer names.""" + return name.encode("utf-8").decode("unicode_escape") + + +N = { + "paper": k("\\uc6a9\\uc9c0"), + "back_hair": k("\\ub4b7\\uba38\\ub9ac"), + "back_hair_left": k("\\uc88c \\ub4b7\\uba38\\ub9ac"), + "back_hair_right": k("\\uc6b0 \\ub4b7\\uba38\\ub9ac"), + "body": k("\\ubab8"), + "neck": k("\\ubaa9"), + "clavicle": k("\\uc1c4\\uace8"), + "outfit": k("\\uc758\\uc0c1"), + "inner_outfit": k("\\uc548\\uc637"), + "outer_outfit": k("\\uac89\\uc637"), + "ribbon": k("\\ub9ac\\ubcf8"), + "face": k("\\uc5bc\\uad74"), + "ear_left": k("\\uadc0 \\uc88c"), + "ear_right": k("\\uadc0\\uc6b0"), + "face_base": k("\\uc5bc\\uad74 \\ubca0\\uc774\\uc2a4"), + "blush_left": k("\\uc88c \\ud64d\\uc870"), + "blush_right": k("\\uc6b0 \\ud64d\\uc870"), + "nose": k("\\ucf54"), + "eye_left": k("\\uc88c \\ub208"), + "eye_right": k("\\uc6b0 \\ub208"), + "white": k("\\ud770 \\uc790"), + "white_copy": k("\\ud770 \\uc790 \\ubcf5\\uc0ac"), + "iris": k("\\ud64d\\ucc44"), + "iris_copy": k("\\ud64d\\ucc44 \\ubcf5\\uc0ac"), + "pupil": k("\\ub3d9\\uacf5"), + "pupil_copy": k("\\ub3d9\\uacf5 \\ubcf5\\uc0ac"), + "highlight": k("\\ub208\\ub3d9\\uc790 \\ud558\\uc774\\ub77c\\uc774\\ud2b8"), + "highlight_copy": k("\\ub208\\ub3d9\\uc790 \\ud558\\uc774\\ub77c\\uc774\\ud2b8 \\ubcf5\\uc0ac"), + "lash": k("\\uc18d\\ub208\\uc379"), + "lash_copy": k("\\uc18d\\ub208\\uc379 \\ubcf5\\uc0ac"), + "upper_lid": k("\\uc717 \\ub208\\uaebc\\ud480"), + "upper_lid_copy": k("\\uc717 \\ub208\\uaebc\\ud480 \\ubcf5\\uc0ac"), + "lower_lid": k("\\uc544\\ub7ab \\ub208\\uaebc\\ud480"), + "lower_lid_copy": k("\\uc544\\ub7ab \\ub208\\uaebc\\ud480 \\ubcf5\\uc0ac"), + "brow": k("\\ub208\\uc379"), + "brow_copy": k("\\ub208\\uc379 \\ubcf5\\uc0ac"), + "front_hair": k("\\uba38\\ub9ac\\uce74\\ub77d"), + "bangs": k("\\uc55e\\uba38\\ub9ac"), + "side_left_2": k("\\uc88c \\uc606\\uba38\\ub9ac2"), + "side_right_2": k("\\uc6b0 \\uc606\\uba38\\ub9ac 2"), + "side_right_1": k("\\uc6b0 \\uc606\\uba38\\ub9ac 1"), + "side_left_1": k("\\uc88c \\uc606\\uba38\\ub9ac 1"), + "mouth": k("\\uc785"), + "mouth_inner": k("\\uc785\\uc548"), + "upper_teeth": k("\\uc717\\ub2c8"), + "lower_side_teeth": k("\\uc544\\ub798\\uc606\\ub2c8"), + "tongue": k("\\ud600"), + "lower_teeth": k("\\uc544\\ub7ab\\ub2c8"), + "upper_lip": k("\\uc785\\uc220 \\uc704"), + "lower_lip": k("\\uc785 \\uc220 \\uc544\\ub798"), + "smile": k("\\ubbf8\\uc18c"), + "back_extra_left": k("\\ub808\\uc774\\uc5b4 110 \\ubcf5\\uc0ac"), + "back_extra_right": k("\\ub808\\uc774\\uc5b4 110"), +} + +LAYER_ORDER = [ + "hair-back-base", + "hair-back-left", + "hair-back-right", + "body", + "outfit", + "ear-left", + "ear-right", + "face-base", + "blush-left", + "blush-right", + "eye-white-left", + "eye-white-right", + "iris-left", + "iris-right", + "pupil-left", + "pupil-right", + "highlight-left", + "highlight-right", + "lash-left", + "lash-right", + "eyelid-upper-left", + "eyelid-upper-right", + "eyelid-lower-left", + "eyelid-lower-right", + "brow-left", + "brow-right", + "nose", + "hair-side-left-1", + "hair-side-right-1", + "hair-side-left-2", + "hair-side-right-2", + "hair-bangs", + "mouth-neutral", +] + + +def alpha_bbox(im: Image.Image, threshold: int = 8) -> tuple[int, int, int, int] | None: + alpha = np.array(im.convert("RGBA").getchannel("A")) + ys, xs = np.where(alpha > threshold) + if len(xs) == 0: + return None + return int(xs.min()), int(ys.min()), int(xs.max() + 1), int(ys.max() + 1) + + +def resize_rgba(im: Image.Image, size: tuple[int, int]) -> Image.Image: + arr = np.array(im.convert("RGBA")).astype(np.float32) + alpha = arr[:, :, 3:4] / 255.0 + premultiplied = arr.copy() + premultiplied[:, :, :3] *= alpha + resized = Image.fromarray(np.clip(premultiplied, 0, 255).astype(np.uint8), "RGBA").resize( + size, + Image.Resampling.LANCZOS, + ) + out = np.array(resized).astype(np.float32) + out_alpha = out[:, :, 3:4] / 255.0 + out[:, :, :3] = np.where(out_alpha > 0.001, out[:, :, :3] / np.maximum(out_alpha, 0.001), 0) + out[out[:, :, 3] < 1, :3] = 0 + return Image.fromarray(np.clip(out, 0, 255).astype(np.uint8), "RGBA") + + +def normalize(full_canvas: Image.Image) -> Image.Image: + return resize_rgba(full_canvas.crop(CROP_BOX), TARGET_CANVAS) + + +def blank(size: tuple[int, int] = SOURCE_CANVAS) -> Image.Image: + return Image.new("RGBA", size, (0, 0, 0, 0)) + + +def find_path(root, path: Sequence[str]): + current = root + for name in path: + for child in current: + if child.name == name: + current = child + break + else: + rendered = " / ".join(item.encode("unicode_escape").decode() for item in path) + raise KeyError(f"Missing PSB layer path: {rendered}") + return current + + +def composite_layer(layer) -> Image.Image: + im = layer.composite(viewport=(0, 0, SOURCE_CANVAS[0], SOURCE_CANVAS[1]), force=True) + return im.convert("RGBA") if im else blank() + + +def composite_path(psd: PSDImage, path: Sequence[str]) -> Image.Image: + return composite_layer(find_path(psd, path)) + + +def composite_many(images: Iterable[Image.Image]) -> Image.Image: + canvas = blank() + for image in images: + canvas.alpha_composite(image.convert("RGBA")) + return canvas + + +def composite_with_visibility( + psd: PSDImage, + root_path: Sequence[str], + visible_paths: Sequence[Sequence[str]], + hidden_paths: Sequence[Sequence[str]] = (), +) -> Image.Image: + changed = [] + try: + for path in visible_paths: + layer = find_path(psd, path) + changed.append((layer, layer.visible)) + layer.visible = True + for path in hidden_paths: + layer = find_path(psd, path) + changed.append((layer, layer.visible)) + layer.visible = False + return composite_path(psd, root_path) + finally: + for layer, visible in reversed(changed): + layer.visible = visible + + +def dominant_color(im: Image.Image) -> tuple[int, int, int, int]: + arr = np.array(im.convert("RGBA")) + mask = arr[:, :, 3] > 24 + if not np.any(mask): + return (130, 72, 72, 255) + rgb = np.median(arr[mask][:, :3], axis=0) + alpha = np.percentile(arr[mask][:, 3], 85) + return (int(rgb[0]), int(rgb[1]), int(rgb[2]), int(max(alpha, 220))) + + +def paste_scaled( + source: Image.Image, + scale_x: float, + scale_y: float, + offset_y: int = 0, + rotate: float = 0, +) -> Image.Image: + source = source.convert("RGBA") + box = alpha_bbox(source) + if box is None: + return blank(TARGET_CANVAS) + crop = source.crop(box) + new_size = ( + max(1, int(round(crop.width * scale_x))), + max(1, int(round(crop.height * scale_y))), + ) + crop = crop.resize(new_size, Image.Resampling.LANCZOS) + if rotate: + crop = crop.rotate(rotate, resample=Image.Resampling.BICUBIC, expand=True) + cx = (box[0] + box[2]) // 2 + cy = (box[1] + box[3]) // 2 + offset_y + out = blank(TARGET_CANVAS) + out.alpha_composite(crop, (cx - crop.width // 2, cy - crop.height // 2)) + return out + + +def make_curve_mouth(reference: Image.Image, curve: float, width_scale: float = 1.0) -> Image.Image: + ref = reference.convert("RGBA") + box = alpha_bbox(ref) + out = blank(TARGET_CANVAS) + if box is None: + return out + color = dominant_color(ref) + cx = (box[0] + box[2]) / 2 + cy = (box[1] + box[3]) / 2 + 1 + width = max(38, (box[2] - box[0]) * width_scale) + height = max(7, (box[3] - box[1]) * 0.48) + points = [] + for i in range(24): + t = i / 23 + x = cx - width / 2 + width * t + y = cy + math.sin(t * math.pi) * curve * height + points.append((x, y)) + draw = ImageDraw.Draw(out) + shadow = (max(0, color[0] - 36), max(0, color[1] - 26), max(0, color[2] - 20), 92) + draw.line([(x, y + 1.4) for x, y in points], fill=shadow, width=4, joint="curve") + draw.line(points, fill=color, width=3, joint="curve") + return out.filter(Image.Resampling.BICUBIC) if False else out + + +def origin_for(part_id: str, box: tuple[int, int, int, int] | None) -> dict[str, float]: + if not box: + return {"x": 50.0, "y": 50.0} + x0, y0, x1, y1 = box + cx = (x0 + x1) / 2 / TARGET_CANVAS[0] * 100 + if part_id.startswith("hair-back"): + y = (y0 + (y1 - y0) * 0.08) / TARGET_CANVAS[1] * 100 + elif part_id.startswith("hair-side"): + y = (y0 + (y1 - y0) * 0.12) / TARGET_CANVAS[1] * 100 + elif part_id == "hair-bangs": + y = (y0 + (y1 - y0) * 0.08) / TARGET_CANVAS[1] * 100 + elif part_id.startswith("brow"): + y = (y0 + y1) / 2 / TARGET_CANVAS[1] * 100 + else: + y = (y0 + y1) / 2 / TARGET_CANVAS[1] * 100 + return {"x": round(cx, 2), "y": round(y, 2)} + + +def save_part(entries: list[dict[str, object]], part_id: str, source: str, image: Image.Image) -> Image.Image: + normalized = normalize(image) if image.size == SOURCE_CANVAS else image.convert("RGBA") + path = PARTS / f"{part_id}.png" + normalized.save(path, optimize=True) + box = alpha_bbox(normalized) + entries.append( + { + "id": part_id, + "source": source, + "file": path.name, + "alphaBox": list(box) if box else None, + "originPercent": origin_for(part_id, box), + } + ) + return normalized + + +def draw_label(draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str) -> None: + x, y = xy + draw.rectangle((x - 3, y - 2, x + len(text) * 6 + 4, y + 12), fill=(22, 29, 27, 210)) + draw.text((x, y), text, fill=(230, 236, 232, 255)) + + +def make_contact(entries: list[dict[str, object]]) -> None: + tile_w, tile_h = 180, 180 + cols = 5 + rows = math.ceil(len(entries) / cols) + sheet = Image.new("RGBA", (tile_w * cols, tile_h * rows), (30, 39, 36, 255)) + draw = ImageDraw.Draw(sheet) + for i, entry in enumerate(entries): + layer = Image.open(PARTS / str(entry["file"])).convert("RGBA") + box = alpha_bbox(layer) + thumb = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) if box is None else layer.crop(box) + thumb.thumbnail((tile_w - 22, tile_h - 44), Image.Resampling.LANCZOS) + x = (i % cols) * tile_w + (tile_w - thumb.width) // 2 + y = (i // cols) * tile_h + 12 + sheet.alpha_composite(thumb, (x, y)) + draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 28), str(entry["id"]), fill=(210, 222, 216, 255)) + draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 14), str(entry["alphaBox"]), fill=(150, 166, 160, 255)) + sheet.save(OUT / "detailed-parts-contact.png", optimize=True) + + +def make_preview(layer_order: Sequence[str]) -> None: + preview = Image.new("RGBA", TARGET_CANVAS, (30, 39, 36, 255)) + transparent = blank(TARGET_CANVAS) + for part_id in layer_order: + im = Image.open(PARTS / f"{part_id}.png").convert("RGBA") + preview.alpha_composite(im) + transparent.alpha_composite(im) + preview.save(OUT / "preview-detailed-parts.png", optimize=True) + transparent.save(OUT / "preview-detailed-parts-transparent.png", optimize=True) + + +def make_imagegen_harness(entries: list[dict[str, object]]) -> None: + base = Image.open(OUT / "preview-detailed-parts.png").convert("RGBA") + draw = ImageDraw.Draw(base) + focus = { + "hair-back-left", + "hair-back-right", + "hair-side-left-1", + "hair-side-right-1", + "hair-side-left-2", + "hair-side-right-2", + "hair-bangs", + "eye-white-left", + "eye-white-right", + "iris-left", + "iris-right", + "brow-left", + "brow-right", + "mouth-neutral", + "mouth-open", + } + for entry in entries: + if entry["id"] not in focus or not entry["alphaBox"]: + continue + x0, y0, x1, y1 = entry["alphaBox"] + color = (120, 223, 177, 210) if str(entry["id"]).startswith("hair") else (255, 214, 92, 220) + if str(entry["id"]).startswith("mouth"): + color = (255, 128, 128, 220) + draw.rectangle((x0, y0, x1, y1), outline=color, width=2) + origin = entry["originPercent"] + ox = int(float(origin["x"]) / 100 * TARGET_CANVAS[0]) + oy = int(float(origin["y"]) / 100 * TARGET_CANVAS[1]) + draw.ellipse((ox - 4, oy - 4, ox + 4, oy + 4), fill=color) + draw_label(draw, (x0, max(0, y0 - 14)), str(entry["id"])) + base.save(OUT / "imagegen-coordinate-harness.png", optimize=True) + + +def sync_to_app() -> None: + APP_PARTS.mkdir(parents=True, exist_ok=True) + for source in PARTS.glob("*.png"): + shutil.copy2(source, APP_PARTS / source.name) + + +def main() -> int: + if not PSB.exists(): + raise FileNotFoundError(PSB) + + OUT.mkdir(parents=True, exist_ok=True) + PARTS.mkdir(parents=True, exist_ok=True) + + psd = PSDImage.open(PSB) + entries: list[dict[str, object]] = [] + + simple_parts: list[tuple[str, str, Sequence[str]]] = [ + ("hair-back-left", "back hair left group", [N["back_hair"], N["back_hair_left"]]), + ("hair-back-right", "back hair right group", [N["back_hair"], N["back_hair_right"]]), + ("body", "body group", [N["body"]]), + ("neck", "neck group", [N["body"], N["neck"]]), + ("clavicle", "clavicle group", [N["body"], N["clavicle"]]), + ("outfit", "outfit group", [N["outfit"]]), + ("outfit-inner", "inner outfit group", [N["outfit"], N["inner_outfit"]]), + ("outfit-outer", "outer outfit group", [N["outfit"], N["outer_outfit"]]), + ("ribbon", "ribbon group", [N["outfit"], N["ribbon"]]), + ("face", "face group", [N["face"]]), + ("face-base", "face base group", [N["face"], N["face_base"]]), + ("ear-left", "left ear group", [N["face"], N["ear_left"]]), + ("ear-right", "right ear group", [N["face"], N["ear_right"]]), + ("blush-left", "left blush group", [N["face"], N["blush_left"]]), + ("blush-right", "right blush group", [N["face"], N["blush_right"]]), + ("nose", "nose group", [N["face"], N["nose"]]), + ("eye-left", "left eye group", [N["eye_left"]]), + ("eye-right", "right eye group", [N["eye_right"]]), + ("eye-white-left", "left eye white group", [N["eye_left"], N["white"]]), + ("eye-white-right", "right eye white group", [N["eye_right"], N["white_copy"]]), + ("iris-left", "left iris group", [N["eye_left"], N["iris"]]), + ("iris-right", "right iris group", [N["eye_right"], N["iris_copy"]]), + ("pupil-left", "left pupil group", [N["eye_left"], N["pupil"]]), + ("pupil-right", "right pupil group", [N["eye_right"], N["pupil_copy"]]), + ("highlight-left", "left eye highlight group", [N["eye_left"], N["highlight"]]), + ("highlight-right", "right eye highlight group", [N["eye_right"], N["highlight_copy"]]), + ("lash-left", "left lash group", [N["eye_left"], N["lash"]]), + ("lash-right", "right lash group", [N["eye_right"], N["lash_copy"]]), + ("eyelid-upper-left", "left upper eyelid group", [N["eye_left"], N["upper_lid"]]), + ("eyelid-upper-right", "right upper eyelid group", [N["eye_right"], N["upper_lid_copy"]]), + ("eyelid-lower-left", "left lower eyelid group", [N["eye_left"], N["lower_lid"]]), + ("eyelid-lower-right", "right lower eyelid group", [N["eye_right"], N["lower_lid_copy"]]), + ("brow-left", "left brow group", [N["eye_left"], N["brow"]]), + ("brow-right", "right brow group", [N["eye_right"], N["brow_copy"]]), + ("hair-front", "front hair group", [N["front_hair"]]), + ("hair-bangs", "bangs group", [N["front_hair"], N["bangs"]]), + ("hair-side-left-1", "left side hair 1 group", [N["front_hair"], N["side_left_1"]]), + ("hair-side-left-2", "left side hair 2 group", [N["front_hair"], N["side_left_2"]]), + ("hair-side-right-1", "right side hair 1 group", [N["front_hair"], N["side_right_1"]]), + ("hair-side-right-2", "right side hair 2 group", [N["front_hair"], N["side_right_2"]]), + ("mouth-neutral", "mouth smile group", [N["mouth"], N["smile"]]), + ] + + for part_id, source, path in simple_parts: + save_part(entries, part_id, source, composite_path(psd, path)) + + hair_back_base = composite_many( + [ + composite_path(psd, [N["back_extra_left"]]), + composite_path(psd, [N["back_extra_right"]]), + ] + ) + save_part(entries, "hair-back-base", "top extra back hair layers", hair_back_base) + + mouth_open_groups = [ + [N["mouth"], N["mouth_inner"]], + [N["mouth"], N["upper_teeth"]], + [N["mouth"], N["lower_side_teeth"]], + [N["mouth"], N["tongue"]], + [N["mouth"], N["lower_teeth"]], + [N["mouth"], N["upper_lip"]], + [N["mouth"], N["lower_lip"]], + ] + mouth_open = save_part( + entries, + "mouth-open", + "hidden open mouth groups", + composite_with_visibility( + psd, + [N["mouth"]], + visible_paths=mouth_open_groups, + hidden_paths=[[N["mouth"], N["smile"]]], + ), + ) + mouth_neutral = Image.open(PARTS / "mouth-neutral.png").convert("RGBA") + save_part(entries, "mouth-warm", "derived from mouth-neutral bbox", paste_scaled(mouth_neutral, 1.12, 1.02, offset_y=-1)) + save_part(entries, "mouth-tired", "derived from mouth-neutral bbox", make_curve_mouth(mouth_neutral, curve=0.15, width_scale=0.86)) + save_part(entries, "mouth-sad", "derived from mouth-neutral bbox", make_curve_mouth(mouth_neutral, curve=-1.0, width_scale=0.9)) + save_part(entries, "mouth-anxious", "derived from mouth-neutral bbox", paste_scaled(mouth_neutral, 0.76, 0.82, offset_y=0)) + save_part(entries, "mouth-startled", "derived from mouth-open bbox", paste_scaled(mouth_open, 0.68, 0.72, offset_y=-1)) + save_part(entries, "mouth-open-small", "derived from mouth-open bbox", paste_scaled(mouth_open, 0.86, 0.62, offset_y=-1)) + + make_preview(LAYER_ORDER) + make_contact(entries) + make_imagegen_harness(entries) + + (OUT / "detailed-parts-manifest.json").write_text( + json.dumps( + { + "source": str(PSB), + "sourceCanvas": SOURCE_CANVAS, + "targetCanvas": TARGET_CANVAS, + "cropBox": CROP_BOX, + "layerOrder": LAYER_ORDER, + "parts": entries, + "imageGenerationHarness": { + "canvas": TARGET_CANVAS, + "rule": "Generate each replacement part on this exact canvas, transparent outside the part bbox, matching alphaBox and originPercent.", + "file": "imagegen-coordinate-harness.png", + }, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + sync_to_app() + print(f"exported {len(entries)} detailed parts") + print(f"parts {PARTS}") + print(f"app parts {APP_PARTS}") + print(f"harness {OUT / 'imagegen-coordinate-harness.png'}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/avatar-art/seoyeon/live2d-psb/export_psb_layers.py b/docs/avatar-art/seoyeon/live2d-psb/export_psb_layers.py new file mode 100644 index 0000000..b3c4fbf --- /dev/null +++ b/docs/avatar-art/seoyeon/live2d-psb/export_psb_layers.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Export PSB layers through ImageMagick and preserve canvas offsets.""" +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +HERE = Path(__file__).resolve().parent +OUT_RAW = HERE / "layers-raw" +OUT_CANVAS = HERE / "layers-canvas" +MANIFEST = HERE / "layers-manifest.json" + +PSB = Path(r"C:\Users\encep\OneDrive\문서\카카오톡 받은 파일\라투디 여캐.psb") +CANVAS_SIZE = (1080, 1920) + + +def run_magick(args: list[str]) -> str: + result = subprocess.run( + ["magick", *args], + cwd=HERE, + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or result.stdout.strip()) + return result.stdout + + +def identify_layers() -> list[dict[str, object]]: + output = run_magick(["identify", "-format", "%p\t%w\t%h\t%g\t%X\t%Y\n", str(PSB)]) + layers: list[dict[str, object]] = [] + for line in output.splitlines(): + if not line.strip(): + continue + index_s, width_s, height_s, page, x_s, y_s = line.split("\t") + layers.append( + { + "index": int(index_s), + "width": int(width_s), + "height": int(height_s), + "page": page, + "x": int(x_s), + "y": int(y_s), + } + ) + return layers + + +def alpha_bbox(im: Image.Image, threshold: int = 8) -> tuple[int, int, int, int] | None: + arr = np.array(im.convert("RGBA").getchannel("A")) + ys, xs = np.where(arr > threshold) + if len(xs) == 0: + return None + return int(xs.min()), int(ys.min()), int(xs.max() + 1), int(ys.max() + 1) + + +def export_layer(layer: dict[str, object]) -> dict[str, object]: + index = int(layer["index"]) + raw = OUT_RAW / f"layer-{index:03d}.png" + full = OUT_CANVAS / f"layer-{index:03d}.png" + + run_magick([f"{PSB}[{index}]", "-background", "none", "-alpha", "on", "png32:" + str(raw)]) + src = Image.open(raw).convert("RGBA") + full_canvas = Image.new("RGBA", CANVAS_SIZE, (0, 0, 0, 0)) + full_canvas.alpha_composite(src, (int(layer["x"]), int(layer["y"]))) + full_canvas.save(full, optimize=True) + + bbox = alpha_bbox(full_canvas) + return { + **layer, + "raw": raw.relative_to(HERE).as_posix(), + "canvas": full.relative_to(HERE).as_posix(), + "alphaBox": list(bbox) if bbox else None, + } + + +def make_contact(layers: list[dict[str, object]]) -> None: + visible = [layer for layer in layers if layer.get("alphaBox") is not None] + tile_w, tile_h = 180, 220 + cols = 6 + rows = (len(visible) + cols - 1) // cols + sheet = Image.new("RGBA", (cols * tile_w, rows * tile_h), (30, 39, 36, 255)) + draw = ImageDraw.Draw(sheet) + for i, layer in enumerate(visible): + full = Image.open(HERE / str(layer["canvas"])).convert("RGBA") + box = tuple(layer["alphaBox"]) + thumb = full.crop(box) + thumb.thumbnail((tile_w - 18, tile_h - 54), Image.Resampling.LANCZOS) + x = (i % cols) * tile_w + (tile_w - thumb.width) // 2 + y = (i // cols) * tile_h + 12 + sheet.alpha_composite(thumb, (x, y)) + label = f"{int(layer['index']):03d} {layer['width']}x{layer['height']} {layer['page']}" + draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 36), label, fill=(210, 222, 216, 255)) + draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 20), str(layer["alphaBox"]), fill=(150, 166, 160, 255)) + sheet.save(HERE / "layers-contact.png", optimize=True) + + +def main() -> int: + if not PSB.exists(): + raise FileNotFoundError(PSB) + OUT_RAW.mkdir(parents=True, exist_ok=True) + OUT_CANVAS.mkdir(parents=True, exist_ok=True) + + layers = identify_layers() + exported = [export_layer(layer) for layer in layers] + MANIFEST.write_text(json.dumps({"source": str(PSB), "canvas": CANVAS_SIZE, "layers": exported}, indent=2), encoding="utf-8") + make_contact(exported) + + flattened = HERE / "flattened.png" + run_magick([f"{PSB}[0]", "-background", "none", "-alpha", "on", "png32:" + str(flattened)]) + print(f"exported {len(exported)} layers") + print(f"manifest {MANIFEST}") + print(f"contact {HERE / 'layers-contact.png'}") + print(f"flattened {flattened}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/avatar-art/seoyeon/live2d-psb/export_top_groups.py b/docs/avatar-art/seoyeon/live2d-psb/export_top_groups.py new file mode 100644 index 0000000..b53d483 --- /dev/null +++ b/docs/avatar-art/seoyeon/live2d-psb/export_top_groups.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Export top-level PSB groups as normalized app-ready raster layers.""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Iterable + +import numpy as np +from PIL import Image, ImageDraw +from psd_tools import PSDImage + +HERE = Path(__file__).resolve().parent +PSB = Path(r"C:\Users\encep\OneDrive\문서\카카오톡 받은 파일\라투디 여캐.psb") +OUT = HERE / "top-groups" +PARTS = OUT / "parts" + +SOURCE_CANVAS = (1080, 1920) +TARGET_CANVAS = (900, 1125) +CROP_BOX = (0, 240, 1080, 1590) + +LAYER_ORDER = [ + "hair-back", + "body", + "outfit", + "face", + "eye-left", + "eye-right", + "hair-front", + "mouth", +] + +GROUP_MAP = { + "뒷머리": "hair-back", + "몸": "body", + "의상": "outfit", + "얼굴": "face", + "좌 눈": "eye-left", + "우 눈": "eye-right", + "머리카락": "hair-front", + "입": "mouth", +} + +EXTRA_BACK_HAIR = {"레이어 110", "레이어 110 복사"} + + +def alpha_bbox(im: Image.Image, threshold: int = 8) -> tuple[int, int, int, int] | None: + alpha = np.array(im.convert("RGBA").getchannel("A")) + ys, xs = np.where(alpha > threshold) + if len(xs) == 0: + return None + return int(xs.min()), int(ys.min()), int(xs.max() + 1), int(ys.max() + 1) + + +def resize_rgba(im: Image.Image, size: tuple[int, int]) -> Image.Image: + arr = np.array(im.convert("RGBA")).astype(np.float32) + alpha = arr[:, :, 3:4] / 255.0 + premultiplied = arr.copy() + premultiplied[:, :, :3] *= alpha + resized = Image.fromarray(np.clip(premultiplied, 0, 255).astype(np.uint8), "RGBA").resize( + size, + Image.Resampling.LANCZOS, + ) + out = np.array(resized).astype(np.float32) + out_alpha = out[:, :, 3:4] / 255.0 + out[:, :, :3] = np.where(out_alpha > 0.001, out[:, :, :3] / np.maximum(out_alpha, 0.001), 0) + out[out[:, :, 3] < 1, :3] = 0 + return Image.fromarray(np.clip(out, 0, 255).astype(np.uint8), "RGBA") + + +def normalize(full_canvas: Image.Image) -> Image.Image: + return resize_rgba(full_canvas.crop(CROP_BOX), TARGET_CANVAS) + + +def composite_layers(layers: Iterable[Image.Image]) -> Image.Image: + canvas = Image.new("RGBA", SOURCE_CANVAS, (0, 0, 0, 0)) + for layer in layers: + canvas.alpha_composite(layer.convert("RGBA")) + return canvas + + +def make_contact(entries: list[dict[str, object]]) -> None: + tile_w, tile_h = 180, 180 + sheet = Image.new("RGBA", (tile_w * 4, tile_h * 2), (30, 39, 36, 255)) + draw = ImageDraw.Draw(sheet) + for i, entry in enumerate(entries): + layer = Image.open(PARTS / str(entry["file"])).convert("RGBA") + box = alpha_bbox(layer) + thumb = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) if box is None else layer.crop(box) + thumb.thumbnail((tile_w - 22, tile_h - 46), Image.Resampling.LANCZOS) + x = (i % 4) * tile_w + (tile_w - thumb.width) // 2 + y = (i // 4) * tile_h + 12 + sheet.alpha_composite(thumb, (x, y)) + draw.text(((i % 4) * tile_w + 8, (i // 4) * tile_h + tile_h - 28), str(entry["id"]), fill=(210, 222, 216, 255)) + draw.text(((i % 4) * tile_w + 8, (i // 4) * tile_h + tile_h - 14), str(entry["source"]), fill=(150, 166, 160, 255)) + sheet.save(OUT / "top-groups-contact.png", optimize=True) + + +def main() -> int: + psd = PSDImage.open(PSB) + PARTS.mkdir(parents=True, exist_ok=True) + + top = {layer.name: layer for layer in psd} + entries: list[dict[str, object]] = [] + + extra_back = [top[name].composite(viewport=(0, 0, psd.width, psd.height)) for name in EXTRA_BACK_HAIR if name in top] + + for source_name, part_id in GROUP_MAP.items(): + if source_name not in top: + raise KeyError(f"Missing top-level group: {source_name}") + image = top[source_name].composite(viewport=(0, 0, psd.width, psd.height)) + if part_id == "hair-back": + image = composite_layers([*extra_back, image]) + normalized = normalize(image) + file_name = f"{part_id}.png" + normalized.save(PARTS / file_name, optimize=True) + entries.append( + { + "id": part_id, + "source": source_name, + "file": file_name, + "alphaBox": list(alpha_bbox(normalized) or ()), + } + ) + + preview = Image.new("RGBA", TARGET_CANVAS, (30, 39, 36, 255)) + for part_id in LAYER_ORDER: + preview.alpha_composite(Image.open(PARTS / f"{part_id}.png").convert("RGBA")) + preview.save(OUT / "preview-psb-groups.png", optimize=True) + + transparent = Image.new("RGBA", TARGET_CANVAS, (0, 0, 0, 0)) + for part_id in LAYER_ORDER: + transparent.alpha_composite(Image.open(PARTS / f"{part_id}.png").convert("RGBA")) + transparent.save(OUT / "preview-psb-groups-transparent.png", optimize=True) + + (OUT / "top-groups-manifest.json").write_text( + json.dumps( + { + "source": str(PSB), + "sourceCanvas": SOURCE_CANVAS, + "targetCanvas": TARGET_CANVAS, + "cropBox": CROP_BOX, + "layerOrder": LAYER_ORDER, + "parts": entries, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + make_contact(entries) + print(f"exported {len(entries)} top-level groups") + print(f"preview {OUT / 'preview-psb-groups.png'}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/avatar-art/seoyeon/live2d-psb/extract_imagegen_eye_variants.py b/docs/avatar-art/seoyeon/live2d-psb/extract_imagegen_eye_variants.py new file mode 100644 index 0000000..cf0a8ed --- /dev/null +++ b/docs/avatar-art/seoyeon/live2d-psb/extract_imagegen_eye_variants.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Turn an imagegen eye atlas into coordinate-locked app eye parts.""" +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +import cv2 +import numpy as np +from PIL import Image, ImageDraw + +HERE = Path(__file__).resolve().parent +REPO_ROOT = HERE.parents[3] +ATLAS = HERE / "imagegen" / "eye-expression-atlas-v1.png" +OUT = HERE / "imagegen" / "eye-variants" +PARTS = OUT / "parts" +APP_PARTS = REPO_ROOT / "apps/web/public/avatar/seoyeon-live2d-psb/parts" + +CANVAS = (900, 1125) + +TARGETS = { + "joy": { + "brow-left": (308, 292, 436, 317), + "brow-right": (472, 283, 601, 300), + "eye-left": (296, 312, 436, 365), + "eye-right": (472, 303, 619, 356), + }, + "sad": { + "brow-left": (308, 294, 436, 322), + "brow-right": (472, 286, 601, 308), + "eye-left": (296, 306, 436, 392), + "eye-right": (472, 297, 619, 377), + }, + "startled": { + "brow-left": (308, 286, 436, 313), + "brow-right": (472, 277, 601, 300), + "eye-left": (296, 292, 436, 392), + "eye-right": (472, 283, 619, 377), + }, +} + +ROW_NAMES = ["joy", "sad", "startled"] +ROW_PARTS = ["brow-left", "brow-right", "eye-left", "eye-right"] + + +def alpha_bbox(im: Image.Image, threshold: int = 8) -> tuple[int, int, int, int] | None: + alpha = np.array(im.convert("RGBA").getchannel("A")) + ys, xs = np.where(alpha > threshold) + if len(xs) == 0: + return None + return int(xs.min()), int(ys.min()), int(xs.max() + 1), int(ys.max() + 1) + + +def chroma_to_alpha(im: Image.Image) -> Image.Image: + arr_u8 = np.array(im.convert("RGBA")) + rgb_u8 = arr_u8[:, :, :3] + hsv = cv2.cvtColor(rgb_u8, cv2.COLOR_RGB2HSV) + hue = hsv[:, :, 0] + sat = hsv[:, :, 1] + val = hsv[:, :, 2] + green_bg = (hue >= 42) & (hue <= 92) & (sat > 45) & (val > 18) + # Keep a tiny soft edge by making only clearly non-green pixels opaque. + alpha = np.where(green_bg, 0, 255).astype(np.uint8) + alpha = cv2.medianBlur(alpha, 3) + arr = arr_u8.astype(np.float32) + rgb = arr[:, :, :3] + greenish_edge = (rgb[:, :, 1] > rgb[:, :, 0] + 8) & (rgb[:, :, 1] > rgb[:, :, 2] + 8) + rgb[:, :, 1] = np.where(greenish_edge, np.minimum(rgb[:, :, 1], np.maximum(rgb[:, :, 0], rgb[:, :, 2]) + 12), rgb[:, :, 1]) + arr[:, :, :3] = rgb + arr[:, :, 3] = alpha + arr[arr[:, :, 3] < 2, :3] = 0 + return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGBA") + + +def component_boxes(alpha_im: Image.Image) -> list[tuple[int, int, int, int]]: + alpha = np.array(alpha_im.getchannel("A")) + mask = (alpha > 22).astype(np.uint8) + num, _labels, stats, _centroids = cv2.connectedComponentsWithStats(mask, 8) + boxes: list[tuple[int, int, int, int, int]] = [] + for i in range(1, num): + x, y, w, h, area = stats[i] + if area > 200: + boxes.append((int(x), int(y), int(x + w), int(y + h), int(area))) + boxes.sort(key=lambda box: (box[1], box[0])) + if len(boxes) != 12: + raise RuntimeError(f"Expected 12 eye atlas components, got {len(boxes)}: {boxes}") + return [(x0, y0, x1, y1) for x0, y0, x1, y1, _area in boxes] + + +def crop_with_padding(im: Image.Image, box: tuple[int, int, int, int], padding: int = 10) -> Image.Image: + x0, y0, x1, y1 = box + x0 = max(0, x0 - padding) + y0 = max(0, y0 - padding) + x1 = min(im.width, x1 + padding) + y1 = min(im.height, y1 + padding) + return im.crop((x0, y0, x1, y1)) + + +def fit_into_canvas(crop: Image.Image, target: tuple[int, int, int, int]) -> Image.Image: + target_w = target[2] - target[0] + target_h = target[3] - target[1] + box = alpha_bbox(crop) + source = crop.crop(box) if box else crop + scale = min(target_w / source.width, target_h / source.height) + size = (max(1, round(source.width * scale)), max(1, round(source.height * scale))) + source = source.resize(size, Image.Resampling.LANCZOS) + canvas = Image.new("RGBA", CANVAS, (0, 0, 0, 0)) + x = target[0] + (target_w - source.width) // 2 + y = target[1] + (target_h - source.height) // 2 + canvas.alpha_composite(source, (x, y)) + return canvas + + +def make_contact(entries: list[dict[str, object]]) -> None: + tile_w, tile_h = 180, 150 + cols = 4 + rows = 3 + sheet = Image.new("RGBA", (tile_w * cols, tile_h * rows), (30, 39, 36, 255)) + draw = ImageDraw.Draw(sheet) + for i, entry in enumerate(entries): + part = Image.open(PARTS / str(entry["file"])).convert("RGBA") + box = alpha_bbox(part) + thumb = part.crop(box) if box else part + thumb.thumbnail((tile_w - 24, tile_h - 42), Image.Resampling.LANCZOS) + x = (i % cols) * tile_w + (tile_w - thumb.width) // 2 + y = (i // cols) * tile_h + 10 + sheet.alpha_composite(thumb, (x, y)) + draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 30), str(entry["id"]), fill=(220, 228, 224, 255)) + draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 15), str(entry["alphaBox"]), fill=(150, 166, 160, 255)) + sheet.save(OUT / "eye-variants-contact.png", optimize=True) + + +def sync_to_app() -> None: + APP_PARTS.mkdir(parents=True, exist_ok=True) + for source in PARTS.glob("*.png"): + shutil.copy2(source, APP_PARTS / source.name) + + +def main() -> int: + if not ATLAS.exists(): + raise FileNotFoundError(ATLAS) + OUT.mkdir(parents=True, exist_ok=True) + PARTS.mkdir(parents=True, exist_ok=True) + + keyed = chroma_to_alpha(Image.open(ATLAS)) + keyed.save(OUT / "eye-expression-atlas-alpha.png", optimize=True) + boxes = component_boxes(keyed) + + entries: list[dict[str, object]] = [] + for row_index, variant in enumerate(ROW_NAMES): + row_boxes = boxes[row_index * 4 : row_index * 4 + 4] + for part_name, source_box in zip(ROW_PARTS, row_boxes): + part_id = f"eyegen-{variant}-{part_name}" + crop = crop_with_padding(keyed, source_box) + canvas = fit_into_canvas(crop, TARGETS[variant][part_name]) + path = PARTS / f"{part_id}.png" + canvas.save(path, optimize=True) + box = alpha_bbox(canvas) + entries.append( + { + "id": part_id, + "file": path.name, + "sourceBox": list(source_box), + "targetBox": list(TARGETS[variant][part_name]), + "alphaBox": list(box) if box else None, + } + ) + + (OUT / "eye-variants-manifest.json").write_text( + json.dumps( + { + "source": str(ATLAS), + "canvas": CANVAS, + "variants": ROW_NAMES, + "parts": entries, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + make_contact(entries) + sync_to_app() + print(f"extracted {len(entries)} imagegen eye variant parts") + print(f"parts {PARTS}") + print(f"app parts {APP_PARTS}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/avatar-art/seoyeon/live2d-psb/flattened.png b/docs/avatar-art/seoyeon/live2d-psb/flattened.png new file mode 100644 index 0000000..424057b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/flattened.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-expression-atlas-v1.png b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-expression-atlas-v1.png new file mode 100644 index 0000000..bab0e7d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-expression-atlas-v1.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/eye-expression-atlas-alpha.png b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/eye-expression-atlas-alpha.png new file mode 100644 index 0000000..dc330a2 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/eye-expression-atlas-alpha.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/eye-variants-contact.png b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/eye-variants-contact.png new file mode 100644 index 0000000..221e995 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/eye-variants-contact.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/eye-variants-manifest.json b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/eye-variants-manifest.json new file mode 100644 index 0000000..071027c --- /dev/null +++ b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/eye-variants-manifest.json @@ -0,0 +1,278 @@ +{ + "source": "D:\\workspace\\vignette\\docs\\avatar-art\\seoyeon\\live2d-psb\\imagegen\\eye-expression-atlas-v1.png", + "canvas": [ + 900, + 1125 + ], + "variants": [ + "joy", + "sad", + "startled" + ], + "parts": [ + { + "id": "eyegen-joy-brow-left", + "file": "eyegen-joy-brow-left.png", + "sourceBox": [ + 157, + 211, + 412, + 261 + ], + "targetBox": [ + 308, + 292, + 436, + 317 + ], + "alphaBox": [ + 308, + 292, + 436, + 317 + ] + }, + { + "id": "eyegen-joy-brow-right", + "file": "eyegen-joy-brow-right.png", + "sourceBox": [ + 611, + 211, + 867, + 261 + ], + "targetBox": [ + 472, + 283, + 601, + 300 + ], + "alphaBox": [ + 493, + 283, + 580, + 300 + ] + }, + { + "id": "eyegen-joy-eye-left", + "file": "eyegen-joy-eye-left.png", + "sourceBox": [ + 133, + 311, + 400, + 386 + ], + "targetBox": [ + 296, + 312, + 436, + 365 + ], + "alphaBox": [ + 296, + 319, + 436, + 358 + ] + }, + { + "id": "eyegen-joy-eye-right", + "file": "eyegen-joy-eye-right.png", + "sourceBox": [ + 626, + 311, + 891, + 387 + ], + "targetBox": [ + 472, + 303, + 619, + 356 + ], + "alphaBox": [ + 472, + 308, + 619, + 350 + ] + }, + { + "id": "eyegen-sad-brow-left", + "file": "eyegen-sad-brow-left.png", + "sourceBox": [ + 174, + 664, + 399, + 723 + ], + "targetBox": [ + 308, + 294, + 436, + 322 + ], + "alphaBox": [ + 318, + 294, + 425, + 322 + ] + }, + { + "id": "eyegen-sad-brow-right", + "file": "eyegen-sad-brow-right.png", + "sourceBox": [ + 625, + 664, + 848, + 723 + ], + "targetBox": [ + 472, + 286, + 601, + 308 + ], + "alphaBox": [ + 495, + 286, + 578, + 308 + ] + }, + { + "id": "eyegen-sad-eye-left", + "file": "eyegen-sad-eye-left.png", + "sourceBox": [ + 135, + 740, + 397, + 861 + ], + "targetBox": [ + 296, + 306, + 436, + 392 + ], + "alphaBox": [ + 296, + 316, + 436, + 381 + ] + }, + { + "id": "eyegen-sad-eye-right", + "file": "eyegen-sad-eye-right.png", + "sourceBox": [ + 628, + 740, + 890, + 861 + ], + "targetBox": [ + 472, + 297, + 619, + 377 + ], + "alphaBox": [ + 472, + 303, + 619, + 371 + ] + }, + { + "id": "eyegen-startled-brow-left", + "file": "eyegen-startled-brow-left.png", + "sourceBox": [ + 206, + 1060, + 387, + 1101 + ], + "targetBox": [ + 308, + 286, + 436, + 313 + ], + "alphaBox": [ + 312, + 286, + 431, + 313 + ] + }, + { + "id": "eyegen-startled-brow-right", + "file": "eyegen-startled-brow-right.png", + "sourceBox": [ + 637, + 1060, + 818, + 1101 + ], + "targetBox": [ + 472, + 277, + 601, + 300 + ], + "alphaBox": [ + 485, + 277, + 587, + 300 + ] + }, + { + "id": "eyegen-startled-eye-left", + "file": "eyegen-startled-eye-left.png", + "sourceBox": [ + 144, + 1132, + 388, + 1285 + ], + "targetBox": [ + 296, + 292, + 436, + 392 + ], + "alphaBox": [ + 296, + 298, + 436, + 386 + ] + }, + { + "id": "eyegen-startled-eye-right", + "file": "eyegen-startled-eye-right.png", + "sourceBox": [ + 637, + 1132, + 880, + 1285 + ], + "targetBox": [ + 472, + 283, + 619, + 377 + ], + "alphaBox": [ + 472, + 283, + 619, + 376 + ] + } + ] +} \ No newline at end of file diff --git a/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-joy-brow-left.png b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-joy-brow-left.png new file mode 100644 index 0000000..65fd964 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-joy-brow-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-joy-brow-right.png b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-joy-brow-right.png new file mode 100644 index 0000000..3b46c09 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-joy-brow-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-joy-eye-left.png b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-joy-eye-left.png new file mode 100644 index 0000000..8ac14d9 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-joy-eye-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-joy-eye-right.png b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-joy-eye-right.png new file mode 100644 index 0000000..4c6cb05 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-joy-eye-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-sad-brow-left.png b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-sad-brow-left.png new file mode 100644 index 0000000..8ad3225 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-sad-brow-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-sad-brow-right.png b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-sad-brow-right.png new file mode 100644 index 0000000..652898e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-sad-brow-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-sad-eye-left.png b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-sad-eye-left.png new file mode 100644 index 0000000..c769e2c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-sad-eye-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-sad-eye-right.png b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-sad-eye-right.png new file mode 100644 index 0000000..89e169a Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-sad-eye-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-startled-brow-left.png b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-startled-brow-left.png new file mode 100644 index 0000000..16803b0 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-startled-brow-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-startled-brow-right.png b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-startled-brow-right.png new file mode 100644 index 0000000..176b12c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-startled-brow-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-startled-eye-left.png b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-startled-eye-left.png new file mode 100644 index 0000000..297a3e0 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-startled-eye-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-startled-eye-right.png b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-startled-eye-right.png new file mode 100644 index 0000000..e935c41 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/imagegen/eye-variants/parts/eyegen-startled-eye-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-000.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-000.png new file mode 100644 index 0000000..655a587 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-000.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-001.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-001.png new file mode 100644 index 0000000..643ddc1 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-001.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-002.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-002.png new file mode 100644 index 0000000..8b6f4de Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-002.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-003.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-003.png new file mode 100644 index 0000000..94f027a Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-003.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-004.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-004.png new file mode 100644 index 0000000..26c04bc Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-004.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-005.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-005.png new file mode 100644 index 0000000..7030d91 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-005.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-006.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-006.png new file mode 100644 index 0000000..7a527c7 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-006.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-007.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-007.png new file mode 100644 index 0000000..47132c1 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-007.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-008.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-008.png new file mode 100644 index 0000000..457b414 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-008.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-009.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-009.png new file mode 100644 index 0000000..783290a Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-009.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-010.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-010.png new file mode 100644 index 0000000..3d0d9a1 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-010.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-011.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-011.png new file mode 100644 index 0000000..8c9389f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-011.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-012.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-012.png new file mode 100644 index 0000000..e0fe225 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-012.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-013.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-013.png new file mode 100644 index 0000000..f2b907a Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-013.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-014.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-014.png new file mode 100644 index 0000000..2816999 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-014.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-015.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-015.png new file mode 100644 index 0000000..abf11e3 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-015.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-016.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-016.png new file mode 100644 index 0000000..712b402 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-016.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-017.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-017.png new file mode 100644 index 0000000..ba408a1 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-017.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-018.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-018.png new file mode 100644 index 0000000..0928ba5 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-018.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-019.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-019.png new file mode 100644 index 0000000..1e9b4fb Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-019.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-020.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-020.png new file mode 100644 index 0000000..deb2d5f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-020.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-021.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-021.png new file mode 100644 index 0000000..41da91f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-021.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-022.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-022.png new file mode 100644 index 0000000..abdee1f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-022.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-023.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-023.png new file mode 100644 index 0000000..8091058 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-023.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-024.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-024.png new file mode 100644 index 0000000..cee900c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-024.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-025.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-025.png new file mode 100644 index 0000000..d3c1453 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-025.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-026.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-026.png new file mode 100644 index 0000000..fb5c68b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-026.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-027.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-027.png new file mode 100644 index 0000000..d1389f2 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-027.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-028.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-028.png new file mode 100644 index 0000000..d59457f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-028.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-029.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-029.png new file mode 100644 index 0000000..1428d5e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-029.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-030.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-030.png new file mode 100644 index 0000000..9bd1c2a Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-030.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-031.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-031.png new file mode 100644 index 0000000..853d6a6 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-031.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-032.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-032.png new file mode 100644 index 0000000..4bb6a61 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-032.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-033.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-033.png new file mode 100644 index 0000000..0d12c0d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-033.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-034.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-034.png new file mode 100644 index 0000000..030d273 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-034.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-035.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-035.png new file mode 100644 index 0000000..b69336f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-035.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-036.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-036.png new file mode 100644 index 0000000..7646a9b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-036.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-037.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-037.png new file mode 100644 index 0000000..4381568 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-037.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-038.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-038.png new file mode 100644 index 0000000..a9da370 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-038.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-039.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-039.png new file mode 100644 index 0000000..fb7bf15 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-039.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-040.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-040.png new file mode 100644 index 0000000..c6ecef2 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-040.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-041.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-041.png new file mode 100644 index 0000000..f0b476d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-041.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-042.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-042.png new file mode 100644 index 0000000..f623a28 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-042.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-043.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-043.png new file mode 100644 index 0000000..c5bdf7a Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-043.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-044.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-044.png new file mode 100644 index 0000000..6308416 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-044.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-045.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-045.png new file mode 100644 index 0000000..c53d8ab Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-045.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-046.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-046.png new file mode 100644 index 0000000..ccfe8e3 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-046.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-047.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-047.png new file mode 100644 index 0000000..8ead9e9 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-047.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-048.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-048.png new file mode 100644 index 0000000..873d490 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-048.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-049.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-049.png new file mode 100644 index 0000000..94e521d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-049.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-050.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-050.png new file mode 100644 index 0000000..713778f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-050.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-051.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-051.png new file mode 100644 index 0000000..942954c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-051.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-052.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-052.png new file mode 100644 index 0000000..a17af42 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-052.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-053.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-053.png new file mode 100644 index 0000000..ffcc863 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-053.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-054.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-054.png new file mode 100644 index 0000000..92be59f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-054.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-055.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-055.png new file mode 100644 index 0000000..4dfc606 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-055.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-056.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-056.png new file mode 100644 index 0000000..be3c409 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-056.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-057.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-057.png new file mode 100644 index 0000000..0367021 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-057.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-058.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-058.png new file mode 100644 index 0000000..28e95c4 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-058.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-059.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-059.png new file mode 100644 index 0000000..c520fda Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-059.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-060.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-060.png new file mode 100644 index 0000000..7fef472 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-060.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-061.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-061.png new file mode 100644 index 0000000..3314c24 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-061.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-062.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-062.png new file mode 100644 index 0000000..fe156b0 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-062.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-063.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-063.png new file mode 100644 index 0000000..cf088dd Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-063.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-064.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-064.png new file mode 100644 index 0000000..c50bd24 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-064.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-065.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-065.png new file mode 100644 index 0000000..2214a92 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-065.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-066.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-066.png new file mode 100644 index 0000000..b066ab5 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-066.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-067.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-067.png new file mode 100644 index 0000000..3de7b3e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-067.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-068.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-068.png new file mode 100644 index 0000000..b05344c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-068.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-069.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-069.png new file mode 100644 index 0000000..643e45f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-069.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-070.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-070.png new file mode 100644 index 0000000..9c717dd Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-070.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-071.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-071.png new file mode 100644 index 0000000..2e24749 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-071.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-072.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-072.png new file mode 100644 index 0000000..39cd794 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-072.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-073.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-073.png new file mode 100644 index 0000000..e2c133e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-073.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-074.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-074.png new file mode 100644 index 0000000..31c0318 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-074.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-075.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-075.png new file mode 100644 index 0000000..f398c01 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-075.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-076.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-076.png new file mode 100644 index 0000000..e72436b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-076.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-077.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-077.png new file mode 100644 index 0000000..fb3ce30 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-077.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-078.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-078.png new file mode 100644 index 0000000..3d657ee Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-078.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-079.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-079.png new file mode 100644 index 0000000..cfa5c0c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-079.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-080.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-080.png new file mode 100644 index 0000000..e8d4225 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-080.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-081.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-081.png new file mode 100644 index 0000000..40e09db Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-081.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-082.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-082.png new file mode 100644 index 0000000..7dffd5e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-082.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-083.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-083.png new file mode 100644 index 0000000..46430b1 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-083.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-084.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-084.png new file mode 100644 index 0000000..5791c2b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-084.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-085.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-085.png new file mode 100644 index 0000000..2fe1e8d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-085.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-086.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-086.png new file mode 100644 index 0000000..4332357 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-086.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-087.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-087.png new file mode 100644 index 0000000..d34dcdb Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-087.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-088.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-088.png new file mode 100644 index 0000000..3506f2a Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-088.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-089.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-089.png new file mode 100644 index 0000000..7eee27a Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-089.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-090.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-090.png new file mode 100644 index 0000000..cb8abad Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-090.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-091.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-091.png new file mode 100644 index 0000000..6afb5db Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-091.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-092.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-092.png new file mode 100644 index 0000000..cf9e5fa Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-092.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-093.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-093.png new file mode 100644 index 0000000..b09cccd Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-093.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-094.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-094.png new file mode 100644 index 0000000..833353a Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-094.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-095.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-095.png new file mode 100644 index 0000000..b62f10d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-095.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-096.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-096.png new file mode 100644 index 0000000..d26cb58 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-096.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-097.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-097.png new file mode 100644 index 0000000..4d8047b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-097.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-098.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-098.png new file mode 100644 index 0000000..3b1dea8 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-098.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-099.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-099.png new file mode 100644 index 0000000..7b380a0 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-099.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-100.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-100.png new file mode 100644 index 0000000..21975c2 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-100.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-101.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-101.png new file mode 100644 index 0000000..dd69df7 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-101.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-102.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-102.png new file mode 100644 index 0000000..55b0e86 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-102.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-103.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-103.png new file mode 100644 index 0000000..a10ea14 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-103.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-104.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-104.png new file mode 100644 index 0000000..6b87661 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-104.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-105.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-105.png new file mode 100644 index 0000000..9331406 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-105.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-106.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-106.png new file mode 100644 index 0000000..37bf810 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-106.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-107.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-107.png new file mode 100644 index 0000000..c0a194c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-107.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-108.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-108.png new file mode 100644 index 0000000..68c6446 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-108.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-109.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-109.png new file mode 100644 index 0000000..7e9b1a9 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-109.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-110.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-110.png new file mode 100644 index 0000000..b5bf5f0 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-110.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-111.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-111.png new file mode 100644 index 0000000..de8b21e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-111.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-112.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-112.png new file mode 100644 index 0000000..c961b99 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-112.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-113.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-113.png new file mode 100644 index 0000000..9c3f239 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-113.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-114.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-114.png new file mode 100644 index 0000000..e461df9 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-114.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-115.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-115.png new file mode 100644 index 0000000..91a6624 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-115.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-116.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-116.png new file mode 100644 index 0000000..9e0abf7 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-116.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-117.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-117.png new file mode 100644 index 0000000..266aa94 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-117.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-118.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-118.png new file mode 100644 index 0000000..1926f80 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-118.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-119.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-119.png new file mode 100644 index 0000000..071779d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-119.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-120.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-120.png new file mode 100644 index 0000000..282fc2f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-120.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-121.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-121.png new file mode 100644 index 0000000..4886f73 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-121.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-122.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-122.png new file mode 100644 index 0000000..f6dae28 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-122.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-123.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-123.png new file mode 100644 index 0000000..5c7fd55 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-123.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-124.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-124.png new file mode 100644 index 0000000..c377695 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-124.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-125.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-125.png new file mode 100644 index 0000000..794b0e9 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-125.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-126.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-126.png new file mode 100644 index 0000000..121ab80 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-126.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-127.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-127.png new file mode 100644 index 0000000..ce84bb2 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-127.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-128.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-128.png new file mode 100644 index 0000000..db765b4 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-128.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-129.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-129.png new file mode 100644 index 0000000..46a5e56 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-129.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-130.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-130.png new file mode 100644 index 0000000..3ff4cfc Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-130.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-131.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-131.png new file mode 100644 index 0000000..b20feda Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-131.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-132.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-132.png new file mode 100644 index 0000000..72dcdeb Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-132.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-133.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-133.png new file mode 100644 index 0000000..6b42de7 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-133.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-134.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-134.png new file mode 100644 index 0000000..b8cb526 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-134.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-135.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-135.png new file mode 100644 index 0000000..2eec8dd Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-135.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-136.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-136.png new file mode 100644 index 0000000..ed21993 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-136.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-137.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-137.png new file mode 100644 index 0000000..b3efdb4 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-137.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-138.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-138.png new file mode 100644 index 0000000..45a76c7 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-138.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-139.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-139.png new file mode 100644 index 0000000..63d57d9 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-139.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-140.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-140.png new file mode 100644 index 0000000..27f309c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-140.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-141.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-141.png new file mode 100644 index 0000000..06d9811 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-141.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-142.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-142.png new file mode 100644 index 0000000..29b8b91 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-142.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-143.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-143.png new file mode 100644 index 0000000..1bd4a9f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-143.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-144.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-144.png new file mode 100644 index 0000000..a1a7686 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-144.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-145.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-145.png new file mode 100644 index 0000000..f9c30e0 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-145.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-146.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-146.png new file mode 100644 index 0000000..40a1a4b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-146.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-147.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-147.png new file mode 100644 index 0000000..912d32f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-147.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-148.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-148.png new file mode 100644 index 0000000..585d08c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-148.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-149.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-149.png new file mode 100644 index 0000000..f95cbd7 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-149.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-150.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-150.png new file mode 100644 index 0000000..4c59826 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-150.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-151.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-151.png new file mode 100644 index 0000000..33172e5 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-151.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-152.png b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-152.png new file mode 100644 index 0000000..3090eec Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-canvas/layer-152.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-contact.png b/docs/avatar-art/seoyeon/live2d-psb/layers-contact.png new file mode 100644 index 0000000..21de421 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-contact.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-manifest.json b/docs/avatar-art/seoyeon/live2d-psb/layers-manifest.json new file mode 100644 index 0000000..82c3569 --- /dev/null +++ b/docs/avatar-art/seoyeon/live2d-psb/layers-manifest.json @@ -0,0 +1,2457 @@ +{ + "source": "C:\\Users\\encep\\OneDrive\\\ubb38\uc11c\\\uce74\uce74\uc624\ud1a1 \ubc1b\uc740 \ud30c\uc77c\\\ub77c\ud22c\ub514 \uc5ec\uce90.psb", + "canvas": [ + 1080, + 1920 + ], + "layers": [ + { + "index": 0, + "width": 1080, + "height": 1920, + "page": "1080x1920+0+0", + "x": 0, + "y": 0, + "raw": "layers-raw/layer-000.png", + "canvas": "layers-canvas/layer-000.png", + "alphaBox": [ + 0, + 0, + 1080, + 1920 + ] + }, + { + "index": 1, + "width": 1080, + "height": 1920, + "page": "1080x1920+0+0", + "x": 0, + "y": 0, + "raw": "layers-raw/layer-001.png", + "canvas": "layers-canvas/layer-001.png", + "alphaBox": [ + 0, + 0, + 1080, + 1920 + ] + }, + { + "index": 2, + "width": 1188, + "height": 1188, + "page": "1188x1188-55+243", + "x": -55, + "y": 243, + "raw": "layers-raw/layer-002.png", + "canvas": "layers-canvas/layer-002.png", + "alphaBox": [ + 0, + 243, + 1080, + 1431 + ] + }, + { + "index": 3, + "width": 545, + "height": 1564, + "page": "545x1564+11+233", + "x": 11, + "y": 233, + "raw": "layers-raw/layer-003.png", + "canvas": "layers-canvas/layer-003.png", + "alphaBox": [ + 11, + 233, + 556, + 1797 + ] + }, + { + "index": 4, + "width": 1080, + "height": 1667, + "page": "1080x1667+0+253", + "x": 0, + "y": 253, + "raw": "layers-raw/layer-004.png", + "canvas": "layers-canvas/layer-004.png", + "alphaBox": [ + 0, + 254, + 1080, + 1920 + ] + }, + { + "index": 5, + "width": 530, + "height": 1505, + "page": "530x1505+198+316", + "x": 198, + "y": 316, + "raw": "layers-raw/layer-005.png", + "canvas": "layers-canvas/layer-005.png", + "alphaBox": [ + 200, + 317, + 727, + 1817 + ] + }, + { + "index": 6, + "width": 334, + "height": 796, + "page": "334x796+83+291", + "x": 83, + "y": 291, + "raw": "layers-raw/layer-006.png", + "canvas": "layers-canvas/layer-006.png", + "alphaBox": [ + 84, + 291, + 417, + 1087 + ] + }, + { + "index": 7, + "width": 549, + "height": 1465, + "page": "549x1465+0+235", + "x": 0, + "y": 235, + "raw": "layers-raw/layer-007.png", + "canvas": "layers-canvas/layer-007.png", + "alphaBox": [ + 0, + 235, + 549, + 1700 + ] + }, + { + "index": 8, + "width": 545, + "height": 1564, + "page": "545x1564+524+233", + "x": 524, + "y": 233, + "raw": "layers-raw/layer-008.png", + "canvas": "layers-canvas/layer-008.png", + "alphaBox": [ + 524, + 233, + 1069, + 1797 + ] + }, + { + "index": 9, + "width": 1080, + "height": 1667, + "page": "1080x1667+0+253", + "x": 0, + "y": 253, + "raw": "layers-raw/layer-009.png", + "canvas": "layers-canvas/layer-009.png", + "alphaBox": [ + 0, + 254, + 1080, + 1920 + ] + }, + { + "index": 10, + "width": 530, + "height": 1505, + "page": "530x1505+352+316", + "x": 352, + "y": 316, + "raw": "layers-raw/layer-010.png", + "canvas": "layers-canvas/layer-010.png", + "alphaBox": [ + 353, + 317, + 880, + 1817 + ] + }, + { + "index": 11, + "width": 334, + "height": 796, + "page": "334x796+663+291", + "x": 663, + "y": 291, + "raw": "layers-raw/layer-011.png", + "canvas": "layers-canvas/layer-011.png", + "alphaBox": [ + 663, + 291, + 996, + 1087 + ] + }, + { + "index": 12, + "width": 549, + "height": 1465, + "page": "549x1465+531+235", + "x": 531, + "y": 235, + "raw": "layers-raw/layer-012.png", + "canvas": "layers-canvas/layer-012.png", + "alphaBox": [ + 531, + 235, + 1080, + 1700 + ] + }, + { + "index": 13, + "width": 1080, + "height": 1920, + "page": "1080x1920+0+0", + "x": 0, + "y": 0, + "raw": "layers-raw/layer-013.png", + "canvas": "layers-canvas/layer-013.png", + "alphaBox": [ + 0, + 409, + 1080, + 1920 + ] + }, + { + "index": 14, + "width": 1080, + "height": 1920, + "page": "1080x1920+0+0", + "x": 0, + "y": 0, + "raw": "layers-raw/layer-014.png", + "canvas": "layers-canvas/layer-014.png", + "alphaBox": [ + 0, + 365, + 1080, + 1920 + ] + }, + { + "index": 15, + "width": 317, + "height": 382, + "page": "317x382+408+768", + "x": 408, + "y": 768, + "raw": "layers-raw/layer-015.png", + "canvas": "layers-canvas/layer-015.png", + "alphaBox": [ + 408, + 768, + 725, + 1150 + ] + }, + { + "index": 16, + "width": 764, + "height": 587, + "page": "764x587+185+536", + "x": 185, + "y": 536, + "raw": "layers-raw/layer-016.png", + "canvas": "layers-canvas/layer-016.png", + "alphaBox": [ + 253, + 582, + 863, + 1075 + ] + }, + { + "index": 17, + "width": 252, + "height": 327, + "page": "252x327+428+722", + "x": 428, + "y": 722, + "raw": "layers-raw/layer-017.png", + "canvas": "layers-canvas/layer-017.png", + "alphaBox": [ + 428, + 722, + 680, + 1035 + ] + }, + { + "index": 18, + "width": 321, + "height": 383, + "page": "321x383+406+767", + "x": 406, + "y": 767, + "raw": "layers-raw/layer-018.png", + "canvas": "layers-canvas/layer-018.png", + "alphaBox": [ + 407, + 767, + 726, + 1150 + ] + }, + { + "index": 19, + "width": 87, + "height": 38, + "page": "87x38+585+1000", + "x": 585, + "y": 1000, + "raw": "layers-raw/layer-019.png", + "canvas": "layers-canvas/layer-019.png", + "alphaBox": [ + 585, + 1000, + 672, + 1037 + ] + }, + { + "index": 20, + "width": 86, + "height": 38, + "page": "86x38+441+999", + "x": 441, + "y": 999, + "raw": "layers-raw/layer-020.png", + "canvas": "layers-canvas/layer-020.png", + "alphaBox": [ + 442, + 999, + 527, + 1036 + ] + }, + { + "index": 21, + "width": 282, + "height": 498, + "page": "282x498+416+866", + "x": 416, + "y": 866, + "raw": "layers-raw/layer-021.png", + "canvas": "layers-canvas/layer-021.png", + "alphaBox": [ + 416, + 867, + 698, + 1364 + ] + }, + { + "index": 22, + "width": 350, + "height": 598, + "page": "350x598+381+737", + "x": 381, + "y": 737, + "raw": "layers-raw/layer-022.png", + "canvas": "layers-canvas/layer-022.png", + "alphaBox": [ + 382, + 737, + 730, + 1335 + ] + }, + { + "index": 23, + "width": 564, + "height": 495, + "page": "564x495+280+955", + "x": 280, + "y": 955, + "raw": "layers-raw/layer-023.png", + "canvas": "layers-canvas/layer-023.png", + "alphaBox": [ + 280, + 955, + 844, + 1450 + ] + }, + { + "index": 24, + "width": 740, + "height": 700, + "page": "740x700+166+868", + "x": 166, + "y": 868, + "raw": "layers-raw/layer-024.png", + "canvas": "layers-canvas/layer-024.png", + "alphaBox": [ + 168, + 869, + 905, + 1568 + ] + }, + { + "index": 25, + "width": 566, + "height": 570, + "page": "566x570+279+865", + "x": 279, + "y": 865, + "raw": "layers-raw/layer-025.png", + "canvas": "layers-canvas/layer-025.png", + "alphaBox": [ + 279, + 866, + 845, + 1435 + ] + }, + { + "index": 26, + "width": 462, + "height": 501, + "page": "462x501+331+952", + "x": 331, + "y": 952, + "raw": "layers-raw/layer-026.png", + "canvas": "layers-canvas/layer-026.png", + "alphaBox": [ + 331, + 952, + 793, + 1453 + ] + }, + { + "index": 27, + "width": 541, + "height": 598, + "page": "541x598+296+922", + "x": 296, + "y": 922, + "raw": "layers-raw/layer-027.png", + "canvas": "layers-canvas/layer-027.png", + "alphaBox": [ + 296, + 928, + 831, + 1516 + ] + }, + { + "index": 28, + "width": 220, + "height": 571, + "page": "220x571+451+880", + "x": 451, + "y": 880, + "raw": "layers-raw/layer-028.png", + "canvas": "layers-canvas/layer-028.png", + "alphaBox": [ + 451, + 880, + 671, + 1451 + ] + }, + { + "index": 29, + "width": 811, + "height": 573, + "page": "811x573+156+884", + "x": 156, + "y": 884, + "raw": "layers-raw/layer-029.png", + "canvas": "layers-canvas/layer-029.png", + "alphaBox": [ + 156, + 890, + 967, + 1457 + ] + }, + { + "index": 30, + "width": 853, + "height": 749, + "page": "853x749+127+791", + "x": 127, + "y": 791, + "raw": "layers-raw/layer-030.png", + "canvas": "layers-canvas/layer-030.png", + "alphaBox": [ + 129, + 791, + 980, + 1540 + ] + }, + { + "index": 31, + "width": 937, + "height": 640, + "page": "937x640+101+975", + "x": 101, + "y": 975, + "raw": "layers-raw/layer-031.png", + "canvas": "layers-canvas/layer-031.png", + "alphaBox": [ + 101, + 976, + 1036, + 1615 + ] + }, + { + "index": 32, + "width": 812, + "height": 563, + "page": "812x563+156+889", + "x": 156, + "y": 889, + "raw": "layers-raw/layer-032.png", + "canvas": "layers-canvas/layer-032.png", + "alphaBox": [ + 156, + 889, + 968, + 1452 + ] + }, + { + "index": 33, + "width": 239, + "height": 282, + "page": "239x282+443+955", + "x": 443, + "y": 955, + "raw": "layers-raw/layer-033.png", + "canvas": "layers-canvas/layer-033.png", + "alphaBox": [ + 443, + 1005, + 682, + 1237 + ] + }, + { + "index": 34, + "width": 288, + "height": 255, + "page": "288x255+422+992", + "x": 422, + "y": 992, + "raw": "layers-raw/layer-034.png", + "canvas": "layers-canvas/layer-034.png", + "alphaBox": [ + 423, + 992, + 708, + 1247 + ] + }, + { + "index": 35, + "width": 241, + "height": 235, + "page": "241x235+442+1003", + "x": 442, + "y": 1003, + "raw": "layers-raw/layer-035.png", + "canvas": "layers-canvas/layer-035.png", + "alphaBox": [ + 442, + 1003, + 683, + 1238 + ] + }, + { + "index": 36, + "width": 229, + "height": 225, + "page": "229x225+446+1008", + "x": 446, + "y": 1008, + "raw": "layers-raw/layer-036.png", + "canvas": "layers-canvas/layer-036.png", + "alphaBox": [ + 446, + 1008, + 667, + 1231 + ] + }, + { + "index": 37, + "width": 90, + "height": 99, + "page": "90x99+327+674", + "x": 327, + "y": 674, + "raw": "layers-raw/layer-037.png", + "canvas": "layers-canvas/layer-037.png", + "alphaBox": [ + 327, + 674, + 417, + 773 + ] + }, + { + "index": 38, + "width": 233, + "height": 259, + "page": "233x259+215+560", + "x": 215, + "y": 560, + "raw": "layers-raw/layer-038.png", + "canvas": "layers-canvas/layer-038.png", + "alphaBox": [ + 240, + 594, + 424, + 789 + ] + }, + { + "index": 39, + "width": 50, + "height": 57, + "page": "50x57+347+700", + "x": 347, + "y": 700, + "raw": "layers-raw/layer-039.png", + "canvas": "layers-canvas/layer-039.png", + "alphaBox": [ + 348, + 700, + 397, + 757 + ] + }, + { + "index": 40, + "width": 92, + "height": 98, + "page": "92x98+326+676", + "x": 326, + "y": 676, + "raw": "layers-raw/layer-040.png", + "canvas": "layers-canvas/layer-040.png", + "alphaBox": [ + 326, + 676, + 418, + 774 + ] + }, + { + "index": 41, + "width": 91, + "height": 98, + "page": "91x98+688+667", + "x": 688, + "y": 667, + "raw": "layers-raw/layer-041.png", + "canvas": "layers-canvas/layer-041.png", + "alphaBox": [ + 688, + 667, + 779, + 765 + ] + }, + { + "index": 42, + "width": 224, + "height": 250, + "page": "224x250+661+550", + "x": 661, + "y": 550, + "raw": "layers-raw/layer-042.png", + "canvas": "layers-canvas/layer-042.png", + "alphaBox": [ + 684, + 585, + 860, + 773 + ] + }, + { + "index": 43, + "width": 387, + "height": 68, + "page": "387x68+371+691", + "x": 371, + "y": 691, + "raw": "layers-raw/layer-043.png", + "canvas": "layers-canvas/layer-043.png", + "alphaBox": [ + 371, + 691, + 758, + 759 + ] + }, + { + "index": 44, + "width": 93, + "height": 100, + "page": "93x100+687+666", + "x": 687, + "y": 666, + "raw": "layers-raw/layer-044.png", + "canvas": "layers-canvas/layer-044.png", + "alphaBox": [ + 687, + 666, + 780, + 766 + ] + }, + { + "index": 45, + "width": 423, + "height": 521, + "page": "423x521+332+356", + "x": 332, + "y": 356, + "raw": "layers-raw/layer-045.png", + "canvas": "layers-canvas/layer-045.png", + "alphaBox": [ + 332, + 356, + 755, + 877 + ] + }, + { + "index": 46, + "width": 1080, + "height": 1058, + "page": "1080x1058+0+0", + "x": 0, + "y": 0, + "raw": "layers-raw/layer-046.png", + "canvas": "layers-canvas/layer-046.png", + "alphaBox": [ + 5, + 0, + 1079, + 987 + ] + }, + { + "index": 47, + "width": 425, + "height": 601, + "page": "425x601+331+277", + "x": 331, + "y": 277, + "raw": "layers-raw/layer-047.png", + "canvas": "layers-canvas/layer-047.png", + "alphaBox": [ + 331, + 277, + 756, + 878 + ] + }, + { + "index": 48, + "width": 205, + "height": 142, + "page": "205x142+348+639", + "x": 348, + "y": 639, + "raw": "layers-raw/layer-048.png", + "canvas": "layers-canvas/layer-048.png", + "alphaBox": [ + 361, + 646, + 539, + 775 + ] + }, + { + "index": 49, + "width": 49, + "height": 28, + "page": "49x28+420+697", + "x": 420, + "y": 697, + "raw": "layers-raw/layer-049.png", + "canvas": "layers-canvas/layer-049.png", + "alphaBox": [ + 420, + 697, + 469, + 725 + ] + }, + { + "index": 50, + "width": 205, + "height": 142, + "page": "205x142+546+629", + "x": 546, + "y": 629, + "raw": "layers-raw/layer-050.png", + "canvas": "layers-canvas/layer-050.png", + "alphaBox": [ + 560, + 636, + 738, + 765 + ] + }, + { + "index": 51, + "width": 49, + "height": 28, + "page": "49x28+630+687", + "x": 630, + "y": 687, + "raw": "layers-raw/layer-051.png", + "canvas": "layers-canvas/layer-051.png", + "alphaBox": [ + 630, + 687, + 679, + 715 + ] + }, + { + "index": 52, + "width": 15, + "height": 20, + "page": "15x20+538+732", + "x": 538, + "y": 732, + "raw": "layers-raw/layer-052.png", + "canvas": "layers-canvas/layer-052.png", + "alphaBox": [ + 538, + 732, + 553, + 751 + ] + }, + { + "index": 53, + "width": 3, + "height": 14, + "page": "3x14+549+736", + "x": 549, + "y": 736, + "raw": "layers-raw/layer-053.png", + "canvas": "layers-canvas/layer-053.png", + "alphaBox": [ + 549, + 736, + 552, + 750 + ] + }, + { + "index": 54, + "width": 102, + "height": 51, + "page": "102x51+393+650", + "x": 393, + "y": 650, + "raw": "layers-raw/layer-054.png", + "canvas": "layers-canvas/layer-054.png", + "alphaBox": [ + 393, + 650, + 495, + 701 + ] + }, + { + "index": 55, + "width": 169, + "height": 65, + "page": "169x65+364+622", + "x": 364, + "y": 622, + "raw": "layers-raw/layer-055.png", + "canvas": "layers-canvas/layer-055.png", + "alphaBox": [ + 364, + 622, + 533, + 687 + ] + }, + { + "index": 56, + "width": 64, + "height": 60, + "page": "64x60+420+641", + "x": 420, + "y": 641, + "raw": "layers-raw/layer-056.png", + "canvas": "layers-canvas/layer-056.png", + "alphaBox": [ + 420, + 641, + 484, + 700 + ] + }, + { + "index": 57, + "width": 77, + "height": 70, + "page": "77x70+416+637", + "x": 416, + "y": 637, + "raw": "layers-raw/layer-057.png", + "canvas": "layers-canvas/layer-057.png", + "alphaBox": [ + 417, + 637, + 493, + 707 + ] + }, + { + "index": 58, + "width": 35, + "height": 19, + "page": "35x19+436+677", + "x": 436, + "y": 677, + "raw": "layers-raw/layer-058.png", + "canvas": "layers-canvas/layer-058.png", + "alphaBox": [ + 436, + 677, + 471, + 695 + ] + }, + { + "index": 59, + "width": 69, + "height": 51, + "page": "69x51+416+652", + "x": 416, + "y": 652, + "raw": "layers-raw/layer-059.png", + "canvas": "layers-canvas/layer-059.png", + "alphaBox": [ + 416, + 652, + 484, + 703 + ] + }, + { + "index": 60, + "width": 66, + "height": 59, + "page": "66x59+419+641", + "x": 419, + "y": 641, + "raw": "layers-raw/layer-060.png", + "canvas": "layers-canvas/layer-060.png", + "alphaBox": [ + 419, + 641, + 485, + 700 + ] + }, + { + "index": 61, + "width": 14, + "height": 19, + "page": "14x19+450+662", + "x": 450, + "y": 662, + "raw": "layers-raw/layer-061.png", + "canvas": "layers-canvas/layer-061.png", + "alphaBox": [ + 450, + 662, + 464, + 681 + ] + }, + { + "index": 62, + "width": 17, + "height": 17, + "page": "17x17+450+661", + "x": 450, + "y": 661, + "raw": "layers-raw/layer-062.png", + "canvas": "layers-canvas/layer-062.png", + "alphaBox": [ + 450, + 662, + 467, + 677 + ] + }, + { + "index": 63, + "width": 14, + "height": 20, + "page": "14x20+450+661", + "x": 450, + "y": 661, + "raw": "layers-raw/layer-063.png", + "canvas": "layers-canvas/layer-063.png", + "alphaBox": [ + 450, + 662, + 464, + 681 + ] + }, + { + "index": 64, + "width": 16, + "height": 21, + "page": "16x21+449+661", + "x": 449, + "y": 661, + "raw": "layers-raw/layer-064.png", + "canvas": "layers-canvas/layer-064.png", + "alphaBox": [ + 449, + 661, + 465, + 682 + ] + }, + { + "index": 65, + "width": 11, + "height": 11, + "page": "11x11+451+659", + "x": 451, + "y": 659, + "raw": "layers-raw/layer-065.png", + "canvas": "layers-canvas/layer-065.png", + "alphaBox": [ + 451, + 659, + 462, + 670 + ] + }, + { + "index": 66, + "width": 14, + "height": 13, + "page": "14x13+450+658", + "x": 450, + "y": 658, + "raw": "layers-raw/layer-066.png", + "canvas": "layers-canvas/layer-066.png", + "alphaBox": [ + 450, + 658, + 464, + 671 + ] + }, + { + "index": 67, + "width": 32, + "height": 22, + "page": "32x22+437+674", + "x": 437, + "y": 674, + "raw": "layers-raw/layer-067.png", + "canvas": "layers-canvas/layer-067.png", + "alphaBox": [ + 440, + 676, + 467, + 694 + ] + }, + { + "index": 68, + "width": 69, + "height": 32, + "page": "69x32+427+634", + "x": 427, + "y": 634, + "raw": "layers-raw/layer-068.png", + "canvas": "layers-canvas/layer-068.png", + "alphaBox": [ + 427, + 634, + 496, + 666 + ] + }, + { + "index": 69, + "width": 116, + "height": 33, + "page": "116x33+389+619", + "x": 389, + "y": 619, + "raw": "layers-raw/layer-069.png", + "canvas": "layers-canvas/layer-069.png", + "alphaBox": [ + 389, + 619, + 505, + 652 + ] + }, + { + "index": 70, + "width": 137, + "height": 58, + "page": "137x58+361+639", + "x": 361, + "y": 639, + "raw": "layers-raw/layer-070.png", + "canvas": "layers-canvas/layer-070.png", + "alphaBox": [ + 361, + 639, + 498, + 697 + ] + }, + { + "index": 71, + "width": 306, + "height": 154, + "page": "306x154+276+591", + "x": 276, + "y": 591, + "raw": "layers-raw/layer-071.png", + "canvas": "layers-canvas/layer-071.png", + "alphaBox": [ + 290, + 602, + 571, + 735 + ] + }, + { + "index": 72, + "width": 147, + "height": 62, + "page": "147x62+354+635", + "x": 354, + "y": 635, + "raw": "layers-raw/layer-072.png", + "canvas": "layers-canvas/layer-072.png", + "alphaBox": [ + 355, + 635, + 501, + 697 + ] + }, + { + "index": 73, + "width": 110, + "height": 24, + "page": "110x24+388+686", + "x": 388, + "y": 686, + "raw": "layers-raw/layer-073.png", + "canvas": "layers-canvas/layer-073.png", + "alphaBox": [ + 388, + 686, + 498, + 710 + ] + }, + { + "index": 74, + "width": 75, + "height": 14, + "page": "75x14+404+692", + "x": 404, + "y": 692, + "raw": "layers-raw/layer-074.png", + "canvas": "layers-canvas/layer-074.png", + "alphaBox": [ + 404, + 692, + 479, + 706 + ] + }, + { + "index": 75, + "width": 85, + "height": 18, + "page": "85x18+401+691", + "x": 401, + "y": 691, + "raw": "layers-raw/layer-075.png", + "canvas": "layers-canvas/layer-075.png", + "alphaBox": [ + 401, + 691, + 486, + 709 + ] + }, + { + "index": 76, + "width": 146, + "height": 26, + "page": "146x26+377+592", + "x": 377, + "y": 592, + "raw": "layers-raw/layer-076.png", + "canvas": "layers-canvas/layer-076.png", + "alphaBox": [ + 377, + 592, + 523, + 618 + ] + }, + { + "index": 77, + "width": 153, + "height": 30, + "page": "153x30+370+591", + "x": 370, + "y": 591, + "raw": "layers-raw/layer-077.png", + "canvas": "layers-canvas/layer-077.png", + "alphaBox": [ + 370, + 591, + 522, + 621 + ] + }, + { + "index": 78, + "width": 102, + "height": 52, + "page": "102x52+603+632", + "x": 603, + "y": 632, + "raw": "layers-raw/layer-078.png", + "canvas": "layers-canvas/layer-078.png", + "alphaBox": [ + 603, + 632, + 705, + 683 + ] + }, + { + "index": 79, + "width": 170, + "height": 72, + "page": "170x72+576+602", + "x": 576, + "y": 602, + "raw": "layers-raw/layer-079.png", + "canvas": "layers-canvas/layer-079.png", + "alphaBox": [ + 576, + 602, + 746, + 674 + ] + }, + { + "index": 80, + "width": 66, + "height": 61, + "page": "66x61+612+625", + "x": 612, + "y": 625, + "raw": "layers-raw/layer-080.png", + "canvas": "layers-canvas/layer-080.png", + "alphaBox": [ + 613, + 626, + 678, + 686 + ] + }, + { + "index": 81, + "width": 77, + "height": 72, + "page": "77x72+604+621", + "x": 604, + "y": 621, + "raw": "layers-raw/layer-081.png", + "canvas": "layers-canvas/layer-081.png", + "alphaBox": [ + 604, + 621, + 681, + 692 + ] + }, + { + "index": 82, + "width": 36, + "height": 20, + "page": "36x20+628+662", + "x": 628, + "y": 662, + "raw": "layers-raw/layer-082.png", + "canvas": "layers-canvas/layer-082.png", + "alphaBox": [ + 629, + 663, + 663, + 681 + ] + }, + { + "index": 83, + "width": 70, + "height": 57, + "page": "70x57+613+634", + "x": 613, + "y": 634, + "raw": "layers-raw/layer-083.png", + "canvas": "layers-canvas/layer-083.png", + "alphaBox": [ + 613, + 635, + 683, + 690 + ] + }, + { + "index": 84, + "width": 67, + "height": 62, + "page": "67x62+612+625", + "x": 612, + "y": 625, + "raw": "layers-raw/layer-084.png", + "canvas": "layers-canvas/layer-084.png", + "alphaBox": [ + 612, + 626, + 679, + 686 + ] + }, + { + "index": 85, + "width": 15, + "height": 20, + "page": "15x20+633+648", + "x": 633, + "y": 648, + "raw": "layers-raw/layer-085.png", + "canvas": "layers-canvas/layer-085.png", + "alphaBox": [ + 634, + 648, + 648, + 668 + ] + }, + { + "index": 86, + "width": 18, + "height": 18, + "page": "18x18+630+647", + "x": 630, + "y": 647, + "raw": "layers-raw/layer-086.png", + "canvas": "layers-canvas/layer-086.png", + "alphaBox": [ + 631, + 648, + 648, + 663 + ] + }, + { + "index": 87, + "width": 16, + "height": 21, + "page": "16x21+633+647", + "x": 633, + "y": 647, + "raw": "layers-raw/layer-087.png", + "canvas": "layers-canvas/layer-087.png", + "alphaBox": [ + 634, + 647, + 648, + 668 + ] + }, + { + "index": 88, + "width": 18, + "height": 23, + "page": "18x23+631+646", + "x": 631, + "y": 646, + "raw": "layers-raw/layer-088.png", + "canvas": "layers-canvas/layer-088.png", + "alphaBox": [ + 632, + 647, + 648, + 669 + ] + }, + { + "index": 89, + "width": 13, + "height": 13, + "page": "13x13+634+645", + "x": 634, + "y": 645, + "raw": "layers-raw/layer-089.png", + "canvas": "layers-canvas/layer-089.png", + "alphaBox": [ + 634, + 645, + 646, + 656 + ] + }, + { + "index": 90, + "width": 16, + "height": 16, + "page": "16x16+631+643", + "x": 631, + "y": 643, + "raw": "layers-raw/layer-090.png", + "canvas": "layers-canvas/layer-090.png", + "alphaBox": [ + 632, + 644, + 646, + 658 + ] + }, + { + "index": 91, + "width": 43, + "height": 24, + "page": "43x24+635+658", + "x": 635, + "y": 658, + "raw": "layers-raw/layer-091.png", + "canvas": "layers-canvas/layer-091.png", + "alphaBox": [ + 637, + 659, + 663, + 680 + ] + }, + { + "index": 92, + "width": 67, + "height": 40, + "page": "67x40+601+618", + "x": 601, + "y": 618, + "raw": "layers-raw/layer-092.png", + "canvas": "layers-canvas/layer-092.png", + "alphaBox": [ + 601, + 619, + 667, + 656 + ] + }, + { + "index": 93, + "width": 120, + "height": 38, + "page": "120x38+587+605", + "x": 587, + "y": 605, + "raw": "layers-raw/layer-093.png", + "canvas": "layers-canvas/layer-093.png", + "alphaBox": [ + 588, + 606, + 706, + 643 + ] + }, + { + "index": 94, + "width": 139, + "height": 58, + "page": "139x58+599+620", + "x": 599, + "y": 620, + "raw": "layers-raw/layer-094.png", + "canvas": "layers-canvas/layer-094.png", + "alphaBox": [ + 601, + 620, + 737, + 677 + ] + }, + { + "index": 95, + "width": 302, + "height": 151, + "page": "302x151+518+579", + "x": 518, + "y": 579, + "raw": "layers-raw/layer-095.png", + "canvas": "layers-canvas/layer-095.png", + "alphaBox": [ + 528, + 593, + 807, + 720 + ] + }, + { + "index": 96, + "width": 146, + "height": 62, + "page": "146x62+597+616", + "x": 597, + "y": 616, + "raw": "layers-raw/layer-096.png", + "canvas": "layers-canvas/layer-096.png", + "alphaBox": [ + 597, + 617, + 742, + 677 + ] + }, + { + "index": 97, + "width": 112, + "height": 29, + "page": "112x29+601+664", + "x": 601, + "y": 664, + "raw": "layers-raw/layer-097.png", + "canvas": "layers-canvas/layer-097.png", + "alphaBox": [ + 602, + 664, + 712, + 692 + ] + }, + { + "index": 98, + "width": 77, + "height": 14, + "page": "77x14+621+675", + "x": 621, + "y": 675, + "raw": "layers-raw/layer-098.png", + "canvas": "layers-canvas/layer-098.png", + "alphaBox": [ + 621, + 676, + 697, + 688 + ] + }, + { + "index": 99, + "width": 87, + "height": 16, + "page": "87x16+613+675", + "x": 613, + "y": 675, + "raw": "layers-raw/layer-099.png", + "canvas": "layers-canvas/layer-099.png", + "alphaBox": [ + 614, + 676, + 700, + 691 + ] + }, + { + "index": 100, + "width": 147, + "height": 21, + "page": "147x21+566+579", + "x": 566, + "y": 579, + "raw": "layers-raw/layer-100.png", + "canvas": "layers-canvas/layer-100.png", + "alphaBox": [ + 567, + 580, + 713, + 599 + ] + }, + { + "index": 101, + "width": 155, + "height": 21, + "page": "155x21+567+579", + "x": 567, + "y": 579, + "raw": "layers-raw/layer-101.png", + "canvas": "layers-canvas/layer-101.png", + "alphaBox": [ + 567, + 580, + 721, + 599 + ] + }, + { + "index": 102, + "width": 396, + "height": 397, + "page": "396x397+349+303", + "x": 349, + "y": 303, + "raw": "layers-raw/layer-102.png", + "canvas": "layers-canvas/layer-102.png", + "alphaBox": [ + 349, + 303, + 745, + 700 + ] + }, + { + "index": 103, + "width": 568, + "height": 505, + "page": "568x505+248+332", + "x": 248, + "y": 332, + "raw": "layers-raw/layer-103.png", + "canvas": "layers-canvas/layer-103.png", + "alphaBox": [ + 248, + 332, + 813, + 837 + ] + }, + { + "index": 104, + "width": 424, + "height": 350, + "page": "424x350+347+368", + "x": 347, + "y": 368, + "raw": "layers-raw/layer-104.png", + "canvas": "layers-canvas/layer-104.png", + "alphaBox": [ + 347, + 368, + 771, + 718 + ] + }, + { + "index": 105, + "width": 295, + "height": 245, + "page": "295x245+402+312", + "x": 402, + "y": 312, + "raw": "layers-raw/layer-105.png", + "canvas": "layers-canvas/layer-105.png", + "alphaBox": [ + 404, + 317, + 696, + 555 + ] + }, + { + "index": 106, + "width": 305, + "height": 234, + "page": "305x234+388+382", + "x": 388, + "y": 382, + "raw": "layers-raw/layer-106.png", + "canvas": "layers-canvas/layer-106.png", + "alphaBox": [ + 388, + 382, + 693, + 615 + ] + }, + { + "index": 107, + "width": 398, + "height": 402, + "page": "398x402+348+302", + "x": 348, + "y": 302, + "raw": "layers-raw/layer-107.png", + "canvas": "layers-canvas/layer-107.png", + "alphaBox": [ + 348, + 302, + 746, + 704 + ] + }, + { + "index": 108, + "width": 136, + "height": 317, + "page": "136x317+495+339", + "x": 495, + "y": 339, + "raw": "layers-raw/layer-108.png", + "canvas": "layers-canvas/layer-108.png", + "alphaBox": [ + 495, + 340, + 631, + 656 + ] + }, + { + "index": 109, + "width": 156, + "height": 615, + "page": "156x615+264+599", + "x": 264, + "y": 599, + "raw": "layers-raw/layer-109.png", + "canvas": "layers-canvas/layer-109.png", + "alphaBox": [ + 264, + 599, + 420, + 1214 + ] + }, + { + "index": 110, + "width": 205, + "height": 806, + "page": "205x806+254+408", + "x": 254, + "y": 408, + "raw": "layers-raw/layer-110.png", + "canvas": "layers-canvas/layer-110.png", + "alphaBox": [ + 254, + 408, + 459, + 1214 + ] + }, + { + "index": 111, + "width": 106, + "height": 336, + "page": "106x336+333+730", + "x": 333, + "y": 730, + "raw": "layers-raw/layer-111.png", + "canvas": "layers-canvas/layer-111.png", + "alphaBox": [ + 333, + 730, + 438, + 1066 + ] + }, + { + "index": 112, + "width": 70, + "height": 427, + "page": "70x427+292+694", + "x": 292, + "y": 694, + "raw": "layers-raw/layer-112.png", + "canvas": "layers-canvas/layer-112.png", + "alphaBox": [ + 292, + 694, + 362, + 1121 + ] + }, + { + "index": 113, + "width": 167, + "height": 570, + "page": "167x570+254+646", + "x": 254, + "y": 646, + "raw": "layers-raw/layer-113.png", + "canvas": "layers-canvas/layer-113.png", + "alphaBox": [ + 263, + 646, + 421, + 1215 + ] + }, + { + "index": 114, + "width": 156, + "height": 615, + "page": "156x615+635+618", + "x": 635, + "y": 618, + "raw": "layers-raw/layer-114.png", + "canvas": "layers-canvas/layer-114.png", + "alphaBox": [ + 635, + 618, + 791, + 1233 + ] + }, + { + "index": 115, + "width": 205, + "height": 806, + "page": "205x806+596+427", + "x": 596, + "y": 427, + "raw": "layers-raw/layer-115.png", + "canvas": "layers-canvas/layer-115.png", + "alphaBox": [ + 596, + 427, + 801, + 1233 + ] + }, + { + "index": 116, + "width": 106, + "height": 336, + "page": "106x336+616+749", + "x": 616, + "y": 749, + "raw": "layers-raw/layer-116.png", + "canvas": "layers-canvas/layer-116.png", + "alphaBox": [ + 617, + 749, + 722, + 1085 + ] + }, + { + "index": 117, + "width": 70, + "height": 427, + "page": "70x427+693+713", + "x": 693, + "y": 713, + "raw": "layers-raw/layer-117.png", + "canvas": "layers-canvas/layer-117.png", + "alphaBox": [ + 693, + 713, + 763, + 1140 + ] + }, + { + "index": 118, + "width": 167, + "height": 570, + "page": "167x570+634+665", + "x": 634, + "y": 665, + "raw": "layers-raw/layer-118.png", + "canvas": "layers-canvas/layer-118.png", + "alphaBox": [ + 634, + 665, + 792, + 1234 + ] + }, + { + "index": 119, + "width": 292, + "height": 498, + "page": "292x498+537+265", + "x": 537, + "y": 265, + "raw": "layers-raw/layer-119.png", + "canvas": "layers-canvas/layer-119.png", + "alphaBox": [ + 537, + 265, + 829, + 763 + ] + }, + { + "index": 120, + "width": 235, + "height": 521, + "page": "235x521+616+276", + "x": 616, + "y": 276, + "raw": "layers-raw/layer-120.png", + "canvas": "layers-canvas/layer-120.png", + "alphaBox": [ + 619, + 277, + 851, + 797 + ] + }, + { + "index": 121, + "width": 192, + "height": 219, + "page": "192x219+676+451", + "x": 676, + "y": 451, + "raw": "layers-raw/layer-121.png", + "canvas": "layers-canvas/layer-121.png", + "alphaBox": [ + 676, + 451, + 867, + 665 + ] + }, + { + "index": 122, + "width": 279, + "height": 493, + "page": "279x493+542+283", + "x": 542, + "y": 283, + "raw": "layers-raw/layer-122.png", + "canvas": "layers-canvas/layer-122.png", + "alphaBox": [ + 543, + 283, + 821, + 776 + ] + }, + { + "index": 123, + "width": 105, + "height": 66, + "page": "105x66+541+284", + "x": 541, + "y": 284, + "raw": "layers-raw/layer-123.png", + "canvas": "layers-canvas/layer-123.png", + "alphaBox": [ + 541, + 284, + 646, + 348 + ] + }, + { + "index": 124, + "width": 160, + "height": 257, + "page": "160x257+665+393", + "x": 665, + "y": 393, + "raw": "layers-raw/layer-124.png", + "canvas": "layers-canvas/layer-124.png", + "alphaBox": [ + 665, + 393, + 824, + 650 + ] + }, + { + "index": 125, + "width": 288, + "height": 503, + "page": "288x503+542+265", + "x": 542, + "y": 265, + "raw": "layers-raw/layer-125.png", + "canvas": "layers-canvas/layer-125.png", + "alphaBox": [ + 543, + 265, + 830, + 768 + ] + }, + { + "index": 126, + "width": 292, + "height": 498, + "page": "292x498+256+266", + "x": 256, + "y": 266, + "raw": "layers-raw/layer-126.png", + "canvas": "layers-canvas/layer-126.png", + "alphaBox": [ + 256, + 266, + 548, + 764 + ] + }, + { + "index": 127, + "width": 235, + "height": 521, + "page": "235x521+234+277", + "x": 234, + "y": 277, + "raw": "layers-raw/layer-127.png", + "canvas": "layers-canvas/layer-127.png", + "alphaBox": [ + 234, + 278, + 466, + 798 + ] + }, + { + "index": 128, + "width": 192, + "height": 219, + "page": "192x219+217+452", + "x": 217, + "y": 452, + "raw": "layers-raw/layer-128.png", + "canvas": "layers-canvas/layer-128.png", + "alphaBox": [ + 218, + 452, + 409, + 666 + ] + }, + { + "index": 129, + "width": 279, + "height": 493, + "page": "279x493+264+284", + "x": 264, + "y": 284, + "raw": "layers-raw/layer-129.png", + "canvas": "layers-canvas/layer-129.png", + "alphaBox": [ + 264, + 284, + 542, + 777 + ] + }, + { + "index": 130, + "width": 105, + "height": 66, + "page": "105x66+439+285", + "x": 439, + "y": 285, + "raw": "layers-raw/layer-130.png", + "canvas": "layers-canvas/layer-130.png", + "alphaBox": [ + 439, + 285, + 544, + 349 + ] + }, + { + "index": 131, + "width": 160, + "height": 257, + "page": "160x257+260+394", + "x": 260, + "y": 394, + "raw": "layers-raw/layer-131.png", + "canvas": "layers-canvas/layer-131.png", + "alphaBox": [ + 261, + 394, + 420, + 651 + ] + }, + { + "index": 132, + "width": 288, + "height": 503, + "page": "288x503+255+266", + "x": 255, + "y": 266, + "raw": "layers-raw/layer-132.png", + "canvas": "layers-canvas/layer-132.png", + "alphaBox": [ + 255, + 266, + 542, + 769 + ] + }, + { + "index": 133, + "width": 125, + "height": 91, + "page": "125x91+493+760", + "x": 493, + "y": 760, + "raw": "layers-raw/layer-133.png", + "canvas": "layers-canvas/layer-133.png", + "alphaBox": [ + 494, + 760, + 617, + 851 + ] + }, + { + "index": 134, + "width": 115, + "height": 34, + "page": "115x34+496+753", + "x": 496, + "y": 753, + "raw": "layers-raw/layer-134.png", + "canvas": "layers-canvas/layer-134.png", + "alphaBox": [ + 496, + 753, + 610, + 787 + ] + }, + { + "index": 135, + "width": 111, + "height": 20, + "page": "111x20+499+768", + "x": 499, + "y": 768, + "raw": "layers-raw/layer-135.png", + "canvas": "layers-canvas/layer-135.png", + "alphaBox": [ + 500, + 768, + 609, + 787 + ] + }, + { + "index": 136, + "width": 98, + "height": 40, + "page": "98x40+508+783", + "x": 508, + "y": 783, + "raw": "layers-raw/layer-136.png", + "canvas": "layers-canvas/layer-136.png", + "alphaBox": [ + 509, + 784, + 604, + 822 + ] + }, + { + "index": 137, + "width": 99, + "height": 43, + "page": "99x43+507+782", + "x": 507, + "y": 782, + "raw": "layers-raw/layer-137.png", + "canvas": "layers-canvas/layer-137.png", + "alphaBox": [ + 508, + 782, + 606, + 824 + ] + }, + { + "index": 138, + "width": 81, + "height": 64, + "page": "81x64+517+785", + "x": 517, + "y": 785, + "raw": "layers-raw/layer-138.png", + "canvas": "layers-canvas/layer-138.png", + "alphaBox": [ + 518, + 785, + 597, + 849 + ] + }, + { + "index": 139, + "width": 103, + "height": 85, + "page": "103x85+507+777", + "x": 507, + "y": 777, + "raw": "layers-raw/layer-139.png", + "canvas": "layers-canvas/layer-139.png", + "alphaBox": [ + 509, + 778, + 610, + 861 + ] + }, + { + "index": 140, + "width": 135, + "height": 133, + "page": "135x133+516+785", + "x": 516, + "y": 785, + "raw": "layers-raw/layer-140.png", + "canvas": "layers-canvas/layer-140.png", + "alphaBox": [ + 517, + 785, + 620, + 887 + ] + }, + { + "index": 141, + "width": 99, + "height": 41, + "page": "99x41+508+795", + "x": 508, + "y": 795, + "raw": "layers-raw/layer-141.png", + "canvas": "layers-canvas/layer-141.png", + "alphaBox": [ + 509, + 795, + 607, + 836 + ] + }, + { + "index": 142, + "width": 108, + "height": 63, + "page": "108x63+505+771", + "x": 505, + "y": 771, + "raw": "layers-raw/layer-142.png", + "canvas": "layers-canvas/layer-142.png", + "alphaBox": [ + 505, + 774, + 613, + 833 + ] + }, + { + "index": 143, + "width": 102, + "height": 42, + "page": "102x42+505+795", + "x": 505, + "y": 795, + "raw": "layers-raw/layer-143.png", + "canvas": "layers-canvas/layer-143.png", + "alphaBox": [ + 506, + 795, + 607, + 837 + ] + }, + { + "index": 144, + "width": 134, + "height": 65, + "page": "134x65+486+749", + "x": 486, + "y": 749, + "raw": "layers-raw/layer-144.png", + "canvas": "layers-canvas/layer-144.png", + "alphaBox": [ + 486, + 749, + 619, + 792 + ] + }, + { + "index": 145, + "width": 109, + "height": 56, + "page": "109x56+497+760", + "x": 497, + "y": 760, + "raw": "layers-raw/layer-145.png", + "canvas": "layers-canvas/layer-145.png", + "alphaBox": [ + 506, + 760, + 605, + 815 + ] + }, + { + "index": 146, + "width": 149, + "height": 94, + "page": "149x94+481+761", + "x": 481, + "y": 761, + "raw": "layers-raw/layer-146.png", + "canvas": "layers-canvas/layer-146.png", + "alphaBox": [ + 492, + 761, + 629, + 854 + ] + }, + { + "index": 147, + "width": 32, + "height": 14, + "page": "32x14+524+826", + "x": 524, + "y": 826, + "raw": "layers-raw/layer-147.png", + "canvas": "layers-canvas/layer-147.png", + "alphaBox": [ + 524, + 826, + 556, + 840 + ] + }, + { + "index": 148, + "width": 120, + "height": 55, + "page": "120x55+485+789", + "x": 485, + "y": 789, + "raw": "layers-raw/layer-148.png", + "canvas": "layers-canvas/layer-148.png", + "alphaBox": [ + 487, + 790, + 605, + 843 + ] + }, + { + "index": 149, + "width": 200, + "height": 127, + "page": "200x127+379+829", + "x": 379, + "y": 829, + "raw": "layers-raw/layer-149.png", + "canvas": "layers-canvas/layer-149.png", + "alphaBox": [ + 380, + 830, + 577, + 956 + ] + }, + { + "index": 150, + "width": 98, + "height": 68, + "page": "98x68+506+766", + "x": 506, + "y": 766, + "raw": "layers-raw/layer-150.png", + "canvas": "layers-canvas/layer-150.png", + "alphaBox": [ + 506, + 766, + 604, + 834 + ] + }, + { + "index": 151, + "width": 80, + "height": 28, + "page": "80x28+519+782", + "x": 519, + "y": 782, + "raw": "layers-raw/layer-151.png", + "canvas": "layers-canvas/layer-151.png", + "alphaBox": [ + 521, + 785, + 597, + 809 + ] + }, + { + "index": 152, + "width": 79, + "height": 10, + "page": "79x10+517+786", + "x": 517, + "y": 786, + "raw": "layers-raw/layer-152.png", + "canvas": "layers-canvas/layer-152.png", + "alphaBox": [ + 517, + 786, + 596, + 796 + ] + } + ] +} \ No newline at end of file diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-000.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-000.png new file mode 100644 index 0000000..003dbc2 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-000.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-001.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-001.png new file mode 100644 index 0000000..056a14f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-001.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-002.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-002.png new file mode 100644 index 0000000..58d8a14 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-002.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-003.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-003.png new file mode 100644 index 0000000..c0dd10c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-003.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-004.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-004.png new file mode 100644 index 0000000..6cc8377 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-004.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-005.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-005.png new file mode 100644 index 0000000..8be058e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-005.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-006.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-006.png new file mode 100644 index 0000000..8d67e5d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-006.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-007.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-007.png new file mode 100644 index 0000000..4b2f82d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-007.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-008.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-008.png new file mode 100644 index 0000000..81f15fc Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-008.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-009.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-009.png new file mode 100644 index 0000000..bd0aba1 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-009.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-010.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-010.png new file mode 100644 index 0000000..6632f59 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-010.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-011.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-011.png new file mode 100644 index 0000000..6a39015 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-011.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-012.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-012.png new file mode 100644 index 0000000..1ddbed5 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-012.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-013.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-013.png new file mode 100644 index 0000000..47624c0 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-013.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-014.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-014.png new file mode 100644 index 0000000..97f0b84 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-014.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-015.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-015.png new file mode 100644 index 0000000..5bbc559 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-015.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-016.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-016.png new file mode 100644 index 0000000..b9f2336 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-016.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-017.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-017.png new file mode 100644 index 0000000..afc2b4d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-017.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-018.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-018.png new file mode 100644 index 0000000..ccf9688 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-018.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-019.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-019.png new file mode 100644 index 0000000..b3ddc36 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-019.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-020.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-020.png new file mode 100644 index 0000000..b6126b1 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-020.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-021.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-021.png new file mode 100644 index 0000000..dadeb86 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-021.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-022.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-022.png new file mode 100644 index 0000000..bb87ad4 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-022.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-023.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-023.png new file mode 100644 index 0000000..98c0dba Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-023.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-024.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-024.png new file mode 100644 index 0000000..b8238a7 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-024.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-025.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-025.png new file mode 100644 index 0000000..9ad444d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-025.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-026.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-026.png new file mode 100644 index 0000000..75669dc Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-026.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-027.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-027.png new file mode 100644 index 0000000..dd3e303 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-027.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-028.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-028.png new file mode 100644 index 0000000..492b948 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-028.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-029.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-029.png new file mode 100644 index 0000000..6ecc811 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-029.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-030.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-030.png new file mode 100644 index 0000000..d7d9027 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-030.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-031.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-031.png new file mode 100644 index 0000000..4f80450 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-031.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-032.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-032.png new file mode 100644 index 0000000..c53541c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-032.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-033.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-033.png new file mode 100644 index 0000000..8af445f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-033.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-034.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-034.png new file mode 100644 index 0000000..0ff2d83 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-034.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-035.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-035.png new file mode 100644 index 0000000..45f99a4 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-035.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-036.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-036.png new file mode 100644 index 0000000..8f8442a Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-036.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-037.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-037.png new file mode 100644 index 0000000..4de678c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-037.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-038.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-038.png new file mode 100644 index 0000000..6e84eb8 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-038.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-039.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-039.png new file mode 100644 index 0000000..2897e7f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-039.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-040.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-040.png new file mode 100644 index 0000000..f38d694 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-040.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-041.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-041.png new file mode 100644 index 0000000..1543f95 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-041.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-042.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-042.png new file mode 100644 index 0000000..5d30b0a Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-042.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-043.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-043.png new file mode 100644 index 0000000..b2211ff Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-043.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-044.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-044.png new file mode 100644 index 0000000..0a75251 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-044.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-045.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-045.png new file mode 100644 index 0000000..418def7 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-045.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-046.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-046.png new file mode 100644 index 0000000..c467b03 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-046.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-047.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-047.png new file mode 100644 index 0000000..22f52d6 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-047.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-048.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-048.png new file mode 100644 index 0000000..983b27e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-048.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-049.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-049.png new file mode 100644 index 0000000..3f70ece Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-049.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-050.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-050.png new file mode 100644 index 0000000..ec37ef8 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-050.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-051.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-051.png new file mode 100644 index 0000000..793b2b6 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-051.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-052.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-052.png new file mode 100644 index 0000000..f435a47 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-052.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-053.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-053.png new file mode 100644 index 0000000..bd90d6a Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-053.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-054.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-054.png new file mode 100644 index 0000000..050b5f3 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-054.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-055.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-055.png new file mode 100644 index 0000000..bcbe52d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-055.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-056.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-056.png new file mode 100644 index 0000000..8341aa1 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-056.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-057.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-057.png new file mode 100644 index 0000000..45f6744 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-057.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-058.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-058.png new file mode 100644 index 0000000..d1d65b8 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-058.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-059.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-059.png new file mode 100644 index 0000000..3418b35 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-059.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-060.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-060.png new file mode 100644 index 0000000..261efec Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-060.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-061.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-061.png new file mode 100644 index 0000000..5b9e524 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-061.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-062.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-062.png new file mode 100644 index 0000000..365185f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-062.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-063.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-063.png new file mode 100644 index 0000000..18849c5 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-063.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-064.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-064.png new file mode 100644 index 0000000..5a2c436 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-064.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-065.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-065.png new file mode 100644 index 0000000..31f81cc Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-065.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-066.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-066.png new file mode 100644 index 0000000..a638513 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-066.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-067.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-067.png new file mode 100644 index 0000000..1d5f41b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-067.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-068.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-068.png new file mode 100644 index 0000000..e1e334b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-068.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-069.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-069.png new file mode 100644 index 0000000..08b13cc Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-069.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-070.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-070.png new file mode 100644 index 0000000..7fbbf3c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-070.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-071.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-071.png new file mode 100644 index 0000000..14c63ff Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-071.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-072.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-072.png new file mode 100644 index 0000000..1004007 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-072.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-073.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-073.png new file mode 100644 index 0000000..f40a041 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-073.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-074.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-074.png new file mode 100644 index 0000000..47771e1 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-074.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-075.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-075.png new file mode 100644 index 0000000..a69b3b4 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-075.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-076.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-076.png new file mode 100644 index 0000000..67d1d42 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-076.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-077.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-077.png new file mode 100644 index 0000000..aea78e6 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-077.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-078.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-078.png new file mode 100644 index 0000000..9b9b256 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-078.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-079.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-079.png new file mode 100644 index 0000000..9d28ee7 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-079.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-080.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-080.png new file mode 100644 index 0000000..38c4181 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-080.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-081.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-081.png new file mode 100644 index 0000000..3fcacb1 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-081.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-082.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-082.png new file mode 100644 index 0000000..7e16ef6 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-082.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-083.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-083.png new file mode 100644 index 0000000..522f808 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-083.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-084.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-084.png new file mode 100644 index 0000000..363ab5b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-084.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-085.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-085.png new file mode 100644 index 0000000..4eb99f0 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-085.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-086.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-086.png new file mode 100644 index 0000000..361b55d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-086.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-087.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-087.png new file mode 100644 index 0000000..4e611b5 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-087.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-088.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-088.png new file mode 100644 index 0000000..1ef27ea Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-088.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-089.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-089.png new file mode 100644 index 0000000..ba51da4 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-089.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-090.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-090.png new file mode 100644 index 0000000..64ff5ce Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-090.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-091.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-091.png new file mode 100644 index 0000000..966c9dc Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-091.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-092.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-092.png new file mode 100644 index 0000000..3e96c9f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-092.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-093.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-093.png new file mode 100644 index 0000000..9115a6f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-093.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-094.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-094.png new file mode 100644 index 0000000..2afc614 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-094.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-095.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-095.png new file mode 100644 index 0000000..2cce565 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-095.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-096.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-096.png new file mode 100644 index 0000000..e7448af Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-096.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-097.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-097.png new file mode 100644 index 0000000..6a4443d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-097.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-098.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-098.png new file mode 100644 index 0000000..a031c43 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-098.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-099.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-099.png new file mode 100644 index 0000000..e8e5665 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-099.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-100.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-100.png new file mode 100644 index 0000000..ceea5d2 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-100.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-101.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-101.png new file mode 100644 index 0000000..de2c133 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-101.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-102.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-102.png new file mode 100644 index 0000000..e843291 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-102.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-103.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-103.png new file mode 100644 index 0000000..f2de995 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-103.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-104.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-104.png new file mode 100644 index 0000000..e2f165e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-104.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-105.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-105.png new file mode 100644 index 0000000..cfee9f8 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-105.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-106.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-106.png new file mode 100644 index 0000000..fa2399f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-106.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-107.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-107.png new file mode 100644 index 0000000..592618e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-107.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-108.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-108.png new file mode 100644 index 0000000..e0fed90 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-108.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-109.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-109.png new file mode 100644 index 0000000..653d748 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-109.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-110.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-110.png new file mode 100644 index 0000000..70a65ba Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-110.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-111.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-111.png new file mode 100644 index 0000000..3f9de80 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-111.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-112.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-112.png new file mode 100644 index 0000000..558d21b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-112.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-113.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-113.png new file mode 100644 index 0000000..bb078a8 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-113.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-114.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-114.png new file mode 100644 index 0000000..0ef3807 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-114.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-115.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-115.png new file mode 100644 index 0000000..a973ebd Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-115.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-116.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-116.png new file mode 100644 index 0000000..d985bac Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-116.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-117.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-117.png new file mode 100644 index 0000000..3c9516e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-117.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-118.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-118.png new file mode 100644 index 0000000..25ce3fe Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-118.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-119.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-119.png new file mode 100644 index 0000000..62b1f47 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-119.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-120.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-120.png new file mode 100644 index 0000000..cadd218 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-120.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-121.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-121.png new file mode 100644 index 0000000..c5d180d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-121.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-122.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-122.png new file mode 100644 index 0000000..a7e45df Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-122.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-123.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-123.png new file mode 100644 index 0000000..2886524 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-123.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-124.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-124.png new file mode 100644 index 0000000..d501ca3 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-124.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-125.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-125.png new file mode 100644 index 0000000..9fa61b9 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-125.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-126.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-126.png new file mode 100644 index 0000000..4cbbfde Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-126.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-127.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-127.png new file mode 100644 index 0000000..ed8fb2c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-127.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-128.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-128.png new file mode 100644 index 0000000..6228bf1 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-128.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-129.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-129.png new file mode 100644 index 0000000..1909b00 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-129.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-130.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-130.png new file mode 100644 index 0000000..55020d6 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-130.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-131.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-131.png new file mode 100644 index 0000000..da305b6 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-131.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-132.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-132.png new file mode 100644 index 0000000..3093370 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-132.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-133.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-133.png new file mode 100644 index 0000000..235632c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-133.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-134.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-134.png new file mode 100644 index 0000000..4f2682d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-134.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-135.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-135.png new file mode 100644 index 0000000..ed7610e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-135.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-136.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-136.png new file mode 100644 index 0000000..44866ac Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-136.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-137.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-137.png new file mode 100644 index 0000000..eb785eb Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-137.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-138.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-138.png new file mode 100644 index 0000000..3bf24ea Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-138.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-139.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-139.png new file mode 100644 index 0000000..2c4ffc1 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-139.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-140.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-140.png new file mode 100644 index 0000000..0fb8eab Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-140.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-141.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-141.png new file mode 100644 index 0000000..b54244d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-141.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-142.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-142.png new file mode 100644 index 0000000..1f8c149 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-142.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-143.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-143.png new file mode 100644 index 0000000..29012f5 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-143.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-144.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-144.png new file mode 100644 index 0000000..b2fcd29 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-144.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-145.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-145.png new file mode 100644 index 0000000..fbd57cb Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-145.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-146.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-146.png new file mode 100644 index 0000000..badb399 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-146.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-147.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-147.png new file mode 100644 index 0000000..7d7622c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-147.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-148.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-148.png new file mode 100644 index 0000000..33e12a9 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-148.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-149.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-149.png new file mode 100644 index 0000000..11fb57c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-149.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-150.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-150.png new file mode 100644 index 0000000..9e2843f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-150.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-151.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-151.png new file mode 100644 index 0000000..968000b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-151.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-152.png b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-152.png new file mode 100644 index 0000000..567ac30 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/layers-raw/layer-152.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/body.png b/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/body.png new file mode 100644 index 0000000..62ed067 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/body.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/eye-left.png b/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/eye-left.png new file mode 100644 index 0000000..977b438 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/eye-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/eye-right.png b/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/eye-right.png new file mode 100644 index 0000000..70d0c9b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/eye-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/face.png b/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/face.png new file mode 100644 index 0000000..9c6a0d2 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/face.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/hair-back.png b/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/hair-back.png new file mode 100644 index 0000000..2ce81aa Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/hair-back.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/hair-front.png b/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/hair-front.png new file mode 100644 index 0000000..563006f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/hair-front.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/mouth.png b/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/mouth.png new file mode 100644 index 0000000..faef23e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/mouth.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/outfit.png b/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/outfit.png new file mode 100644 index 0000000..c6d6d1b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/top-groups/parts/outfit.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/top-groups/preview-psb-groups-transparent.png b/docs/avatar-art/seoyeon/live2d-psb/top-groups/preview-psb-groups-transparent.png new file mode 100644 index 0000000..30dd332 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/top-groups/preview-psb-groups-transparent.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/top-groups/preview-psb-groups.png b/docs/avatar-art/seoyeon/live2d-psb/top-groups/preview-psb-groups.png new file mode 100644 index 0000000..4edf321 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/top-groups/preview-psb-groups.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/top-groups/top-groups-contact.png b/docs/avatar-art/seoyeon/live2d-psb/top-groups/top-groups-contact.png new file mode 100644 index 0000000..c763a0a Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-psb/top-groups/top-groups-contact.png differ diff --git a/docs/avatar-art/seoyeon/live2d-psb/top-groups/top-groups-manifest.json b/docs/avatar-art/seoyeon/live2d-psb/top-groups/top-groups-manifest.json new file mode 100644 index 0000000..b21c572 --- /dev/null +++ b/docs/avatar-art/seoyeon/live2d-psb/top-groups/top-groups-manifest.json @@ -0,0 +1,117 @@ +{ + "source": "C:\\Users\\encep\\OneDrive\\문서\\카카오톡 받은 파일\\라투디 여캐.psb", + "sourceCanvas": [ + 1080, + 1920 + ], + "targetCanvas": [ + 900, + 1125 + ], + "cropBox": [ + 0, + 240, + 1080, + 1590 + ], + "layerOrder": [ + "hair-back", + "body", + "outfit", + "face", + "eye-left", + "eye-right", + "hair-front", + "mouth" + ], + "parts": [ + { + "id": "hair-back", + "source": "뒷머리", + "file": "hair-back.png", + "alphaBox": [ + 0, + 0, + 900, + 1125 + ] + }, + { + "id": "body", + "source": "몸", + "file": "body.png", + "alphaBox": [ + 339, + 439, + 605, + 759 + ] + }, + { + "id": "outfit", + "source": "의상", + "file": "outfit.png", + "alphaBox": [ + 130, + 521, + 806, + 1015 + ] + }, + { + "id": "face", + "source": "얼굴", + "file": "face.png", + "alphaBox": [ + 271, + 31, + 650, + 532 + ] + }, + { + "id": "eye-left", + "source": "좌 눈", + "file": "eye-left.png", + "alphaBox": [ + 296, + 292, + 436, + 392 + ] + }, + { + "id": "eye-right", + "source": "우 눈", + "file": "eye-right.png", + "alphaBox": [ + 472, + 283, + 619, + 377 + ] + }, + { + "id": "hair-front", + "source": "머리카락", + "file": "hair-front.png", + "alphaBox": [ + 212, + 20, + 692, + 829 + ] + }, + { + "id": "mouth", + "source": "입", + "file": "mouth.png", + "alphaBox": [ + 431, + 454, + 498, + 474 + ] + } + ] +} \ No newline at end of file diff --git a/docs/avatar-art/seoyeon/live2d-v2/extract-live2d-v2.py b/docs/avatar-art/seoyeon/live2d-v2/extract-live2d-v2.py new file mode 100644 index 0000000..ed1fb2c --- /dev/null +++ b/docs/avatar-art/seoyeon/live2d-v2/extract-live2d-v2.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +"""Extract a Live2D-style raster parts set from sheet-candidate-a.png. + +This is intentionally offline-only. It writes to docs/avatar-art/seoyeon/live2d-v2 +and does not publish anything to apps/web/public until the recomposition preview +is acceptable. +""" +from __future__ import annotations + +from pathlib import Path +from typing import Iterable + +import numpy as np +from PIL import Image, ImageDraw, ImageOps +from scipy import ndimage as ndi + +HERE = Path(__file__).resolve().parent +SHEET = HERE / "sheet-candidate-a.png" +OUT = HERE / "parts" +CANVAS = (900, 1125) + +Box = tuple[int, int, int, int] + + +def alpha_bbox(im: Image.Image, threshold: int = 8) -> Box | None: + alpha = np.array(im.getchannel("A")) + ys, xs = np.where(alpha > threshold) + if len(xs) == 0: + return None + return int(xs.min()), int(ys.min()), int(xs.max() + 1), int(ys.max() + 1) + + +def remove_white_bg(im: Image.Image, *, lo: float = 12.0, hi: float = 58.0) -> Image.Image: + arr = np.array(im.convert("RGBA")).astype(np.float32) + rgb = arr[:, :, :3] + border = np.concatenate( + [ + rgb[:6, :, :].reshape(-1, 3), + rgb[-6:, :, :].reshape(-1, 3), + rgb[:, :6, :].reshape(-1, 3), + rgb[:, -6:, :].reshape(-1, 3), + ], + axis=0, + ) + bg = np.median(border, axis=0) + dist = np.sqrt(((rgb - bg.reshape(1, 1, 3)) ** 2).sum(axis=2)) + white_dist = np.sqrt(((255.0 - rgb) ** 2).sum(axis=2)) + alpha = np.minimum( + np.clip((dist - lo) * 255.0 / max(1.0, hi - lo), 0, 255), + np.clip((white_dist - 12.0) * 255.0 / 48.0, 0, 255), + ) + + luma = rgb[:, :, 0] * 0.2126 + rgb[:, :, 1] * 0.7152 + rgb[:, :, 2] * 0.0722 + chroma = rgb.max(axis=2) - rgb.min(axis=2) + keep = (luma < 225) | (chroma > 14) + alpha = np.where(keep & (alpha > 28), np.maximum(alpha, 235), alpha) + + arr[:, :, 3] = np.minimum(alpha, arr[:, :, 3]) + arr[arr[:, :, 3] < 1, :3] = 0 + return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGBA") + + +def crop_part(sheet: Image.Image, src: Box, *, lo: float = 12, hi: float = 58) -> Image.Image: + return remove_white_bg(sheet.crop(src), lo=lo, hi=hi) + + +def filter_components(im: Image.Image, *, min_area: int = 0, keep_largest: bool = False) -> Image.Image: + if min_area <= 0 and not keep_largest: + return im + arr = np.array(im.convert("RGBA")) + alpha = arr[:, :, 3] > 8 + labels, count = ndi.label(alpha) + if count == 0: + return im + areas = np.bincount(labels.reshape(-1)) + areas[0] = 0 + if keep_largest: + keep = labels == int(areas.argmax()) + else: + keep_labels = np.where(areas >= min_area)[0] + keep = np.isin(labels, keep_labels) + arr[:, :, 3] = np.where(keep, arr[:, :, 3], 0) + arr[arr[:, :, 3] == 0, :3] = 0 + return Image.fromarray(arr, "RGBA") + + +def place_exact(part: Image.Image, dst: Box) -> Image.Image: + w, h = dst[2] - dst[0], dst[3] - dst[1] + resized = part.resize((w, h), Image.Resampling.LANCZOS) + canvas = Image.new("RGBA", CANVAS, (0, 0, 0, 0)) + canvas.alpha_composite(resized, (dst[0], dst[1])) + return canvas + + +def save_part( + sheet: Image.Image, + name: str, + src: Box, + dst: Box, + *, + lo: float = 12, + hi: float = 58, + min_area: int = 0, + keep_largest: bool = False, + flip_x: bool = False, +) -> Image.Image: + part = filter_components(crop_part(sheet, src, lo=lo, hi=hi), min_area=min_area, keep_largest=keep_largest) + if flip_x: + part = ImageOps.mirror(part) + im = place_exact(part, dst) + im.save(OUT / f"{name}.png", optimize=True) + print(f"{name:20} src={src} dst={dst} bbox={alpha_bbox(im)}") + return im + + +def save_shape_crop(sheet: Image.Image, name: str, src: Box, dst: Box, *, shape: str = "ellipse") -> Image.Image: + part = sheet.crop(src).convert("RGBA") + mask = Image.new("L", part.size, 0) + draw = ImageDraw.Draw(mask) + if shape == "ellipse": + draw.ellipse((1, 1, part.width - 2, part.height - 2), fill=255) + else: + draw.rounded_rectangle((1, 1, part.width - 2, part.height - 2), radius=max(2, part.height // 3), fill=255) + arr = np.array(part) + arr[:, :, 3] = np.minimum(arr[:, :, 3], np.array(mask)) + arr[arr[:, :, 3] == 0, :3] = 0 + im = place_exact(Image.fromarray(arr, "RGBA"), dst) + im.save(OUT / f"{name}.png", optimize=True) + print(f"{name:20} src={src} dst={dst} bbox={alpha_bbox(im)}") + return im + + +def save_solid_ellipse(name: str, dst: Box, color: tuple[int, int, int, int]) -> Image.Image: + w, h = dst[2] - dst[0], dst[3] - dst[1] + part = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + draw = ImageDraw.Draw(part) + draw.ellipse((0, 0, w - 1, h - 1), fill=color) + canvas = Image.new("RGBA", CANVAS, (0, 0, 0, 0)) + canvas.alpha_composite(part, (dst[0], dst[1])) + canvas.save(OUT / f"{name}.png", optimize=True) + print(f"{name:20} solid dst={dst} bbox={alpha_bbox(canvas)}") + return canvas + + +def save_duplicate(name: str, src_name: str) -> Image.Image: + im = Image.open(OUT / f"{src_name}.png").convert("RGBA") + im.save(OUT / f"{name}.png", optimize=True) + print(f"{name:20} duplicate={src_name} bbox={alpha_bbox(im)}") + return im + + +def composite(names: Iterable[str], out_path: Path) -> Image.Image: + bg = Image.new("RGBA", CANVAS, (30, 39, 36, 255)) + for name in names: + bg.alpha_composite(Image.open(OUT / f"{name}.png").convert("RGBA")) + bg.save(out_path, optimize=True) + print("preview", out_path) + return bg + + +def make_contact() -> None: + names = [ + "head-faceless", + "torso", + "hair-back", + "hair-front", + "hair-left", + "hair-right", + "brow-left", + "brow-right", + "eye-white-left", + "eye-white-right", + "iris-left", + "iris-right", + "pupil-left", + "pupil-right", + "highlight-left", + "highlight-right", + "lash-left", + "lash-right", + "eyelid-left-closed", + "eyelid-right-closed", + "nose", + "mouth-neutral", + "mouth-sad", + "mouth-warm", + "mouth-open", + ] + tile_w, tile_h = 180, 160 + cols = 5 + rows = (len(names) + cols - 1) // cols + sheet = Image.new("RGBA", (cols * tile_w, rows * tile_h), (30, 39, 36, 255)) + draw = ImageDraw.Draw(sheet) + for i, name in enumerate(names): + layer = Image.open(OUT / f"{name}.png").convert("RGBA") + box = alpha_bbox(layer) + thumb = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) if box is None else layer.crop(box) + thumb.thumbnail((tile_w - 24, tile_h - 42), Image.Resampling.LANCZOS) + x = (i % cols) * tile_w + (tile_w - thumb.width) // 2 + y = (i // cols) * tile_h + 16 + sheet.alpha_composite(thumb, (x, y)) + draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 22), name, fill=(200, 214, 208, 255)) + sheet.save(HERE / "parts-contact.png", optimize=True) + + +def main() -> int: + OUT.mkdir(parents=True, exist_ok=True) + sheet = Image.open(SHEET).convert("RGBA") + + parts: dict[str, Image.Image] = {} + + # Body/base layers. + parts["hair-back"] = save_part(sheet, "hair-back", (1145, 585, 1450, 925), (230, 90, 670, 560), lo=10, hi=46, min_area=900) + parts["torso"] = save_part(sheet, "torso", (540, 720, 970, 920), (165, 585, 735, 825), lo=12, hi=54, min_area=900) + parts["head-faceless"] = save_part(sheet, "head-faceless", (595, 40, 875, 465), (285, 98, 615, 600), lo=10, hi=48, min_area=900) + + # Hair layers. Front hair is intentionally above the face, as in a real PSD. + parts["hair-left"] = save_part(sheet, "hair-left", (1118, 305, 1265, 600), (150, 200, 360, 555), lo=10, hi=46, min_area=900) + parts["hair-right"] = save_part(sheet, "hair-right", (1362, 302, 1510, 600), (540, 200, 750, 555), lo=10, hi=46, min_area=900) + parts["hair-front"] = save_part(sheet, "hair-front", (1135, 45, 1518, 260), (230, 84, 670, 320), lo=10, hi=46, min_area=900) + + # Brows and eye components. These are separate so a later renderer can move + # irises/pupils within eye-white masks instead of scaling the whole eye. + parts["brow-left"] = save_part(sheet, "brow-left", (920, 112, 1010, 140), (322, 292, 410, 314), lo=8, hi=38, min_area=18) + parts["brow-right"] = save_part(sheet, "brow-right", (1040, 112, 1130, 140), (490, 292, 578, 314), lo=8, hi=38, min_area=18) + parts["eye-white-left"] = save_shape_crop(sheet, "eye-white-left", (920, 154, 1015, 202), (323, 326, 397, 356), shape="ellipse") + parts["eye-white-right"] = save_shape_crop(sheet, "eye-white-right", (920, 154, 1015, 202), (503, 326, 577, 356), shape="ellipse") + parts["iris-left"] = save_part(sheet, "iris-left", (944, 232, 994, 282), (345, 326, 377, 358), lo=8, hi=42, keep_largest=True) + parts["iris-right"] = save_part(sheet, "iris-right", (944, 232, 994, 282), (523, 326, 555, 358), lo=8, hi=42, keep_largest=True, flip_x=True) + parts["pupil-left"] = save_part(sheet, "pupil-left", (954, 314, 990, 350), (353, 334, 373, 354), lo=8, hi=40, keep_largest=True) + parts["pupil-right"] = save_part(sheet, "pupil-right", (954, 314, 990, 350), (527, 334, 547, 354), lo=8, hi=40, keep_largest=True, flip_x=True) + parts["highlight-left"] = save_solid_ellipse("highlight-left", (360, 328, 368, 336), (248, 246, 238, 235)) + parts["highlight-right"] = save_solid_ellipse("highlight-right", (534, 328, 542, 336), (248, 246, 238, 235)) + parts["lash-left"] = save_part(sheet, "lash-left", (918, 420, 1018, 463), (318, 318, 402, 360), lo=8, hi=38, min_area=18) + parts["lash-right"] = save_part(sheet, "lash-right", (918, 420, 1018, 463), (498, 318, 582, 360), lo=8, hi=38, min_area=18, flip_x=True) + parts["eyelid-left-closed"] = save_part(sheet, "eyelid-left-closed", (918, 480, 1018, 510), (320, 338, 400, 360), lo=8, hi=38, min_area=18) + parts["eyelid-right-closed"] = save_part(sheet, "eyelid-right-closed", (918, 480, 1018, 510), (500, 338, 580, 360), lo=8, hi=38, min_area=18, flip_x=True) + + parts["nose"] = save_part(sheet, "nose", (985, 550, 1045, 595), (424, 388, 476, 428), lo=8, hi=40, keep_largest=True) + parts["mouth-neutral"] = save_part(sheet, "mouth-neutral", (965, 612, 1065, 650), (398, 430, 502, 458), lo=8, hi=40, keep_largest=True) + parts["mouth-sad"] = save_part(sheet, "mouth-sad", (965, 672, 1065, 712), (398, 430, 502, 460), lo=8, hi=40, keep_largest=True) + parts["mouth-warm"] = save_part(sheet, "mouth-warm", (965, 735, 1065, 775), (398, 430, 502, 460), lo=8, hi=40, keep_largest=True) + parts["mouth-open"] = save_part(sheet, "mouth-open", (970, 790, 1065, 850), (400, 418, 500, 472), lo=8, hi=42, keep_largest=True) + + # Compatibility aliases for expression-level renderers. + save_duplicate("brow-neutral", "brow-left") + save_duplicate("eyes-neutral", "eye-white-left") + + neutral_order = [ + "hair-back", + "torso", + "head-faceless", + "hair-front", + "eye-white-left", + "eye-white-right", + "iris-left", + "iris-right", + "pupil-left", + "pupil-right", + "highlight-left", + "highlight-right", + "lash-left", + "lash-right", + "brow-left", + "brow-right", + "nose", + "mouth-neutral", + ] + composite(neutral_order, HERE / "preview-neutral.png") + + blink_order = [ + "hair-back", + "torso", + "head-faceless", + "hair-front", + "eyelid-left-closed", + "eyelid-right-closed", + "brow-left", + "brow-right", + "nose", + "mouth-neutral", + ] + composite(blink_order, HERE / "preview-blink.png") + + speaking_order = [ + *neutral_order[:-1], + "mouth-open", + ] + composite(speaking_order, HERE / "preview-speaking.png") + make_contact() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/avatar-art/seoyeon/live2d-v2/eye-parts-grid.png b/docs/avatar-art/seoyeon/live2d-v2/eye-parts-grid.png new file mode 100644 index 0000000..c80271b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/eye-parts-grid.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/mouth-parts-grid.png b/docs/avatar-art/seoyeon/live2d-v2/mouth-parts-grid.png new file mode 100644 index 0000000..9982481 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/mouth-parts-grid.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts-contact.png b/docs/avatar-art/seoyeon/live2d-v2/parts-contact.png new file mode 100644 index 0000000..dfc6d8c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts-contact.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/brow-left.png b/docs/avatar-art/seoyeon/live2d-v2/parts/brow-left.png new file mode 100644 index 0000000..d10f479 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/brow-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/brow-neutral.png b/docs/avatar-art/seoyeon/live2d-v2/parts/brow-neutral.png new file mode 100644 index 0000000..d10f479 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/brow-neutral.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/brow-right.png b/docs/avatar-art/seoyeon/live2d-v2/parts/brow-right.png new file mode 100644 index 0000000..6ce8242 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/brow-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/eye-white-left.png b/docs/avatar-art/seoyeon/live2d-v2/parts/eye-white-left.png new file mode 100644 index 0000000..dc5435b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/eye-white-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/eye-white-right.png b/docs/avatar-art/seoyeon/live2d-v2/parts/eye-white-right.png new file mode 100644 index 0000000..ac3af41 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/eye-white-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/eyelid-left-closed.png b/docs/avatar-art/seoyeon/live2d-v2/parts/eyelid-left-closed.png new file mode 100644 index 0000000..933ce28 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/eyelid-left-closed.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/eyelid-right-closed.png b/docs/avatar-art/seoyeon/live2d-v2/parts/eyelid-right-closed.png new file mode 100644 index 0000000..172d628 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/eyelid-right-closed.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/eyes-neutral.png b/docs/avatar-art/seoyeon/live2d-v2/parts/eyes-neutral.png new file mode 100644 index 0000000..dc5435b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/eyes-neutral.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/hair-back.png b/docs/avatar-art/seoyeon/live2d-v2/parts/hair-back.png new file mode 100644 index 0000000..3711414 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/hair-back.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/hair-front.png b/docs/avatar-art/seoyeon/live2d-v2/parts/hair-front.png new file mode 100644 index 0000000..97800eb Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/hair-front.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/hair-left.png b/docs/avatar-art/seoyeon/live2d-v2/parts/hair-left.png new file mode 100644 index 0000000..a553500 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/hair-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/hair-right.png b/docs/avatar-art/seoyeon/live2d-v2/parts/hair-right.png new file mode 100644 index 0000000..7fa35d9 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/hair-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/head-faceless.png b/docs/avatar-art/seoyeon/live2d-v2/parts/head-faceless.png new file mode 100644 index 0000000..f17755c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/head-faceless.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/highlight-left.png b/docs/avatar-art/seoyeon/live2d-v2/parts/highlight-left.png new file mode 100644 index 0000000..7f72da8 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/highlight-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/highlight-right.png b/docs/avatar-art/seoyeon/live2d-v2/parts/highlight-right.png new file mode 100644 index 0000000..c3cde5e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/highlight-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/iris-left.png b/docs/avatar-art/seoyeon/live2d-v2/parts/iris-left.png new file mode 100644 index 0000000..7932959 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/iris-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/iris-right.png b/docs/avatar-art/seoyeon/live2d-v2/parts/iris-right.png new file mode 100644 index 0000000..b051432 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/iris-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/lash-left.png b/docs/avatar-art/seoyeon/live2d-v2/parts/lash-left.png new file mode 100644 index 0000000..b4f915c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/lash-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/lash-right.png b/docs/avatar-art/seoyeon/live2d-v2/parts/lash-right.png new file mode 100644 index 0000000..66dd9ba Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/lash-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/mouth-neutral.png b/docs/avatar-art/seoyeon/live2d-v2/parts/mouth-neutral.png new file mode 100644 index 0000000..933b73b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/mouth-neutral.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/mouth-open.png b/docs/avatar-art/seoyeon/live2d-v2/parts/mouth-open.png new file mode 100644 index 0000000..16d9a6a Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/mouth-open.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/mouth-sad.png b/docs/avatar-art/seoyeon/live2d-v2/parts/mouth-sad.png new file mode 100644 index 0000000..e4783d3 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/mouth-sad.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/mouth-warm.png b/docs/avatar-art/seoyeon/live2d-v2/parts/mouth-warm.png new file mode 100644 index 0000000..c1e1e1e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/mouth-warm.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/nose.png b/docs/avatar-art/seoyeon/live2d-v2/parts/nose.png new file mode 100644 index 0000000..9aa7236 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/nose.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/pupil-left.png b/docs/avatar-art/seoyeon/live2d-v2/parts/pupil-left.png new file mode 100644 index 0000000..6c44ee1 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/pupil-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/pupil-right.png b/docs/avatar-art/seoyeon/live2d-v2/parts/pupil-right.png new file mode 100644 index 0000000..2b4c090 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/pupil-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/parts/torso.png b/docs/avatar-art/seoyeon/live2d-v2/parts/torso.png new file mode 100644 index 0000000..c00fadb Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/parts/torso.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/preview-blink.png b/docs/avatar-art/seoyeon/live2d-v2/preview-blink.png new file mode 100644 index 0000000..288e0da Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/preview-blink.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/preview-neutral.png b/docs/avatar-art/seoyeon/live2d-v2/preview-neutral.png new file mode 100644 index 0000000..f72f2fc Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/preview-neutral.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/preview-speaking.png b/docs/avatar-art/seoyeon/live2d-v2/preview-speaking.png new file mode 100644 index 0000000..a872de8 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/preview-speaking.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/sheet-candidate-a.png b/docs/avatar-art/seoyeon/live2d-v2/sheet-candidate-a.png new file mode 100644 index 0000000..0574695 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/sheet-candidate-a.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v2/sheet-candidate-b.png b/docs/avatar-art/seoyeon/live2d-v2/sheet-candidate-b.png new file mode 100644 index 0000000..d400bc8 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v2/sheet-candidate-b.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/body-torso-cutout.png b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/body-torso-cutout.png new file mode 100644 index 0000000..dc645ae Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/body-torso-cutout.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/body-torso-input.png b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/body-torso-input.png new file mode 100644 index 0000000..b5bd109 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/body-torso-input.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/body-torso.sha256 b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/body-torso.sha256 new file mode 100644 index 0000000..5c67667 --- /dev/null +++ b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/body-torso.sha256 @@ -0,0 +1 @@ +dd0654bc60827deb6be7b9768ef0e747e3972e3370e8239510e89fd6d5df9ddd \ No newline at end of file diff --git a/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-back-cutout.png b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-back-cutout.png new file mode 100644 index 0000000..b2ed554 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-back-cutout.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-back-input.png b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-back-input.png new file mode 100644 index 0000000..cf15267 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-back-input.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-back.sha256 b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-back.sha256 new file mode 100644 index 0000000..ee22d75 --- /dev/null +++ b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-back.sha256 @@ -0,0 +1 @@ +0d0253579f1876067b2082af61d801d26a46f99033f2f637aa7afc9148dd2cf4 \ No newline at end of file diff --git a/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-front-cutout.png b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-front-cutout.png new file mode 100644 index 0000000..e99a94e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-front-cutout.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-front-input.png b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-front-input.png new file mode 100644 index 0000000..908907b Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-front-input.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-front.sha256 b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-front.sha256 new file mode 100644 index 0000000..215d35a --- /dev/null +++ b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-front.sha256 @@ -0,0 +1 @@ +8ccd24cef046e5b7b16e7c3c87e64da3415ad9d4353cf99d0069eb14700dce72 \ No newline at end of file diff --git a/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-left-cutout.png b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-left-cutout.png new file mode 100644 index 0000000..bb60fbf Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-left-cutout.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-left-input.png b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-left-input.png new file mode 100644 index 0000000..ab24c4a Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-left-input.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-left.sha256 b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-left.sha256 new file mode 100644 index 0000000..1295852 --- /dev/null +++ b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-left.sha256 @@ -0,0 +1 @@ +6dd8c6b87cd093e7beb42f23650f319852d3b6d3c5916c922360ee31b440924c \ No newline at end of file diff --git a/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-right-cutout.png b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-right-cutout.png new file mode 100644 index 0000000..c4980b0 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-right-cutout.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-right-input.png b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-right-input.png new file mode 100644 index 0000000..7eaeb36 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-right-input.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-right.sha256 b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-right.sha256 new file mode 100644 index 0000000..dd675a4 --- /dev/null +++ b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/hair-right.sha256 @@ -0,0 +1 @@ +2b149c379cd66aab04b7784f17ad301657c1cead46eedbd7c830845bbf39c352 \ No newline at end of file diff --git a/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/head-faceless-cutout.png b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/head-faceless-cutout.png new file mode 100644 index 0000000..4e8cb9c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/head-faceless-cutout.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/head-faceless-input.png b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/head-faceless-input.png new file mode 100644 index 0000000..721c618 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/head-faceless-input.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/head-faceless.sha256 b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/head-faceless.sha256 new file mode 100644 index 0000000..4b34a42 --- /dev/null +++ b/docs/avatar-art/seoyeon/live2d-v3/ai-cutout-work/head-faceless.sha256 @@ -0,0 +1 @@ +2ffa463b3564944c406402cdff1ebb770d520d3df5ef8a60f858da0f799745a1 \ No newline at end of file diff --git a/docs/avatar-art/seoyeon/live2d-v3/app-render/render-grid.png b/docs/avatar-art/seoyeon/live2d-v3/app-render/render-grid.png new file mode 100644 index 0000000..dc8eab7 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/app-render/render-grid.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/app-render/render-hero-0.png b/docs/avatar-art/seoyeon/live2d-v3/app-render/render-hero-0.png new file mode 100644 index 0000000..5d3f349 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/app-render/render-hero-0.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/app-render/render-hero-1.png b/docs/avatar-art/seoyeon/live2d-v3/app-render/render-hero-1.png new file mode 100644 index 0000000..c806163 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/app-render/render-hero-1.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/app-render/render-hero-2.png b/docs/avatar-art/seoyeon/live2d-v3/app-render/render-hero-2.png new file mode 100644 index 0000000..bda862e Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/app-render/render-hero-2.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/app-render/render-hero-3.png b/docs/avatar-art/seoyeon/live2d-v3/app-render/render-hero-3.png new file mode 100644 index 0000000..3158796 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/app-render/render-hero-3.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/app-render/render-hero.png b/docs/avatar-art/seoyeon/live2d-v3/app-render/render-hero.png new file mode 100644 index 0000000..62b6795 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/app-render/render-hero.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/debug-nose-crop.png b/docs/avatar-art/seoyeon/live2d-v3/debug-nose-crop.png new file mode 100644 index 0000000..2b918c4 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/debug-nose-crop.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/layout-template-v3-clean.png b/docs/avatar-art/seoyeon/live2d-v3/layout-template-v3-clean.png new file mode 100644 index 0000000..bf233b1 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/layout-template-v3-clean.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/layout-template-v3.png b/docs/avatar-art/seoyeon/live2d-v3/layout-template-v3.png new file mode 100644 index 0000000..0481933 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/layout-template-v3.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/layout-v3.json b/docs/avatar-art/seoyeon/live2d-v3/layout-v3.json new file mode 100644 index 0000000..6c3b699 --- /dev/null +++ b/docs/avatar-art/seoyeon/live2d-v3/layout-v3.json @@ -0,0 +1,410 @@ +{ + "version": 3, + "artSet": "seoyeon-live2d-v3", + "sheet": { + "width": 1536, + "height": 1024, + "background": "flat pure white", + "template": "layout-template-v3.png", + "generated": "sheet-v3.png" + }, + "canvas": { + "width": 900, + "height": 1125 + }, + "extraction": { + "sourceInsetByAlpha": { + "matte": 4, + "dark": 3, + "shape": 0 + }, + "frameEraseByAlpha": { + "matte": 3, + "dark": 2, + "shape": 0 + }, + "suppressEdgeArtifactsByAlpha": { + "matte": true, + "dark": false, + "shape": false + }, + "edgeArtifactWidth": 3, + "edgeArtifactLuma": 150, + "edgeArtifactChroma": 30, + "aiBackend": "ben2", + "aiRefine": true, + "defringe": true + }, + "character": { + "name": "Seoyeon", + "description": "young Korean woman, soft semi-realistic anime style, short warm-brown bob haircut with straight bangs, gray crewneck shirt, calm neutral expression" + }, + "rules": [ + "The assembled reference bust is only for visual consistency and is not extracted.", + "Every separated part must be centered inside its assigned slot.", + "Separated parts must use the same scale, skin tone, hair color, and line style as the assembled reference.", + "Do not draw labels, arrows, text, shadows, gradients, texture, or stray marks inside part slots.", + "Eye whites, irises, pupils, highlights, lashes, and eyelids must be separate pieces, not a combined eye image.", + "The faceless head must contain ears, jaw, neck top, and clean skin only: no brows, no eyes, no nose, no mouth.", + "Hair parts must include full painted pixels where hidden by other layers, so they can be moved slightly without holes." + ], + "slots": [ + { + "id": "reference.assembled", + "role": "reference-only", + "promptName": "assembled front-facing bust reference", + "sheetBox": [24, 80, 470, 920], + "extract": false + }, + { + "id": "body.torso", + "file": "torso.png", + "role": "body", + "promptName": "torso and shoulders, gray crewneck shirt, no head", + "sheetBox": [470, 700, 812, 930], + "targetBox": [150, 520, 750, 875], + "alpha": "matte", + "aiCutout": false, + "minArea": 1200, + "keepLargest": true + }, + { + "id": "head.faceless", + "file": "head-faceless.png", + "role": "base", + "promptName": "faceless head with ears and neck, clean matching skin, no facial features", + "sheetBox": [510, 40, 760, 455], + "targetBox": [285, 100, 615, 600], + "alpha": "matte", + "aiCutout": true, + "minArea": 1200, + "keepLargest": true + }, + { + "id": "neck.fill", + "file": "neck-fill.png", + "role": "base", + "promptName": "derived neck and upper chest skin fill from faceless head", + "sheetBox": [570, 285, 700, 455], + "targetBox": [365, 430, 535, 625], + "alpha": "matte", + "maskShape": "neck-gap", + "minArea": 500, + "keepLargest": true, + "derived": true + }, + { + "id": "hair.back", + "file": "hair-back.png", + "role": "hair", + "promptName": "back hair full bob silhouette, behind head", + "sheetBox": [1190, 620, 1448, 922], + "targetBox": [225, 85, 675, 575], + "alpha": "matte", + "aiCutout": true, + "minArea": 1200, + "keepLargest": true, + "pivot": [450, 180] + }, + { + "id": "hair.front", + "file": "hair-front.png", + "role": "hair", + "promptName": "full front hair and straight bangs", + "sheetBox": [1145, 22, 1502, 255], + "targetBox": [215, 70, 685, 335], + "alpha": "matte", + "aiCutout": true, + "minArea": 1200, + "keepLargest": true, + "pivot": [450, 145] + }, + { + "id": "hair.left", + "file": "hair-left.png", + "role": "hair", + "promptName": "left side hair strand cluster", + "sheetBox": [1142, 285, 1303, 596], + "targetBox": [165, 205, 380, 580], + "alpha": "matte", + "aiCutout": true, + "minArea": 600, + "keepLargest": true, + "pivot": [300, 280] + }, + { + "id": "hair.right", + "file": "hair-right.png", + "role": "hair", + "promptName": "right side hair strand cluster", + "sheetBox": [1378, 285, 1518, 596], + "targetBox": [520, 205, 735, 580], + "alpha": "matte", + "aiCutout": true, + "minArea": 600, + "keepLargest": true, + "pivot": [600, 280] + }, + { + "id": "brow.left", + "file": "brow-left.png", + "role": "brow", + "promptName": "left eyebrow only", + "sheetBox": [835, 28, 950, 62], + "targetBox": [318, 292, 412, 318], + "alpha": "dark", + "minArea": 10, + "pivot": [365, 305] + }, + { + "id": "brow.right", + "file": "brow-right.png", + "role": "brow", + "promptName": "right eyebrow only", + "sheetBox": [995, 28, 1110, 62], + "targetBox": [488, 292, 582, 318], + "alpha": "dark", + "minArea": 10, + "pivot": [535, 305] + }, + { + "id": "eye.left.white", + "file": "eye-white-left.png", + "role": "eye-mask", + "promptName": "left eye white sclera only, almond shape", + "sheetBox": [848, 100, 950, 154], + "targetBox": [316, 319, 410, 363], + "alpha": "shape", + "shape": "eye" + }, + { + "id": "eye.right.white", + "file": "eye-white-right.png", + "role": "eye-mask", + "promptName": "right eye white sclera only, almond shape", + "sheetBox": [1000, 100, 1102, 154], + "targetBox": [490, 319, 584, 363], + "alpha": "shape", + "shape": "eye" + }, + { + "id": "eye.left.iris", + "file": "iris-left.png", + "role": "eye-iris", + "promptName": "left brown iris only, no sclera", + "sheetBox": [865, 168, 925, 228], + "targetBox": [342, 322, 384, 364], + "alpha": "matte", + "keepLargest": true, + "cleanIrisDetail": true, + "pivot": [363, 344] + }, + { + "id": "eye.right.iris", + "file": "iris-right.png", + "role": "eye-iris", + "promptName": "right brown iris only, no sclera", + "sheetBox": [1026, 168, 1086, 228], + "targetBox": [516, 322, 558, 364], + "alpha": "matte", + "keepLargest": true, + "cleanIrisDetail": true, + "pivot": [537, 344] + }, + { + "id": "eye.left.pupil", + "file": "pupil-left.png", + "role": "eye-pupil", + "promptName": "left black pupil only", + "sheetBox": [875, 252, 916, 294], + "targetBox": [352, 331, 374, 353], + "alpha": "dark", + "keepLargest": true, + "pivot": [363, 344] + }, + { + "id": "eye.right.pupil", + "file": "pupil-right.png", + "role": "eye-pupil", + "promptName": "right black pupil only", + "sheetBox": [1036, 252, 1078, 294], + "targetBox": [526, 331, 548, 353], + "alpha": "dark", + "keepLargest": true, + "pivot": [537, 344] + }, + { + "id": "eye.left.highlight", + "file": "highlight-left.png", + "role": "eye-highlight", + "promptName": "left small white eye highlight dot only", + "sheetBox": [888, 333, 906, 351], + "targetBox": [358, 326, 368, 336], + "alpha": "shape", + "shape": "circle" + }, + { + "id": "eye.right.highlight", + "file": "highlight-right.png", + "role": "eye-highlight", + "promptName": "right small white eye highlight dot only", + "sheetBox": [1048, 333, 1066, 351], + "targetBox": [532, 326, 542, 336], + "alpha": "shape", + "shape": "circle" + }, + { + "id": "eye.left.lash", + "file": "lash-left.png", + "role": "eye-line", + "promptName": "left upper eyelash and eye line only", + "sheetBox": [835, 402, 950, 450], + "targetBox": [310, 309, 415, 361], + "alpha": "dark", + "minArea": 10 + }, + { + "id": "eye.right.lash", + "file": "lash-right.png", + "role": "eye-line", + "promptName": "right upper eyelash and eye line only", + "sheetBox": [1005, 402, 1115, 450], + "targetBox": [485, 309, 590, 361], + "alpha": "dark", + "minArea": 10 + }, + { + "id": "eye.left.closed", + "file": "eyelid-left-closed.png", + "role": "blink", + "promptName": "left closed eyelid curved line only", + "sheetBox": [835, 486, 950, 516], + "targetBox": [315, 333, 410, 360], + "alpha": "dark", + "minArea": 10 + }, + { + "id": "eye.right.closed", + "file": "eyelid-right-closed.png", + "role": "blink", + "promptName": "right closed eyelid curved line only", + "sheetBox": [1005, 486, 1115, 516], + "targetBox": [490, 333, 585, 360], + "alpha": "dark", + "minArea": 10 + }, + { + "id": "nose.neutral", + "file": "nose.png", + "role": "face-detail", + "promptName": "soft small nose only", + "sheetBox": [935, 545, 1008, 590], + "targetBox": [420, 385, 480, 432], + "alpha": "soft", + "minArea": 6, + "alphaScale": 0.45 + }, + { + "id": "mouth.neutral", + "file": "mouth-neutral.png", + "role": "mouth", + "promptName": "neutral closed mouth only, centered", + "sheetBox": [900, 620, 1042, 660], + "targetBox": [388, 430, 512, 462], + "alpha": "dark", + "minArea": 6, + "pivot": [450, 446] + }, + { + "id": "mouth.sad", + "file": "mouth-sad.png", + "role": "mouth", + "promptName": "sad closed mouth only, centered", + "sheetBox": [905, 695, 1038, 735], + "targetBox": [388, 430, 512, 462], + "alpha": "dark", + "minArea": 6, + "pivot": [450, 446] + }, + { + "id": "mouth.warm", + "file": "mouth-warm.png", + "role": "mouth", + "promptName": "small warm smile mouth only, centered", + "sheetBox": [905, 760, 1045, 812], + "targetBox": [388, 430, 512, 462], + "alpha": "dark", + "minArea": 6, + "pivot": [450, 446] + }, + { + "id": "mouth.open", + "file": "mouth-open.png", + "role": "mouth", + "promptName": "open speaking mouth only, centered", + "sheetBox": [920, 835, 1030, 912], + "targetBox": [394, 418, 506, 474], + "alpha": "matte", + "keepLargest": true, + "pivot": [450, 446] + } + ], + "layerOrder": [ + "hair.back", + "body.torso", + "head.faceless", + "hair.left", + "hair.right", + "hair.front", + "eye.left.white", + "eye.right.white", + "eye.left.iris", + "eye.right.iris", + "eye.left.pupil", + "eye.right.pupil", + "eye.left.highlight", + "eye.right.highlight", + "eye.left.lash", + "eye.right.lash", + "brow.left", + "brow.right", + "nose.neutral", + "mouth.neutral" + ], + "blinkLayerOrder": [ + "hair.back", + "body.torso", + "head.faceless", + "hair.left", + "hair.right", + "hair.front", + "eye.left.closed", + "eye.right.closed", + "brow.left", + "brow.right", + "nose.neutral", + "mouth.neutral" + ], + "speakingLayerOrder": [ + "hair.back", + "body.torso", + "head.faceless", + "hair.left", + "hair.right", + "hair.front", + "eye.left.white", + "eye.right.white", + "eye.left.iris", + "eye.right.iris", + "eye.left.pupil", + "eye.right.pupil", + "eye.left.highlight", + "eye.right.highlight", + "eye.left.lash", + "eye.right.lash", + "brow.left", + "brow.right", + "nose.neutral", + "mouth.open" + ] +} diff --git a/docs/avatar-art/seoyeon/live2d-v3/live2d_harness.py b/docs/avatar-art/seoyeon/live2d-v3/live2d_harness.py new file mode 100644 index 0000000..d645e46 --- /dev/null +++ b/docs/avatar-art/seoyeon/live2d-v3/live2d_harness.py @@ -0,0 +1,566 @@ +#!/usr/bin/env python3 +"""Manifest-driven Live2D-style raster parts harness. + +The layout JSON is the single source of truth. This script can: + +* render a slot template for the image-generation prompt, +* write a prompt that names every required part and slot, +* extract full-canvas transparent PNG layers from a generated sheet, +* render offline recomposition previews without touching app public assets. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import shutil +import subprocess +from pathlib import Path +from typing import Any, Iterable + +import numpy as np +from PIL import Image, ImageDraw, ImageFilter, ImageFont +from scipy import ndimage as ndi + +HERE = Path(__file__).resolve().parent +LAYOUT = HERE / "layout-v3.json" +OUT = HERE / "parts" +AI_WORK = HERE / "ai-cutout-work" +OBJECT_SEPARATE_PY = Path("C:/Users/encep/.agents/skills/object-separation/scripts/separate_object.py") +OBJECT_SEPARATE_VENV_PY = Path("C:/Users/encep/.venvs/object-separation/Scripts/python.exe") + +Box = tuple[int, int, int, int] +Inset = tuple[int, int, int, int] + + +def load_layout() -> dict[str, Any]: + return json.loads(LAYOUT.read_text(encoding="utf-8")) + + +def as_box(value: list[int] | tuple[int, int, int, int]) -> Box: + return int(value[0]), int(value[1]), int(value[2]), int(value[3]) + + +def as_inset(value: int | list[int] | tuple[int, int, int, int] | None) -> Inset: + if value is None: + return 0, 0, 0, 0 + if isinstance(value, int): + return value, value, value, value + return int(value[0]), int(value[1]), int(value[2]), int(value[3]) + + +def inset_box(box: Box, inset: Inset) -> Box: + x1, y1, x2, y2 = box + left, top, right, bottom = inset + nx1, ny1 = x1 + left, y1 + top + nx2, ny2 = x2 - right, y2 - bottom + if nx2 <= nx1 + 2 or ny2 <= ny1 + 2: + return box + return nx1, ny1, nx2, ny2 + + +def slot_by_id(layout: dict[str, Any]) -> dict[str, dict[str, Any]]: + return {slot["id"]: slot for slot in layout["slots"]} + + +def alpha_bbox(im: Image.Image, threshold: int = 8) -> Box | None: + alpha = np.array(im.getchannel("A")) + ys, xs = np.where(alpha > threshold) + if len(xs) == 0: + return None + return int(xs.min()), int(ys.min()), int(xs.max() + 1), int(ys.max() + 1) + + +def remove_white_bg(im: Image.Image, *, lo: float = 10.0, hi: float = 58.0) -> Image.Image: + arr = np.array(im.convert("RGBA")).astype(np.float32) + rgb = arr[:, :, :3] + border = np.concatenate( + [ + rgb[:6, :, :].reshape(-1, 3), + rgb[-6:, :, :].reshape(-1, 3), + rgb[:, :6, :].reshape(-1, 3), + rgb[:, -6:, :].reshape(-1, 3), + ], + axis=0, + ) + bg = np.median(border, axis=0) + dist_bg = np.sqrt(((rgb - bg.reshape(1, 1, 3)) ** 2).sum(axis=2)) + dist_white = np.sqrt(((255.0 - rgb) ** 2).sum(axis=2)) + alpha = np.minimum( + np.clip((dist_bg - lo) * 255.0 / max(1.0, hi - lo), 0, 255), + np.clip((dist_white - 12.0) * 255.0 / 48.0, 0, 255), + ) + + luma = rgb[:, :, 0] * 0.2126 + rgb[:, :, 1] * 0.7152 + rgb[:, :, 2] * 0.0722 + chroma = rgb.max(axis=2) - rgb.min(axis=2) + neutral_artifact = (luma > 175) & (chroma < 20) & (dist_white < 110) + alpha = np.where(neutral_artifact, 0, alpha) + keep = ((luma < 225) | (chroma > 14)) & ~neutral_artifact + alpha = np.where(keep & (alpha > 28), np.maximum(alpha, 235), alpha) + + arr[:, :, 3] = np.minimum(alpha, arr[:, :, 3]) + arr[arr[:, :, 3] < 1, :3] = 0 + return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGBA") + + +def soft_detail_alpha(im: Image.Image, *, lo: float = 2.0, hi: float = 36.0) -> Image.Image: + arr = np.array(im.convert("RGBA")).astype(np.float32) + rgb = arr[:, :, :3] + border = np.concatenate( + [ + rgb[:4, :, :].reshape(-1, 3), + rgb[-4:, :, :].reshape(-1, 3), + rgb[:, :4, :].reshape(-1, 3), + rgb[:, -4:, :].reshape(-1, 3), + ], + axis=0, + ) + bg = np.median(border, axis=0) + dist_bg = np.sqrt(((rgb - bg.reshape(1, 1, 3)) ** 2).sum(axis=2)) + alpha = np.clip((dist_bg - lo) * 255.0 / max(1.0, hi - lo), 0, 255) + arr[:, :, 3] = np.minimum(alpha, arr[:, :, 3]) + arr[arr[:, :, 3] < 1, :3] = 0 + return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGBA") + + +def ai_cutout(im: Image.Image, slot: dict[str, Any], defaults: dict[str, Any]) -> Image.Image: + AI_WORK.mkdir(parents=True, exist_ok=True) + safe_id = slot["id"].replace(".", "-") + src = AI_WORK / f"{safe_id}-input.png" + out = AI_WORK / f"{safe_id}-cutout.png" + meta = AI_WORK / f"{safe_id}.sha256" + im.convert("RGB").save(src, optimize=True) + digest = hashlib.sha256(src.read_bytes()).hexdigest() + if out.exists() and meta.exists() and meta.read_text(encoding="utf-8") == digest: + return Image.open(out).convert("RGBA") + backend = slot.get("aiBackend", defaults.get("aiBackend", "ben2")) + object_separate = shutil.which("object-separate") + if object_separate: + cmd = [object_separate, str(src), str(out), "--backend", str(backend)] + else: + cmd = [str(OBJECT_SEPARATE_VENV_PY), str(OBJECT_SEPARATE_PY), str(src), str(out), "--backend", str(backend)] + if slot.get("aiRefine", defaults.get("aiRefine", True)): + cmd.append("--refine") + result = subprocess.run(cmd, cwd=HERE, text=True, capture_output=True) + if result.returncode != 0: + print(f"ai-cutout failed for {slot['id']}; falling back to matte") + if result.stderr: + print(result.stderr.strip()) + return remove_white_bg(im) + meta.write_text(digest, encoding="utf-8") + return Image.open(out).convert("RGBA") + + +def neutralize_crop_frame(im: Image.Image, px: int) -> Image.Image: + """Erase atlas slot borders before alpha extraction. + + The generated sheet obeys the requested slot layout, so its faint rectangle + guides are useful for coordinates but must never become character pixels. + """ + if px <= 0: + return im + arr = np.array(im.convert("RGBA")) + px = min(px, max(0, arr.shape[0] // 3), max(0, arr.shape[1] // 3)) + if px <= 0: + return im + arr[:px, :, :3] = 255 + arr[-px:, :, :3] = 255 + arr[:, :px, :3] = 255 + arr[:, -px:, :3] = 255 + arr[:, :, 3] = 255 + return Image.fromarray(arr, "RGBA") + + +def dark_alpha(im: Image.Image) -> Image.Image: + arr = np.array(im.convert("RGBA")).astype(np.float32) + rgb = arr[:, :, :3] + luma = rgb[:, :, 0] * 0.2126 + rgb[:, :, 1] * 0.7152 + rgb[:, :, 2] * 0.0722 + chroma = rgb.max(axis=2) - rgb.min(axis=2) + alpha = np.clip((210.0 - luma) * 255.0 / 150.0, 0, 255) + alpha = np.where((luma < 210) | (chroma > 18), alpha, 0) + arr[:, :, 3] = np.minimum(alpha, arr[:, :, 3]) + arr[arr[:, :, 3] < 1, :3] = 0 + return Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8), "RGBA") + + +def shape_mask(size: tuple[int, int], shape: str) -> Image.Image: + w, h = size + mask = Image.new("L", size, 0) + draw = ImageDraw.Draw(mask) + if shape == "circle": + draw.ellipse((1, 1, w - 2, h - 2), fill=255) + elif shape == "eye": + # Almond-like mask. This deliberately preserves white sclera pixels that + # ordinary white-background removal would erase. + pts = [ + (1, h // 2), + (w // 5, h // 5), + (w // 2, 2), + (w * 4 // 5, h // 5), + (w - 2, h // 2), + (w * 4 // 5, h * 4 // 5), + (w // 2, h - 2), + (w // 5, h * 4 // 5), + ] + draw.polygon(pts, fill=255) + mask = mask.filter(ImageFilter.GaussianBlur(0.9)) + elif shape == "neck-fill": + pts = [ + (w * 36 // 100, 1), + (w * 64 // 100, 1), + (w * 70 // 100, h * 54 // 100), + (w * 94 // 100, h * 74 // 100), + (w * 84 // 100, h - 2), + (w * 16 // 100, h - 2), + (w * 6 // 100, h * 74 // 100), + (w * 30 // 100, h * 54 // 100), + ] + draw.polygon(pts, fill=255) + mask = mask.filter(ImageFilter.GaussianBlur(1.2)) + elif shape == "neck-gap": + pts = [ + (w * 36 // 100, 1), + (w * 64 // 100, 1), + (w * 67 // 100, h - 2), + (w * 33 // 100, h - 2), + ] + draw.polygon(pts, fill=255) + mask = mask.filter(ImageFilter.GaussianBlur(1.4)) + else: + draw.rounded_rectangle((1, 1, w - 2, h - 2), radius=max(2, h // 3), fill=255) + return mask + + +def apply_shape_mask(im: Image.Image, shape: str) -> Image.Image: + part = im.convert("RGBA") + arr = np.array(part) + mask = np.array(shape_mask(part.size, shape)) + arr[:, :, 3] = np.minimum(arr[:, :, 3], mask) + arr[arr[:, :, 3] == 0, :3] = 0 + return Image.fromarray(arr, "RGBA") + + +def shape_crop(im: Image.Image, shape: str) -> Image.Image: + return apply_shape_mask(im, shape) + + +def filter_components(im: Image.Image, *, min_area: int = 0, keep_largest: bool = False) -> Image.Image: + if min_area <= 0 and not keep_largest: + return im + arr = np.array(im.convert("RGBA")) + alpha = arr[:, :, 3] > 8 + labels, count = ndi.label(alpha) + if count == 0: + return im + areas = np.bincount(labels.reshape(-1)) + areas[0] = 0 + if keep_largest: + keep = labels == int(areas.argmax()) + else: + keep_labels = np.where(areas >= min_area)[0] + keep = np.isin(labels, keep_labels) + arr[:, :, 3] = np.where(keep, arr[:, :, 3], 0) + arr[arr[:, :, 3] == 0, :3] = 0 + return Image.fromarray(arr, "RGBA") + + +def decontaminate_edges(im: Image.Image, *, opaque_threshold: int = 210) -> Image.Image: + arr = np.array(im.convert("RGBA")) + alpha = arr[:, :, 3] + opaque = alpha >= opaque_threshold + if not opaque.any(): + return im + indices = ndi.distance_transform_edt(~opaque, return_distances=False, return_indices=True) + fringe = (alpha > 0) & (alpha < opaque_threshold) + if fringe.any(): + arr[fringe, :3] = arr[indices[0][fringe], indices[1][fringe], :3] + arr[alpha == 0, :3] = 0 + return Image.fromarray(arr, "RGBA") + + +def suppress_edge_artifacts( + im: Image.Image, + *, + edge_width: int = 3, + luma_min: float = 150.0, + chroma_max: float = 30.0, +) -> Image.Image: + if edge_width <= 0: + return im + arr = np.array(im.convert("RGBA")) + alpha = arr[:, :, 3] + mask = alpha > 8 + if not mask.any(): + return im + eroded = ndi.binary_erosion(mask, iterations=edge_width, border_value=0) + edge = mask & ~eroded + rgb = arr[:, :, :3].astype(np.float32) + luma = rgb[:, :, 0] * 0.2126 + rgb[:, :, 1] * 0.7152 + rgb[:, :, 2] * 0.0722 + chroma = rgb.max(axis=2) - rgb.min(axis=2) + artifact = edge & (luma > luma_min) & (chroma < chroma_max) + if artifact.any(): + arr[artifact, 3] = 0 + arr[artifact, :3] = 0 + return Image.fromarray(arr, "RGBA") + + +def clean_iris_detail(im: Image.Image) -> Image.Image: + arr = np.array(im.convert("RGBA")) + alpha = arr[:, :, 3] + if not (alpha > 8).any(): + return im + h, w = alpha.shape + yy, xx = np.mgrid[:h, :w] + cx = (w - 1) / 2.0 + cy = (h - 1) / 2.0 + radius = np.sqrt(((xx - cx) / max(1.0, w * 0.46)) ** 2 + ((yy - cy) / max(1.0, h * 0.46)) ** 2) + rgb = arr[:, :, :3].astype(np.float32) + luma = rgb[:, :, 0] * 0.2126 + rgb[:, :, 1] * 0.7152 + rgb[:, :, 2] * 0.0722 + chroma = rgb.max(axis=2) - rgb.min(axis=2) + pupil = (radius < 0.62) & (luma < 48) + highlight = (radius < 0.85) & (luma > 235) & (chroma < 30) + pupil_or_highlight = pupil | highlight + arr[pupil_or_highlight, 3] = 0 + arr[pupil_or_highlight, :3] = 0 + return Image.fromarray(arr, "RGBA") + + +def resize_rgba(im: Image.Image, size: tuple[int, int]) -> Image.Image: + """Resize RGBA in premultiplied-alpha space to avoid bright/dark halos.""" + if im.size == size: + return im + arr = np.array(im.convert("RGBA")).astype(np.float32) + alpha = arr[:, :, 3:4] / 255.0 + premultiplied = arr.copy() + premultiplied[:, :, :3] *= alpha + resized = Image.fromarray(np.clip(premultiplied, 0, 255).astype(np.uint8), "RGBA").resize( + size, + Image.Resampling.LANCZOS, + ) + out = np.array(resized).astype(np.float32) + out_alpha = out[:, :, 3:4] / 255.0 + out[:, :, :3] = np.where(out_alpha > 0.001, out[:, :, :3] / np.maximum(out_alpha, 0.001), 0) + return Image.fromarray(np.clip(out, 0, 255).astype(np.uint8), "RGBA") + + +def place(part: Image.Image, dst_box: Box, canvas_size: tuple[int, int]) -> Image.Image: + x1, y1, x2, y2 = dst_box + resized = resize_rgba(part, (x2 - x1, y2 - y1)) + canvas = Image.new("RGBA", canvas_size, (0, 0, 0, 0)) + canvas.alpha_composite(resized, (x1, y1)) + return canvas + + +def draw_template(layout: dict[str, Any], *, clean: bool) -> Path: + sheet = layout["sheet"] + width, height = int(sheet["width"]), int(sheet["height"]) + image = Image.new("RGB", (width, height), (255, 255, 255)) + draw = ImageDraw.Draw(image) + colors = { + "reference-only": (80, 120, 180), + "base": (220, 145, 70), + "body": (90, 130, 170), + "hair": (95, 70, 45), + "brow": (90, 70, 50), + "eye-mask": (80, 150, 210), + "eye-iris": (90, 90, 150), + "eye-pupil": (30, 30, 30), + "eye-highlight": (160, 160, 160), + "eye-line": (50, 50, 50), + "blink": (120, 80, 150), + "face-detail": (210, 130, 90), + "mouth": (200, 90, 90), + } + for slot in layout["slots"]: + if slot.get("derived", False): + continue + box = as_box(slot["sheetBox"]) + role = slot["role"] + color = colors.get(role, (120, 120, 120)) + draw.rectangle(box, outline=color, width=3) + if not clean: + label = f"{slot['id']}\n{box[0]},{box[1]}-{box[2]},{box[3]}" + draw.multiline_text((box[0] + 6, box[1] + 6), label, fill=color) + + out = HERE / ("layout-template-v3-clean.png" if clean else "layout-template-v3.png") + image.save(out, optimize=True) + return out + + +def build_prompt(layout: dict[str, Any]) -> str: + lines: list[str] = [] + sheet = layout["sheet"] + character = layout["character"] + lines.extend( + [ + "Use case: stylized-concept", + "Asset type: Live2D-ready raster character parts atlas", + f"Primary request: Create a {sheet['width']}x{sheet['height']} pixel white-background parts sheet for {character['name']}.", + f"Subject: {character['description']}.", + "Style/medium: polished soft semi-realistic anime illustration, clean raster edges, consistent lighting, consistent scale.", + "Composition/framing: Place each item inside its exact assigned rectangular slot. Keep generous whitespace between slots.", + "Critical Live2D constraints:", + ] + ) + for rule in layout["rules"]: + lines.append(f"- {rule}") + lines.append("") + lines.append("Exact slot map. Put only the named item in that rectangle:") + for slot in layout["slots"]: + if slot.get("derived", False): + continue + box = as_box(slot["sheetBox"]) + name = slot["promptName"] + extract = "reference only" if not slot.get("extract", True) else f"extracts to {slot.get('file')}" + lines.append(f"- {slot['id']}: x={box[0]} y={box[1]} w={box[2]-box[0]} h={box[3]-box[1]}: {name}; {extract}.") + lines.extend( + [ + "", + "Eye construction must be layer-ready: white sclera pieces must contain no iris; iris pieces must contain no sclera; pupils must be black-only; highlights must be separate white dots; lashes must be separate line art.", + "Avoid: off-center mouth, mismatched eye scale, combined eyes, facial features on the faceless head, hair holes, side hair drawn as ponytails, labels, captions, watermark, decorative background, shadows, gradients, paper texture, creepy or distorted anatomy.", + ] + ) + return "\n".join(lines) + "\n" + + +def default_for_alpha(defaults: dict[str, Any], key: str, alpha_mode: str, fallback: Any) -> Any: + by_alpha = defaults.get(f"{key}ByAlpha", {}) + return by_alpha.get(alpha_mode, defaults.get(key, fallback)) + + +def extract_part( + sheet: Image.Image, + slot: dict[str, Any], + canvas_size: tuple[int, int], + defaults: dict[str, Any], +) -> Image.Image: + alpha_mode = slot.get("alpha", "matte") + default_inset = default_for_alpha(defaults, "sourceInset", alpha_mode, 0) + src = inset_box(as_box(slot["sheetBox"]), as_inset(slot.get("sourceInset", default_inset))) + dst = as_box(slot["targetBox"]) + crop = sheet.crop(src).convert("RGBA") + frame_erase = int(slot.get("frameErase", default_for_alpha(defaults, "frameErase", alpha_mode, 0))) + crop = neutralize_crop_frame(crop, frame_erase) + if slot.get("aiCutout", False): + part = ai_cutout(crop, slot, defaults) + elif alpha_mode == "shape": + part = shape_crop(crop, slot.get("shape", "round")) + elif alpha_mode == "soft": + part = soft_detail_alpha(crop) + elif alpha_mode == "dark": + part = dark_alpha(crop) + else: + part = remove_white_bg(crop) + if "maskShape" in slot: + part = apply_shape_mask(part, slot["maskShape"]) + if slot.get("cleanIrisDetail", False): + part = clean_iris_detail(part) + part = filter_components( + part, + min_area=int(slot.get("minArea", 0)), + keep_largest=bool(slot.get("keepLargest", False)), + ) + suppress_artifacts = slot.get( + "suppressEdgeArtifacts", + default_for_alpha(defaults, "suppressEdgeArtifacts", alpha_mode, alpha_mode == "matte"), + ) + if suppress_artifacts: + part = suppress_edge_artifacts( + part, + edge_width=int(slot.get("edgeArtifactWidth", defaults.get("edgeArtifactWidth", 3))), + luma_min=float(slot.get("edgeArtifactLuma", defaults.get("edgeArtifactLuma", 150))), + chroma_max=float(slot.get("edgeArtifactChroma", defaults.get("edgeArtifactChroma", 30))), + ) + if defaults.get("defringe", True) and slot.get("defringe", True): + part = decontaminate_edges(part) + if "alphaScale" in slot: + arr = np.array(part.convert("RGBA")) + arr[:, :, 3] = np.clip(arr[:, :, 3].astype(np.float32) * float(slot["alphaScale"]), 0, 255).astype(np.uint8) + arr[arr[:, :, 3] == 0, :3] = 0 + part = Image.fromarray(arr, "RGBA") + return place(part, dst, canvas_size) + + +def extract_all(layout: dict[str, Any], sheet_path: Path) -> None: + if not sheet_path.exists(): + raise FileNotFoundError(sheet_path) + OUT.mkdir(parents=True, exist_ok=True) + sheet = Image.open(sheet_path).convert("RGBA") + canvas_size = (int(layout["canvas"]["width"]), int(layout["canvas"]["height"])) + defaults = layout.get("extraction", {}) + for slot in layout["slots"]: + if not slot.get("extract", True): + continue + im = extract_part(sheet, slot, canvas_size, defaults) + out = OUT / slot["file"] + im.save(out, optimize=True) + print(f"{slot['id']:22} -> {slot['file']:26} bbox={alpha_bbox(im)}") + + +def composite(layout: dict[str, Any], order_name: str, out_path: Path) -> Image.Image: + slots = slot_by_id(layout) + canvas_size = (int(layout["canvas"]["width"]), int(layout["canvas"]["height"])) + bg = Image.new("RGBA", canvas_size, (30, 39, 36, 255)) + for slot_id in layout[order_name]: + slot = slots[slot_id] + bg.alpha_composite(Image.open(OUT / slot["file"]).convert("RGBA")) + bg.save(out_path, optimize=True) + print(f"preview {out_path}") + return bg + + +def make_contact(layout: dict[str, Any]) -> None: + slots = [slot for slot in layout["slots"] if slot.get("extract", True)] + tile_w, tile_h = 180, 160 + cols = 5 + rows = (len(slots) + cols - 1) // cols + sheet = Image.new("RGBA", (cols * tile_w, rows * tile_h), (30, 39, 36, 255)) + draw = ImageDraw.Draw(sheet) + for i, slot in enumerate(slots): + layer = Image.open(OUT / slot["file"]).convert("RGBA") + box = alpha_bbox(layer) + thumb = Image.new("RGBA", (1, 1), (0, 0, 0, 0)) if box is None else layer.crop(box) + thumb.thumbnail((tile_w - 24, tile_h - 42), Image.Resampling.LANCZOS) + x = (i % cols) * tile_w + (tile_w - thumb.width) // 2 + y = (i // cols) * tile_h + 16 + sheet.alpha_composite(thumb, (x, y)) + draw.text(((i % cols) * tile_w + 8, (i // cols) * tile_h + tile_h - 22), slot["file"], fill=(200, 214, 208, 255)) + out = HERE / "parts-contact-v3.png" + sheet.save(out, optimize=True) + print(f"contact {out}") + + +def preview(layout: dict[str, Any]) -> None: + composite(layout, "layerOrder", HERE / "preview-neutral-v3.png") + composite(layout, "blinkLayerOrder", HERE / "preview-blink-v3.png") + composite(layout, "speakingLayerOrder", HERE / "preview-speaking-v3.png") + make_contact(layout) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("command", choices=["template", "prompt", "extract", "preview", "all"]) + parser.add_argument("--sheet", default=None, help="Generated sheet path for extract/all") + args = parser.parse_args() + + layout = load_layout() + if args.command in {"template", "all"}: + labelled = draw_template(layout, clean=False) + clean = draw_template(layout, clean=True) + print(f"template {labelled}") + print(f"template-clean {clean}") + if args.command in {"prompt", "all"}: + prompt = build_prompt(layout) + out = HERE / "prompt-live2d-v3.txt" + out.write_text(prompt, encoding="utf-8") + print(f"prompt {out}") + if args.command in {"extract", "all"}: + sheet_path = Path(args.sheet) if args.sheet else HERE / layout["sheet"]["generated"] + extract_all(layout, sheet_path) + if args.command in {"preview", "all"}: + preview(layout) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts-contact-v3.png b/docs/avatar-art/seoyeon/live2d-v3/parts-contact-v3.png new file mode 100644 index 0000000..723e863 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts-contact-v3.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/brow-left.png b/docs/avatar-art/seoyeon/live2d-v3/parts/brow-left.png new file mode 100644 index 0000000..553f30c Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/brow-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/brow-right.png b/docs/avatar-art/seoyeon/live2d-v3/parts/brow-right.png new file mode 100644 index 0000000..a6f7b80 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/brow-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/eye-white-left.png b/docs/avatar-art/seoyeon/live2d-v3/parts/eye-white-left.png new file mode 100644 index 0000000..1131b0d Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/eye-white-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/eye-white-right.png b/docs/avatar-art/seoyeon/live2d-v3/parts/eye-white-right.png new file mode 100644 index 0000000..6225bc2 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/eye-white-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/eyelid-left-closed.png b/docs/avatar-art/seoyeon/live2d-v3/parts/eyelid-left-closed.png new file mode 100644 index 0000000..f561af6 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/eyelid-left-closed.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/eyelid-right-closed.png b/docs/avatar-art/seoyeon/live2d-v3/parts/eyelid-right-closed.png new file mode 100644 index 0000000..f3a6450 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/eyelid-right-closed.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/hair-back.png b/docs/avatar-art/seoyeon/live2d-v3/parts/hair-back.png new file mode 100644 index 0000000..dedb0ea Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/hair-back.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/hair-front.png b/docs/avatar-art/seoyeon/live2d-v3/parts/hair-front.png new file mode 100644 index 0000000..ee30e52 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/hair-front.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/hair-left.png b/docs/avatar-art/seoyeon/live2d-v3/parts/hair-left.png new file mode 100644 index 0000000..1ab0cb9 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/hair-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/hair-right.png b/docs/avatar-art/seoyeon/live2d-v3/parts/hair-right.png new file mode 100644 index 0000000..20bcb21 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/hair-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/head-faceless.png b/docs/avatar-art/seoyeon/live2d-v3/parts/head-faceless.png new file mode 100644 index 0000000..f30905f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/head-faceless.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/highlight-left.png b/docs/avatar-art/seoyeon/live2d-v3/parts/highlight-left.png new file mode 100644 index 0000000..dfd7240 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/highlight-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/highlight-right.png b/docs/avatar-art/seoyeon/live2d-v3/parts/highlight-right.png new file mode 100644 index 0000000..8c57287 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/highlight-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/iris-left.png b/docs/avatar-art/seoyeon/live2d-v3/parts/iris-left.png new file mode 100644 index 0000000..14db3e7 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/iris-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/iris-right.png b/docs/avatar-art/seoyeon/live2d-v3/parts/iris-right.png new file mode 100644 index 0000000..26497eb Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/iris-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/lash-left.png b/docs/avatar-art/seoyeon/live2d-v3/parts/lash-left.png new file mode 100644 index 0000000..20e6e81 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/lash-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/lash-right.png b/docs/avatar-art/seoyeon/live2d-v3/parts/lash-right.png new file mode 100644 index 0000000..8ae8efa Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/lash-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/mouth-neutral.png b/docs/avatar-art/seoyeon/live2d-v3/parts/mouth-neutral.png new file mode 100644 index 0000000..9602979 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/mouth-neutral.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/mouth-open.png b/docs/avatar-art/seoyeon/live2d-v3/parts/mouth-open.png new file mode 100644 index 0000000..2c86a34 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/mouth-open.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/mouth-sad.png b/docs/avatar-art/seoyeon/live2d-v3/parts/mouth-sad.png new file mode 100644 index 0000000..9a6318f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/mouth-sad.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/mouth-warm.png b/docs/avatar-art/seoyeon/live2d-v3/parts/mouth-warm.png new file mode 100644 index 0000000..13b7c16 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/mouth-warm.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/neck-fill.png b/docs/avatar-art/seoyeon/live2d-v3/parts/neck-fill.png new file mode 100644 index 0000000..7b82c77 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/neck-fill.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/nose.png b/docs/avatar-art/seoyeon/live2d-v3/parts/nose.png new file mode 100644 index 0000000..a1beb63 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/nose.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/pupil-left.png b/docs/avatar-art/seoyeon/live2d-v3/parts/pupil-left.png new file mode 100644 index 0000000..8baf5eb Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/pupil-left.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/pupil-right.png b/docs/avatar-art/seoyeon/live2d-v3/parts/pupil-right.png new file mode 100644 index 0000000..74958db Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/pupil-right.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/parts/torso.png b/docs/avatar-art/seoyeon/live2d-v3/parts/torso.png new file mode 100644 index 0000000..0e3bd09 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/parts/torso.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/preview-blink-v3.png b/docs/avatar-art/seoyeon/live2d-v3/preview-blink-v3.png new file mode 100644 index 0000000..981d350 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/preview-blink-v3.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/preview-neutral-v3.png b/docs/avatar-art/seoyeon/live2d-v3/preview-neutral-v3.png new file mode 100644 index 0000000..2537d54 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/preview-neutral-v3.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/preview-speaking-v3.png b/docs/avatar-art/seoyeon/live2d-v3/preview-speaking-v3.png new file mode 100644 index 0000000..3bb9e9f Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/preview-speaking-v3.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/prompt-live2d-v3.txt b/docs/avatar-art/seoyeon/live2d-v3/prompt-live2d-v3.txt new file mode 100644 index 0000000..5768775 --- /dev/null +++ b/docs/avatar-art/seoyeon/live2d-v3/prompt-live2d-v3.txt @@ -0,0 +1,45 @@ +Use case: stylized-concept +Asset type: Live2D-ready raster character parts atlas +Primary request: Create a 1536x1024 pixel white-background parts sheet for Seoyeon. +Subject: young Korean woman, soft semi-realistic anime style, short warm-brown bob haircut with straight bangs, gray crewneck shirt, calm neutral expression. +Style/medium: polished soft semi-realistic anime illustration, clean raster edges, consistent lighting, consistent scale. +Composition/framing: Place each item inside its exact assigned rectangular slot. Keep generous whitespace between slots. +Critical Live2D constraints: +- The assembled reference bust is only for visual consistency and is not extracted. +- Every separated part must be centered inside its assigned slot. +- Separated parts must use the same scale, skin tone, hair color, and line style as the assembled reference. +- Do not draw labels, arrows, text, shadows, gradients, texture, or stray marks inside part slots. +- Eye whites, irises, pupils, highlights, lashes, and eyelids must be separate pieces, not a combined eye image. +- The faceless head must contain ears, jaw, neck top, and clean skin only: no brows, no eyes, no nose, no mouth. +- Hair parts must include full painted pixels where hidden by other layers, so they can be moved slightly without holes. + +Exact slot map. Put only the named item in that rectangle: +- reference.assembled: x=24 y=80 w=446 h=840: assembled front-facing bust reference; reference only. +- body.torso: x=470 y=700 w=342 h=230: torso and shoulders, gray crewneck shirt, no head; extracts to torso.png. +- head.faceless: x=510 y=40 w=250 h=415: faceless head with ears and neck, clean matching skin, no facial features; extracts to head-faceless.png. +- hair.back: x=1190 y=620 w=258 h=302: back hair full bob silhouette, behind head; extracts to hair-back.png. +- hair.front: x=1145 y=22 w=357 h=233: full front hair and straight bangs; extracts to hair-front.png. +- hair.left: x=1142 y=285 w=161 h=311: left side hair strand cluster; extracts to hair-left.png. +- hair.right: x=1378 y=285 w=140 h=311: right side hair strand cluster; extracts to hair-right.png. +- brow.left: x=835 y=28 w=115 h=34: left eyebrow only; extracts to brow-left.png. +- brow.right: x=995 y=28 w=115 h=34: right eyebrow only; extracts to brow-right.png. +- eye.left.white: x=848 y=100 w=102 h=54: left eye white sclera only, almond shape; extracts to eye-white-left.png. +- eye.right.white: x=1000 y=100 w=102 h=54: right eye white sclera only, almond shape; extracts to eye-white-right.png. +- eye.left.iris: x=865 y=168 w=60 h=60: left brown iris only, no sclera; extracts to iris-left.png. +- eye.right.iris: x=1026 y=168 w=60 h=60: right brown iris only, no sclera; extracts to iris-right.png. +- eye.left.pupil: x=875 y=252 w=41 h=42: left black pupil only; extracts to pupil-left.png. +- eye.right.pupil: x=1036 y=252 w=42 h=42: right black pupil only; extracts to pupil-right.png. +- eye.left.highlight: x=888 y=333 w=18 h=18: left small white eye highlight dot only; extracts to highlight-left.png. +- eye.right.highlight: x=1048 y=333 w=18 h=18: right small white eye highlight dot only; extracts to highlight-right.png. +- eye.left.lash: x=835 y=402 w=115 h=48: left upper eyelash and eye line only; extracts to lash-left.png. +- eye.right.lash: x=1005 y=402 w=110 h=48: right upper eyelash and eye line only; extracts to lash-right.png. +- eye.left.closed: x=835 y=486 w=115 h=30: left closed eyelid curved line only; extracts to eyelid-left-closed.png. +- eye.right.closed: x=1005 y=486 w=110 h=30: right closed eyelid curved line only; extracts to eyelid-right-closed.png. +- nose.neutral: x=935 y=545 w=73 h=45: soft small nose only; extracts to nose.png. +- mouth.neutral: x=900 y=620 w=142 h=40: neutral closed mouth only, centered; extracts to mouth-neutral.png. +- mouth.sad: x=905 y=695 w=133 h=40: sad closed mouth only, centered; extracts to mouth-sad.png. +- mouth.warm: x=905 y=760 w=140 h=52: small warm smile mouth only, centered; extracts to mouth-warm.png. +- mouth.open: x=920 y=835 w=110 h=77: open speaking mouth only, centered; extracts to mouth-open.png. + +Eye construction must be layer-ready: white sclera pieces must contain no iris; iris pieces must contain no sclera; pupils must be black-only; highlights must be separate white dots; lashes must be separate line art. +Avoid: off-center mouth, mismatched eye scale, combined eyes, facial features on the faceless head, hair holes, side hair drawn as ponytails, labels, captions, watermark, decorative background, shadows, gradients, paper texture, creepy or distorted anatomy. diff --git a/docs/avatar-art/seoyeon/live2d-v3/sheet-v3-grid.png b/docs/avatar-art/seoyeon/live2d-v3/sheet-v3-grid.png new file mode 100644 index 0000000..53b6969 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/sheet-v3-grid.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/sheet-v3-noborder-candidate.png b/docs/avatar-art/seoyeon/live2d-v3/sheet-v3-noborder-candidate.png new file mode 100644 index 0000000..55014c2 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/sheet-v3-noborder-candidate.png differ diff --git a/docs/avatar-art/seoyeon/live2d-v3/sheet-v3.png b/docs/avatar-art/seoyeon/live2d-v3/sheet-v3.png new file mode 100644 index 0000000..83c09f5 Binary files /dev/null and b/docs/avatar-art/seoyeon/live2d-v3/sheet-v3.png differ diff --git a/docs/avatar-art/seoyeon/render-grid.png b/docs/avatar-art/seoyeon/render-grid.png index c9d7b25..813f0fc 100644 Binary files a/docs/avatar-art/seoyeon/render-grid.png and b/docs/avatar-art/seoyeon/render-grid.png differ diff --git a/docs/avatar-art/seoyeon/render-hero-0.png b/docs/avatar-art/seoyeon/render-hero-0.png index 864eab5..25b9551 100644 Binary files a/docs/avatar-art/seoyeon/render-hero-0.png and b/docs/avatar-art/seoyeon/render-hero-0.png differ diff --git a/docs/avatar-art/seoyeon/render-hero-1.png b/docs/avatar-art/seoyeon/render-hero-1.png index 5d8861f..452dd39 100644 Binary files a/docs/avatar-art/seoyeon/render-hero-1.png and b/docs/avatar-art/seoyeon/render-hero-1.png differ diff --git a/docs/avatar-art/seoyeon/render-hero-2.png b/docs/avatar-art/seoyeon/render-hero-2.png index 48b23a1..022523a 100644 Binary files a/docs/avatar-art/seoyeon/render-hero-2.png and b/docs/avatar-art/seoyeon/render-hero-2.png differ diff --git a/docs/avatar-art/seoyeon/render-hero-3.png b/docs/avatar-art/seoyeon/render-hero-3.png index 9f4de56..26dbb01 100644 Binary files a/docs/avatar-art/seoyeon/render-hero-3.png and b/docs/avatar-art/seoyeon/render-hero-3.png differ diff --git a/docs/avatar-art/seoyeon/render-hero.png b/docs/avatar-art/seoyeon/render-hero.png index ddbbaa4..25d8cc8 100644 Binary files a/docs/avatar-art/seoyeon/render-hero.png and b/docs/avatar-art/seoyeon/render-hero.png differ diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index 732399c..461bd1d 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -304,6 +304,7 @@
결정아키텍처·일정

백엔드 언어 (Python 유지 vs Node/Spring)

PDF는 Node.js 우선(팀 보유기술·5개월 일정)/Spring 대안. 현재 Python/FastAPI — 재작성은 일정·인력 리스크.

영향: 팀 역량·유지보수 vs 재작성 비용. 람다 단독 결정 금지.

결정Phase 3 측정

평가설계 (H1: 실험/통제군·3척도·κ/ICC)

doc4가 20명 실험/통제군·단회기 50분·3척도 pre-post를 명시. κ/ICC·환각률 목표는 doc4 미명시 — 확정 필요.

영향: 평가 타당도, Phase 3 측정력. 평가설계는 소유자 결정.

결정음성 아키텍처

s2s 2차 PoC 채택 여부

캐스케이드 1차는 확정. voice-s2s-poc.md 기준표로 비교 후 유지/폐기.

캐스케이드만 / s2s 2차 PoC 유지

+
결정페르소나 음성

서비스용 음성 모델·권리 정책

Higgs급 무참조 synthetic P1 샘플은 생성 완료, dev-only sample TTS provider 연결 완료. 실존 인물/성우 ref 사용 금지 확정. 서비스 배포 전 상업 라이선스·동의 ref·대체 provider 중 결정 필요.

Higgs 라이선스 / 동의 ref / 대체 TTS

결정데이터주권·레이턴시

fast-loop 외부 API 허용 여부

로컬 상주 분류기 기본 + 외부 Haiku/Solar 폴백 허용 여부.

로컬 전용 / 외부 폴백 허용

결정동의서·데이터셋

재귀학습 fine-tuning 범위

1차 few-shot 자동갱신만 vs fine-tuning 포함.

few-shot only / fine-tuning 포함

결정안전 정책

자유연습 기본값

수정안: 기본 ON + 피드백 권장 배지 + 교수자 토글.

기본 ON / 교수자 승인 후 ON

@@ -484,8 +485,8 @@
현재

doc4가 20명 실험/통제군·단회기 50분·3척도 pre-post를 못박았으나 코드 0건. Phase3 KPI도 report shape만, 계산 코드 0줄. (κ/ICC·환각률은 doc4 미명시 — 평가설계 문서 확정 필요.)

권고

3척도 pre-post 폼·실험/통제군 배정·자동누적 대시보드·추이 시각화·검정 계산 코드. 평가설계는 소유자 결정.

- -
현재

텍스트 /turn 경로에 eval_hook=make_eval_hook(engine_client) 주입, fast-loop 턴 평가를 학습자 TurnRecord(evaluation 신규 필드)에 영속화, 리뷰가 per-turn 기법칩(category→empathy/explore/reflect/confront/closed)·적절성/의도이탈 노트를 노출하도록 배선. 라이브 검증: "많이 힘드셨겠어요…" → techniques [공감/empathy, 촉진질문/explore] + note '적절한 개입'. 백엔드 84/84 pass.

잔여

음성 경로(voice.py:307) eval_hook 미주입 · few-shot 골든셋 기본 OFF · 회기리뷰 2열 UI(축어록 타임라인+비언어) 프론트 강화 · 원천 축어록 골든셋 적재(임상팀).

+ +
현재

텍스트 /turn·음성 경로에 eval_hook=make_eval_hook(engine_client) 주입. fast-loop 턴 평가는 TurnRecord.evaluation에만 남기지 않고 app.feedback_scores, app.turn_technique, app.turn_client_state, app.supervisor_comment로 정규화 적재 후 리뷰 조회에서 hydrate한다. 원시 평가 테이블은 evaluator/admin RLS로 제한하고 서버가 evaluator 컨텍스트로 적재한다. app.turns insert도 RETURNING id 확인으로 0-row silent success를 차단. 검증: app.test_evaluation_persistence + app.test_session_turn_persistence 14 tests OK.

잔여

stream 경로 fast-loop 평가 미주입 · deep-loop alternative_utterance 정규화 미구현 · safety_events 공통 finalize_turn 미통합 · few-shot 골든셋 기본 OFF · 회기리뷰 2열 UI(축어록 타임라인+비언어) 프론트 강화 · 원천 축어록 골든셋 적재(임상팀).

@@ -529,7 +530,7 @@
-
현재

routes/sessions.py(submit/stream)·routes/voice.py가 세션로드→오너십→prepare_turn→run_turn_generate→TurnRecord append→state update를 복제. 실드리프트 존재: stage 표기 채널별 상이(한글 vs enum), 음성 경로 eval_hook 미주입·빈 RecallContext. 공통 record_completed_turn/SessionRepository로 추출.

안전

순수 리팩터 단독 PR + 골든 테스트(test_session_turn_persistence·test_voice_ws) 동등성 확인.

+
현재

routes/sessions.py(submit/stream)·routes/voice.py가 세션로드→오너십→prepare_turn→run_turn_generate→TurnRecord append→state update를 복제. 일부 정리: sync/voice eval_hook은 주입됐고 fast-loop 평가는 DB 정규화 적재를 시작. 잔여 실드리프트: stream 평가 누락, voice 빈 RecallContext, safety_events 저장 경로 분산. 공통 finalize_turn/SessionRepository로 추출.

안전

순수 리팩터 단독 PR + 골든 테스트(test_session_turn_persistence·test_voice_ws) 동등성 확인.

@@ -643,6 +644,7 @@
CHECK
voice gateway WebSocket

/voice/ws backend contract를 fake WebSocket으로 회귀화했고, Session UI의 MediaRecorder 기반 브라우저 경로도 synthetic audio로 한 턴 통과시켰다. 실제 AudioWorklet 전환, public WSS, 50분 양방향 실측은 남아 있다.

산출물

app/test_voice_ws.py, apps/web/e2e/voice-success.spec.ts, audio_start/binary/audio_end/text_turn/ping contract

검증

5 backend tests OK; voice-success chromium-single-run 2 passed; auth guard, chunk concat, max audio cap, TTS chunk envelope

CHECK
Deepgram STT + EOT

provider-neutral EOT helper로 final transcript와 silence threshold readiness를 회귀화했다. 실제 Deepgram interim/final 스트림 연결은 남아 있다.

산출물

assess_end_of_turn, EndOfTurnDecision, EOT silence threshold

검증

final pending, empty transcript, silence threshold 케이스 unit test

CHECK
OpenAI TTS voice preset 추상화

P1/P2/P3 persona voice preset, invalid preset fallback, speed clamp, instructions payload 계약을 회귀화했다. DB voice_persona_map과 live TTS 호출은 남아 있다.

산출물

resolve_voice, build_tts_payload, provider-neutral params test

검증

P1/P2/P3 voice 분리, fallback, fake-client streaming payload OK

+
POC
P1 서연 무참조 synthetic voice 샘플

Higgs v3 로컬 런타임에서 실존 reference voice 없이 synthetic seed를 먼저 만들고, 그 seed만 재참조해 우울·피로·불안·라포·회복감 5개 변주를 생성했다. 개발용 VIGNETTE_VOICE_POC_SAMPLE_TTS 플래그로 /voice/ws P1 sample TTS provider에도 연결했다.

산출물

docs/voice-art/bimec-persona-voice-poc-2026-06-27.md, docs/voice-art/p1-seoyeon-higgs-v3-20260627/manifest.json, apps/api/app/services/voice.py

검증

mp3/wav 6개(seed+5) 생성, 24kHz, 7.72~11.68초. live /voice/ws text_turn smoke: provider p1-sample-poc, binary 20 chunks/78,573 bytes. 샘플 provider는 TTS override만 담당하고 마이크/STT는 OpenAI 키가 필요하다. 프로덕션 탑재는 모델 라이선스·권리정책 결정 전 금지.

CHECK
barge-in / paralinguistic logging

음성 learner turn에 audio_ref, silence_ms, speech_rate, barge_in을 nullable 메타로 저장한다. 실제 장시간 WSS/마이크 증거는 남아 있다.

산출물

turns nullable 확장, voice route 메타 산출

검증

오디오 STT→엔진→TTS 더블 테스트에서 voice turn log에 메타 저장

CHECK
s2s 2차 PoC 판단

캐스케이드 1차 원칙과 s2s 유지/폐기 증거 기준을 문서화했다. 채택 여부 결정은 윤찬 DECIDE로 유지한다.

산출물

docs/decisions/voice-s2s-poc.md

검증

transcript, safety, audit, latency, failure, privacy 기준 명시

@@ -662,6 +664,8 @@
DONE
운영 웹 asset 확인

커스텀 도메인이 최신 JS/CSS asset을 서빙한다.

산출물

index-D1Z5DH5J.js, index-BA9-xxfn.css

검증

Invoke-WebRequest https://vignette.chanpaca.net/

DONE
공개 API prod health

터널은 prod API 8001을 보고, API는 DB와 engine을 모두 확인한다.

산출물

environment:"prod", db:true, engine:true

검증

https://api-vignette.chanpaca.net/health

+
DONE
Tailscale Tailnet 런타임

로컬 web/api/engine을 Tailscale Serve로 묶어 같은 Tailnet PC·모바일에서 HTTPS로 접근한다. Vite proxy와 API 포트가 어긋나지 않도록 dev-up.ps1에서 VITE_API_PROXY_TARGETApiPort와 동기화한다.

산출물

scripts/start-tailscale-runtime.ps1, AUTH_DEV_LOGIN_EXTRA_ORIGINS, VITE_ALLOWED_HOSTS

검증

https://alpaca-home.taile93291.ts.net/login 200, /api/auth/config dev_login true, dev-login 후 /api/auth/me 200, 로그인 DOM에 로컬 테스트 계정으로 계속 표시.

+
BLOCKED
공개 vnet.18ka.net 런타임

vnet.18ka.netapi-vnet.18ka.net을 정식 공개 도메인으로 허용하도록 코드 경로는 보강했다.

산출물

FRONTEND_ORIGIN_MAP, 공개 host별 API 매핑, scripts/start-public-runtime.ps1 vnet ingress 옵션

블로커

현재 DNS 미해결, Cloudflare credential의 18ka.net zone 권한 불일치, prod DB 127.0.0.1:55432 down, Google OAuth redirect URI 등록 필요.

DONE
서버 정리 상태

운영 확인용 프로세스만 유지하고 임시 dev server는 내린다.

산출물

8001 API, 9099 engine, 20241 cloudflared

검증

Get-NetTCPConnection 대상 포트 확인

CHECK
재부팅 후 런타임 재기동

engine gateway, prod API, cloudflared를 health-check하고 복구하는 watchdog과 Scheduled Task installer를 추가했다. 실제 재부팅 후 public /turn smoke는 남아 있다.

산출물

watch-public-runtime.ps1, install-public-runtime-task.ps1, ops runbook

검증

PowerShell parser OK, check-only watchdog 실행

DONE
레이아웃 감사팀 / 전면 레이아웃 개선

서브에이전트 전담으로 1차 overflow/clipping 수리 후 2차 전면 재설계를 진행했고, 이번 라운드에서 7개 권장 너비 시각 수용까지 닫았다. 세션, 학습자 홈/리뷰, 교수 콘솔, 관리자, 설정 화면을 작업 화면 기준으로 재배치했고, 과한 빈칸·튀어나감·버튼/텍스트 잘림·모바일 테이블 사용성을 줄였다. 엄격한 시각 게이트 + 화면별 적대적 시각 재검수에서 7개 화면 모두 accept(critical/major 0, 회귀 0)를 받아 DONE으로 닫는다.

산출물

docs/ops/layout-redesign-handoff-2026-06-26.md, page-by-page redesign patches, apps/web/e2e/layout-visual-gate.spec.ts

검증

npm run typecheck OK, 엄격 시각 게이트 7 passed, layout focused E2E desktop/mobile 54 passed, 적대적 시각 재검수 7/7 accept·회귀 0.

@@ -674,17 +678,18 @@ 범위명령 / 확인 대상결과 Web typechecknpm run typecheckPassed - Backend unittestpython -m unittest app.test_runtime_policy app.test_session_turn_persistence app.test_state_machine_resistance app.test_orchestrator_masking app.test_rbac_idor app.test_voice_service app.test_auth_providers app.test_voice_ws app.test_persona_review engine_gateway.test_gateway_model84 tests OK + Backend unittestpython -m unittest app.test_runtime_policy app.test_session_turn_persistence app.test_evaluation_persistence app.test_state_machine_resistance app.test_orchestrator_masking app.test_rbac_idor app.test_voice_service app.test_auth_providers app.test_voice_ws app.test_persona_review engine_gateway.test_gateway_model98 tests OK P1 cost telemetryapp.test_session_turn_persistence.SessionTurnPersistenceTestPassed; generate/stream client AI turns store provider/model/tokens/cost Engine stream contractgateway token/done/error parsingPassed; gateway error event does not create saved turns Engine session reuseengine_gateway.test_gateway_model7 tests OK; live session_id reuse and ephemeral close fixed P1 masking gateapp.test_orchestrator_masking4 tests OK; phone/email/RRN absent from generate/stream payloads P2a RBAC/audit/visibilityapp.test_rbac_idor7 tests OK; other learner 403, read_session audit, evaluator-only hidden + Turn evaluation SSOPpython -B -m unittest app.test_session_turn_persistence app.test_evaluation_persistence14 tests OK; fast-loop evaluation normalized mapping + evaluator-only raw RLS + turn insert id 확인 Postgres RLS/audit smoke checkerpython scripts\check-postgres-rls-audit.py --helppy_compile/help/safe default OK; live DSN run still required for proof OAuth/BFF cookie securityapp.test_auth_providers12 tests OK; PKCE/state, opaque HttpOnly/Secure cookie, no browser tokens, SAML AuthnRequest/dev-only fixture ACS Persona review workflowapp.test_persona_review14 tests OK; approved-only catalog, teacher/admin queue, approve/reject audit, learner 403 Voice WS contractapp.test_voice_ws5 tests OK; auth guard, audio_start/chunks/audio_end, text_turn, ping, max audio cap - Voice preset/EOTapp.test_voice_service9 tests OK; TTS payload, preset fallback, EOT readiness + Voice preset/EOTapp.test_voice_service11 tests OK; TTS payload, preset fallback, P1 sample TTS, EOT readiness Voice metadataapp.test_session_turn_persistenceaudio_ref/silence_ms/speech_rate/barge_in persisted on learner voice turn P1 MVP UI E2Enpx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run1 passed Persona review UI E2Enpx playwright test e2e/teacher.spec.ts --project=chromium-single-run1 passed; teacher approves pending persona from console @@ -740,12 +745,13 @@ $env:E2E_PUBLIC_STORAGE_STATE=".\node_modules\.tmp\public-auth.json" powershell -NoProfile -ExecutionPolicy Bypass -File scripts\start-public-runtime.ps1
닫은 항목(live 증거): ① engine config 운영값(claude_cli / 9099 / gateway-default, durable, db). ② 상주 엔진풀 probe — 단일 session_id 2회 reused stream, TTFT 1667–4199ms, cost 누적 0.068→0.123. ③ Postgres RLS/audit smoke 5 checks PASS. ④ turn cost telemetry — app.turns client_ai 13행에 provider/model/cost_usd 실적재(합 $1.22). ⑤ 레이아웃 시각 게이트 7/7 + 적대적 재검수 7/7 accept.
-
환경 제약으로 이 세션에서 못 닫는 항목(정직 표기): 공개 Google OAuth 실제 /turn(owner 지시로 보류) · 한신대 데이터/SSO 거버넌스(owner 지시로 보류) · 음성 live STT/TTS provider·물리 마이크·공개 WSS 50분 · 저항엔진 openness 곡선 DB 실증 · claude_cli↔Messages API 폴백 동일성 · 재부팅 후 watchdog smoke · Phase 3 파일럿 게이트 · decisions 5건(owner 결정). 운영 원칙상 가짜 증거로 DONE 표기하지 않는다. 전체 추적: docs/ops/backlog-2026-06-26.md(B1 폴리시 · B2 환경제약 · B3 소유자결정 · B4 거버넌스).
+
환경 제약으로 이 세션에서 못 닫는 항목(정직 표기): 공개 Google OAuth 실제 /turn(owner 지시로 보류) · vnet.18ka.net/api-vnet.18ka.net 공개 런타임(DNS 미해결, Cloudflare 18ka.net zone 권한 불일치, prod DB 55432 down, Google OAuth redirect URI 등록 필요) · 한신대 데이터/SSO 거버넌스(owner 지시로 보류) · 음성 live STT/TTS provider·물리 마이크·공개 WSS 50분 · 저항엔진 openness 곡선 DB 실증 · claude_cli↔Messages API 폴백 동일성 · 재부팅 후 watchdog smoke · Phase 3 파일럿 게이트 · decisions 6건(owner 결정). 운영 원칙상 가짜 증거로 DONE 표기하지 않는다. 전체 추적: docs/ops/backlog-2026-06-26.md(B1 폴리시 · B2 환경제약 · B3 소유자결정 · B4 거버넌스).