"""공통 리노컷 파이프라인의 페르소나별 설정 로더. 페르소나 폴더(예: docs/avatar-art/p1-linocut/)의 persona.json을 읽어 PersonaConfig로 만든다. 파이프라인 스크립트들은 이 모듈을 통해서만 페르소나별 차이(게시 경로·rig 내보내기 이름·팔레트 표본 상자·점 위치·피벗 등)를 얻는다. persona.json에 없는 선택 필드는 모두 기본값(P1에서 쓰던 값과 같은 보수적 기본값)으로 채운다 — 랜드마크로 유도할 수 없는 "원화 고유" 상수(점 위치, 팔레트 표본 상자, 피벗 등)만 이 설정에 둔다. 랜드마크·분할로 계산 가능한 것은 각 단계 스크립트가 그대로 계산한다(이 모듈이 대신하지 않는다). """ from __future__ import annotations import json from dataclasses import dataclass from pathlib import Path # 모든 페르소나가 같은 종이·잉크 계열(리노컷 판화)이라 공유하는 기본값. # persona.json이 생략하면 이 값을 쓴다. 종이색(#EEE5D3)은 내부 합성·마스크 계산에 # 쓰는 고정 상수라 각 단계 스크립트에 그대로 둔다(모든 페르소나가 같은 종이이고, # persona.json에서 조정할 축이 아니다 — 캐릭터별로 달라지는 "배경색"은 UI에 쓰이는 # rig.backdrop이며 그건 persona.json의 backdrop 필드로 다룬다). DEFAULT_EYE_OVERRIDE = {"sclera": "#D8CEBD", "iris": "#4F3B2C", "irisRing": "#1E1F1F"} DEFAULT_PALETTE_FIXED = { "mouthInner": "#3B201B", "teeth": "#E9E0CF", "blush": "#C0624A", "tear": "#EEE5D3", "pallor": "#9AA3A6", "paper": "#EEE5D3", } DEFAULT_BACKDROP = { "cognitive": "#ECE3D1", "positive": "#F1DEC2", "negative": "#DCE0E2", "defensive": "#E6DAD3", "energy": "#E2E0D0", } DEFAULT_BUST_Y_OFFSET = 40.0 DEFAULT_FACE_INSET_PX = 8.0 @dataclass class PersonaConfig: persona_dir: Path code: str public_slug: str rig_file_name: str rig_export_name: str # 아래 필드들은 원화마다 달라지고 랜드마크로 유도할 수 없는(참조 이미지를 보고 # 사람이 고른) 값이라 persona.json에 없으면 None/빈 값으로 두고, 실제로 그 # 값이 필요한 단계 스크립트가 쓰는 시점에 어떤 필드를 채워야 하는지 에러로 # 알린다(로드 시점에 한꺼번에 요구하지 않는다 — 단계별로 독립 실행 가능해야 함). style_frame_path: Path | None palette_ink_box: list[float] | None palette_ink_lum_threshold: float | None eye_override: dict motif_petal_boxes: list[dict] motif_leaf_boxes: list[dict] moles: list[dict] pivots: dict | None bust_crop_y_offset: float palette_fixed: dict backdrop: dict # P1 전용 호환 장치(새 페르소나는 쓰지 않는다). face_detail.py가 눈썹 제외 영역· # eyeRegion y0을 계산할 때 쓰는 눈썹 좌표를 manifest.landmarks(중심선 보정본) # 대신 이 값으로 바꿔 쓴다. P1의 기존 face-detail.png·jaw-detail.png· # face-detail.webp·jaw-detail.webp·p1Rig.ts는 눈썹 중심선 보정(browCenterline) # 이전 좌표(manifest.browCenterline.oldPoints)로 빌드된 뒤 "다시 빌드하지 # 않는다"는 오케스트레이터 지시로 고정됐다 — 그 시점 좌표를 재현해야 바이트 # 단위 회귀가 성립한다. {"browLeft": {...}, "browRight": {...}} 형태(각 # inner/peak/outer), manifest.landmarks.eyebrowLeft/Right와 같은 스키마. brow_landmarks_override: dict | None # false면 export_rig.py가 RigBrow.halfWidth를 쓰지 않는다(렌더러가 linocutGeometry.ts # BROW_STOPS 고정값을 쓴다). 기본 true — 원화에서 잰 눈썹 반두께(manifest.browCenterline. # halfWidth{Left,Right})를 쓴다. P1은 이미 고정 굵기로 빌드·검수됐으므로 false로 둔다. brow_half_width_from_art: bool # false면 brow_sprite.py가 눈썹 스프라이트(layers/v2/brow-{left,right}.png)를 # 만들지 않는다(결정문 §8.4.1). 기본 true. P1은 이미 고정 벡터 눈썹으로 # 빌드·검수됐으므로 false로 둔다. brow_sprite_from_art: bool # false면 brow_sprite.py가 눈꺼풀 선 굵기 배율(RigEye.lineScale)을 재지 않는다 # (결정문 §8.4.1). 기본 true. P1은 lineScale의 기준값 자체(P1_UPPER_LID_THICKNESS_PX)라 # false로 둔다. eye_line_scale_from_art: bool # false면 export_rig.py가 RigEye.iris에 manifest.landmarks(mediapipe)의 값을 그대로 # 쓴다. 기본 true — brow_sprite.py가 원화에서 실측한 홍채(manifest.iris)를 쓴다 # (2차 보정 T3: mediapipe iris 랜드마크는 실제 눈 비례로 학습돼 판화 잉크 홍채보다 # 크게 잡는 경향이 있다). P1은 이미 고정 벡터 렌더로 빌드·검수됐으므로 false로 둔다. iris_from_art: bool # faceDetail.region: "features"(기본, 눈·입 주변 타원만) | "face"(얼굴 오벌 전체 — # 캐스트 확장, 얼굴 피부 해칭 보존). face_detail.py만 쓴다. face_detail_region: str # region="face"일 때 얼굴 오벌 경계에서 안쪽으로 줄이는 폭(px) — 원화 윤곽 잉크선이 # 이 영역에 들어오지 않게 하는 여유. region="features"면 쓰이지 않는다. face_inset_px: float # true면 face_detail.py가 제외 영역(눈·눈썹·입) 안을 채우는 faceless(F) 픽셀의 # 색을 주변 원화 피부에 맞춰 평행이동한다(보간/블러 없음). 기본 false(P1 그대로). match_faceless_color: bool def require_pivots(self) -> dict: if self.pivots is None: raise SystemExit( f"[중단] {self.persona_dir.name}/persona.json에 pivots(neck/body/face)가 없다 — " "원화를 보고 목·몸통·얼굴 회전 중심점을 정해 채워야 한다." ) return self.pivots def require_ink_sample(self) -> tuple[list[float], float]: if self.palette_ink_box is None or self.palette_ink_lum_threshold is None: raise SystemExit( f"[중단] {self.persona_dir.name}/persona.json에 paletteSamples.ink(box/lumThreshold)가 " "없다 — base-front.png에서 머리카락 어두운 덩어리를 담는 상자를 정해야 한다." ) return self.palette_ink_box, self.palette_ink_lum_threshold def require_style_frame(self) -> Path: if self.style_frame_path is None or not self.style_frame_path.exists(): raise SystemExit( f"[중단] {self.persona_dir.name}/persona.json의 styleFrame이 없거나 파일이 없다 — " "모티프 팔레트(motifPetal/motifLeaf)를 뽑을 스타일 참조 이미지가 필요하다." ) return self.style_frame_path def require_motif_boxes(self) -> tuple[list[dict], list[dict]]: if not self.motif_petal_boxes or not self.motif_leaf_boxes: raise SystemExit( f"[중단] {self.persona_dir.name}/persona.json에 paletteSamples.motifPetalBoxes/" "motifLeafBoxes가 없다 — styleFrame에서 꽃잎(ochre)·잎/구름(blue) 표본 상자를 정해야 한다." ) return self.motif_petal_boxes, self.motif_leaf_boxes # --- 공통 경로(모든 단계 스크립트가 쓰는 폴더 레이아웃, 페르소나 폴더 안에서 고정) --- @property def root(self) -> Path: return self.persona_dir @property def base_dir(self) -> Path: return self.persona_dir / "base" @property def raw_dir(self) -> Path: return self.persona_dir / "raw" @property def layers_dir(self) -> Path: return self.persona_dir / "layers" @property def layers_v2_dir(self) -> Path: return self.persona_dir / "layers" / "v2" @property def preview_dir(self) -> Path: return self.persona_dir / "preview" @property def preview_v2_dir(self) -> Path: return self.persona_dir / "preview" / "v2" @property def manifest_path(self) -> Path: return self.persona_dir / "manifest.json" # --- 저장소 안 게시 경로(apps/web) --- @property def repo_root(self) -> Path: # docs/avatar-art// -> parents[0]=avatar-art, [1]=docs, [2]=저장소 루트 return self.persona_dir.parents[2] @property def public_dir(self) -> Path: return self.repo_root / "apps" / "web" / "public" / "avatar" / "v3" / self.public_slug @property def public_href_prefix(self) -> str: return f"/avatar/v3/{self.public_slug}" @property def rig_ts_path(self) -> Path: return ( self.repo_root / "apps" / "web" / "src" / "components" / "avatar" / "v3" / "rigs" / self.rig_file_name ) def load_persona_config(persona_dir: Path) -> PersonaConfig: persona_dir = Path(persona_dir).resolve() cfg_path = persona_dir / "persona.json" if not cfg_path.exists(): raise SystemExit(f"[중단] 페르소나 설정이 없다: {cfg_path}") data = json.loads(cfg_path.read_text(encoding="utf-8")) code = data["code"] public_slug = data.get("publicSlug", code.lower()) rig_file_name = data.get("rigFileName", f"{public_slug}Rig.ts") rig_export_name = data.get("rigExportName", f"{code}_LINOCUT_RIG") style_frame = data.get("styleFrame") style_frame_path = (persona_dir / style_frame).resolve() if style_frame else None palette_samples = data.get("paletteSamples", {}) ink = palette_samples.get("ink") palette_ink_box = ink.get("box") if ink else None palette_ink_lum_threshold = ink.get("lumThreshold") if ink else None eye_override = palette_samples.get("eyeOverride", DEFAULT_EYE_OVERRIDE) motif_petal_boxes = palette_samples.get("motifPetalBoxes", []) motif_leaf_boxes = palette_samples.get("motifLeafBoxes", []) face_detail = data.get("faceDetail", {}) moles = face_detail.get("moles", []) brow_landmarks_override = face_detail.get("browLandmarksOverride") brow_half_width_from_art = data.get("browHalfWidthFromArt", True) brow_sprite_from_art = data.get("browSpriteFromArt", True) eye_line_scale_from_art = data.get("eyeLineScaleFromArt", True) iris_from_art = data.get("irisFromArt", True) face_detail_region = face_detail.get("region", "features") if face_detail_region not in ("features", "face"): raise SystemExit( f"[중단] {persona_dir.name}/persona.json의 faceDetail.region={face_detail_region!r}는 " "지원하지 않는다 — \"features\" 또는 \"face\"만 쓴다." ) face_inset_px = float(face_detail.get("faceInsetPx", DEFAULT_FACE_INSET_PX)) match_faceless_color = bool(face_detail.get("matchFacelessColor", False)) pivots = data.get("pivots") crops = data.get("crops", {}) bust_crop_y_offset = crops.get("bustYOffset", DEFAULT_BUST_Y_OFFSET) palette_fixed = {**DEFAULT_PALETTE_FIXED, **data.get("paletteFixed", {})} backdrop = {**DEFAULT_BACKDROP, **data.get("backdrop", {})} return PersonaConfig( persona_dir=persona_dir, code=code, public_slug=public_slug, rig_file_name=rig_file_name, rig_export_name=rig_export_name, style_frame_path=style_frame_path, palette_ink_box=list(palette_ink_box) if palette_ink_box is not None else None, palette_ink_lum_threshold=( float(palette_ink_lum_threshold) if palette_ink_lum_threshold is not None else None ), eye_override=eye_override, motif_petal_boxes=motif_petal_boxes, motif_leaf_boxes=motif_leaf_boxes, moles=moles, pivots=pivots, bust_crop_y_offset=float(bust_crop_y_offset), palette_fixed=palette_fixed, backdrop=backdrop, brow_landmarks_override=brow_landmarks_override, brow_half_width_from_art=bool(brow_half_width_from_art), brow_sprite_from_art=bool(brow_sprite_from_art), eye_line_scale_from_art=bool(eye_line_scale_from_art), iris_from_art=bool(iris_from_art), face_detail_region=face_detail_region, face_inset_px=face_inset_px, match_faceless_color=match_faceless_color, )