vignette/docs/avatar-art/linocut-pipeline/scripts/persona_config.py
Yun Chan ecb36d123f 리노컷 자산 파이프라인 공통화와 캐스트 외형 설계
- P1 전용 스크립트를 docs/avatar-art/linocut-pipeline 으로 옮겨 persona.json 설정으로 일반화(P1 재실행 리그 바이트 동일)
- 7명 외형·상징 설계(linocut-cast.md)와 P2~P7 정면 원화 생성 프롬프트, 얼굴 없는 화풍 참조
- P1 원화 생성 프롬프트 보존
2026-10-01 16:11:28 +09:00

214 lines
9 KiB
Python

"""공통 리노컷 파이프라인의 페르소나별 설정 로더.
페르소나 폴더(예: 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
@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
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/<persona>/ -> 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")
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,
)