"""P1 서연 리노컷 리그 게시 스크립트 (2단계-B-1a). layers/{v2 있으면 v2 우선, 없으면 layers/*}의 PNG를 알파 bbox로 잘라 WebP로 apps/web/public/avatar/v3/p1/에 게시하고, apps/web/src/components/avatar/v3/rigs/p1Rig.ts를 생성한다. 같은 입력이면 같은 결과가 나오도록 결정적으로 만든다(팔레트·faceOval 계산 방식 고정). 같은 스크립트를 (0) 선행 게시(faceDetail·grain 없음)와 (5) 최종 게시(둘 다 있음) 양쪽에 쓴다. faceDetail/grain 소스 파일이 없으면 해당 필드를 rig에서 생략한다. 실행: /python.exe export_rig.py """ from __future__ import annotations import io import json import math import subprocess import sys import tempfile from pathlib import Path import numpy as np from PIL import Image from build_layers_v2 import halo_metric # noqa: E402 ROOT = Path(__file__).resolve().parents[1] BASE_DIR = ROOT / "base" LAYERS_DIR = ROOT / "layers" LAYERS_V2_DIR = ROOT / "layers" / "v2" MANIFEST_PATH = ROOT / "manifest.json" SCRIPTS_DIR = Path(__file__).resolve().parent MOTIF_ROOT = ROOT / "motif" MOTIF_SPRITES_DIR = MOTIF_ROOT / "sprites" MOTIF_MANIFEST_PATH = MOTIF_ROOT / "manifest.json" BUD_STATES = ["closed", "half", "open", "droop"] WEATHER_GROUPS = ["positive", "negative", "defensive", "cognitive", "energy"] MOTIF_SIZE_BUDGET_BYTES = 160 * 1024 REPO_ROOT = ROOT.parents[2] PUBLIC_DIR = REPO_ROOT / "apps" / "web" / "public" / "avatar" / "v3" / "p1" MOTIF_PUBLIC_DIR = PUBLIC_DIR / "motif" RIG_TS_PATH = REPO_ROOT / "apps" / "web" / "src" / "components" / "avatar" / "v3" / "rigs" / "p1Rig.ts" STYLE_FRAME_PATH = ROOT.parents[0] / "art-direction-v3" / "p1" / "r2-b-linocut.png" SIZE_BUDGET_BYTES = 600 * 1024 # --- 레이어 소스 해석: v2가 있으면 v2, 없으면 기존 layers/ ------------------ LAYER_SPECS = [ # (rig key, 파일 stem, public href stem, v2에만 있어도 되는가) ("body", "body", "body", False), ("head", "head", "head", False), ("hairFront", "hairFront", "hair-front", False), ("faceDetail", "face-detail", "face-detail", True), ] GRAIN_SPEC = ("paper-grain", "paper-grain") # --- 입술 결 스프라이트(결정문 §8.4 입, 2단계-B-1d-A2) ----------------------- LIP_SPECS = [ # (rig key, 파일 stem, public href stem) ("upper", "lip-upper", "lip-upper"), ("lower", "lip-lower", "lip-lower"), ("shadow", "lip-shadow", "lip-shadow"), ] LIP_SIZE_BUDGET_BYTES = 80 * 1024 # 패킷 A5 크기 상한(그늘 조각 분리로 3장, A4의 60KB에서 상향) # --- 턱 조각(결정문 §8.4 하관 띠 변형, 작업 패킷 A5) -------------------------- JAW_SPECS = [ # (rig key, 파일 stem, public href stem) ("head", "jaw-head", "jaw-head"), ("detail", "jaw-detail", "jaw-detail"), ] JAW_SIZE_BUDGET_BYTES = 200 * 1024 # 패킷 A5 크기 상한 def resolve_layer_source(stem: str, optional: bool) -> Path | None: v2_path = LAYERS_V2_DIR / f"{stem}.png" if v2_path.exists(): return v2_path legacy_path = LAYERS_DIR / f"{stem}.png" if legacy_path.exists(): return legacy_path if optional: return None raise SystemExit(f"[중단] 레이어 소스가 없다: {v2_path} 또는 {legacy_path}") def resolve_grain_source() -> Path | None: stem, _ = GRAIN_SPEC p = LAYERS_V2_DIR / f"{stem}.png" return p if p.exists() else None # --- 알파 bbox 크롭 --------------------------------------------------------- def alpha_bbox(alpha: np.ndarray, threshold: int = 1) -> tuple[int, int, int, int]: ys, xs = np.where(alpha >= threshold) if len(xs) == 0: raise SystemExit("[중단] 레이어 알파가 전부 0이다.") return int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1 def crop_to_bbox(im: Image.Image) -> tuple[Image.Image, tuple[int, int, int, int]]: arr = np.array(im.convert("RGBA")) x0, y0, x1, y1 = alpha_bbox(arr[..., 3]) return im.crop((x0, y0, x1, y1)), (x0, y0, x1 - x0, y1 - y0) # --- WebP 게시(용량 예산에 맞춰 quality/해상도 단계적으로 낮춤) ------------- def encode_webp(im: Image.Image, quality: int) -> bytes: import io buf = io.BytesIO() im.save(buf, "WEBP", quality=quality, alpha_quality=100, method=6) return buf.getvalue() def publish_layers(sources: dict[str, tuple[Path, str]], grain_source: Path | None) -> tuple[dict, dict | None, dict]: """sources: rig key -> (원본 PNG 경로, href stem). 반환: (layers 리포트, grain 리포트, 파일크기 dict)""" PUBLIC_DIR.mkdir(parents=True, exist_ok=True) cropped: dict[str, tuple[Image.Image, tuple[int, int, int, int], str]] = {} for key, (path, href_stem) in sources.items(): im = Image.open(path) cropped_im, bbox = crop_to_bbox(im) cropped[key] = (cropped_im, bbox, href_stem) grain_im = None if grain_source is not None: grain_im = Image.open(grain_source).convert("RGB") def encode_all(quality: int, scale: float) -> dict[str, bytes]: out: dict[str, bytes] = {} for key, (im, _bbox, href_stem) in cropped.items(): work = im if scale != 1.0: w = max(1, round(im.width * scale)) h = max(1, round(im.height * scale)) work = im.resize((w, h), Image.LANCZOS) out[href_stem] = encode_webp(work.convert("RGBA"), quality) if grain_im is not None: work = grain_im if scale != 1.0: w = max(1, round(grain_im.width * scale)) h = max(1, round(grain_im.height * scale)) work = grain_im.resize((w, h), Image.LANCZOS) out[GRAIN_SPEC[1]] = encode_webp(work.convert("RGB"), quality) return out attempts = [(80, 1.0), (70, 1.0), (70, 0.85)] chosen = None chosen_meta = None for quality, scale in attempts: encoded = encode_all(quality, scale) total = sum(len(b) for b in encoded.values()) if total <= SIZE_BUDGET_BYTES or (quality, scale) == attempts[-1]: chosen = encoded chosen_meta = {"quality": quality, "scale": scale, "totalBytes": total} break assert chosen is not None and chosen_meta is not None file_sizes: dict[str, int] = {} for href_stem, data in chosen.items(): out_path = PUBLIC_DIR / f"{href_stem}.webp" out_path.write_bytes(data) file_sizes[href_stem] = len(data) layers_report = {} for key, (_im, bbox, href_stem) in cropped.items(): x0, y0, w, h = bbox layers_report[key] = { "href": f"/avatar/v3/p1/{href_stem}.webp", "x": round(x0, 1), "y": round(y0, 1), "w": round(w, 1), "h": round(h, 1), } grain_report = None if grain_im is not None: grain_report = {"href": f"/avatar/v3/p1/{GRAIN_SPEC[1]}.webp", "size": 256} return layers_report, grain_report, {"fileSizes": file_sizes, "encodeMeta": chosen_meta} # --- 입술 결 스프라이트 게시(결정문 §8.4 입, 2단계-B-1d-A2) ----------------- def publish_lip_texture(manifest: dict) -> tuple[dict | None, dict | None]: """layers/v2/lip-{upper,lower}.png(build_lip_texture.py 산출)를 알파 bbox로 잘라 WebP로 게시한다. 소스가 없으면 (None, None)을 반환해 lipTexture 없는 리그도 여전히 만들 수 있게 한다(렌더러는 palette 단색 으로 대체). 두 파일 합계 예산은 60KB(패킷 A4 크기 상한). PNG 자체는 이미 알파 bbox로 꽉 차게 잘려 있어(build_lip_texture.py) 이 함수의 crop_to_bbox는 사실상 no-op이고, 캔버스 위치 정보를 담고 있지 않다 — RigLayer.x/y(절대 캔버스 좌표)는 manifest["lipTexture"]["canvasOrigin"](작업 캔버스 좌표를 build_lip_texture.py가 절대 좌표로 환산해 기록한 값)에서 읽는다.""" canvas_origin = manifest.get("lipTexture", {}).get("canvasOrigin") if canvas_origin is None: raise SystemExit("[중단] manifest.lipTexture.canvasOrigin이 없다 — build_lip_texture.py를 먼저 실행하라.") sources: dict[str, tuple[Path, str]] = {} for key, stem, href_stem in LIP_SPECS: p = LAYERS_V2_DIR / f"{stem}.png" if not p.exists(): return None, None sources[key] = (p, href_stem) cropped: dict[str, tuple[Image.Image, tuple[int, int, int, int], str]] = {} for key, (path, href_stem) in sources.items(): im = Image.open(path) cropped_im, local_bbox = crop_to_bbox(im) lx0, ly0 = canvas_origin[key] abs_bbox = (lx0 + local_bbox[0], ly0 + local_bbox[1], local_bbox[2], local_bbox[3]) cropped[key] = (cropped_im, abs_bbox, href_stem) import io LIP_DIFF4_TARGET = 2.0 # 검사(4) 목표(입 결은 표정의 중심이라 품질을 우선한다, 오케스트레이터 지시) def encode_all(quality: int) -> dict[str, bytes]: out: dict[str, bytes] = {} for key, (im, _bbox, href_stem) in cropped.items(): out[href_stem] = encode_webp(im.convert("RGBA"), quality) return out def encode_all_lossless() -> dict[str, bytes]: out: dict[str, bytes] = {} for key, (im, _bbox, href_stem) in cropped.items(): buf = io.BytesIO() # exact=True: libwebp 기본값(0)은 완전 투명(알파=0) 영역의 RGB를 압축률을 위해 # 바꿔도 되는 것으로 보고 버린다 — "무손실"이 알파>0 영역에만 적용된다. 알파=0 # 영역도 원화 RGB를 그대로 담고 있으므로(build_lip_texture.py) exact=True로 강제한다. im.convert("RGBA").save(buf, "WEBP", lossless=True, quality=100, method=6, exact=True) out[href_stem] = buf.getvalue() return out def webp_vs_png_diff(encoded: dict[str, bytes]) -> dict[str, float]: diffs: dict[str, float] = {} for key, (im, _bbox, href_stem) in cropped.items(): src_arr = np.array(im.convert("RGBA")).astype(np.float64) dec_arr = np.array(Image.open(io.BytesIO(encoded[href_stem])).convert("RGBA")).astype(np.float64) diffs[key] = round(float(np.abs(src_arr - dec_arr).mean()), 3) return diffs # 1순위: 무손실. 두 장 합계가 예산(60KB) 안이면 무손실을 쓴다. lossless_encoded = encode_all_lossless() lossless_total = sum(len(b) for b in lossless_encoded.values()) if lossless_total <= LIP_SIZE_BUDGET_BYTES: chosen, chosen_quality = lossless_encoded, "lossless" else: # 2순위: quality>=95 손실 인코딩 중 예산 안이면서 평균차<=2인 가장 작은 파일(=가장 높은 quality부터 시도). candidates: list[tuple[int, dict[str, bytes], int, dict[str, float]]] = [] chosen, chosen_quality = None, None for q in (100, 99, 98, 97, 96, 95): encoded = encode_all(q) total = sum(len(b) for b in encoded.values()) diffs = webp_vs_png_diff(encoded) candidates.append((q, encoded, total, diffs)) if total <= LIP_SIZE_BUDGET_BYTES and max(diffs.values()) <= LIP_DIFF4_TARGET: chosen, chosen_quality = encoded, q break if chosen is None: # 목표(예산 AND 평균차<=2)를 동시에 만족하는 quality가 없다 — 예산 안 후보 중 평균차 최솟값을 쓴다. within_budget = [c for c in candidates if c[2] <= LIP_SIZE_BUDGET_BYTES] pool = within_budget if within_budget else candidates best = min(pool, key=lambda c: max(c[3].values())) chosen_quality, chosen, _total, _diffs = best[0], best[1], best[2], best[3] print( f" [경고] quality 95~100 중 예산({LIP_SIZE_BUDGET_BYTES}B) AND 평균차<={LIP_DIFF4_TARGET} " f"동시 만족 없음 — quality={chosen_quality}(평균차 최소) 채택" ) assert chosen is not None and chosen_quality is not None PUBLIC_DIR.mkdir(parents=True, exist_ok=True) file_sizes: dict[str, int] = {} for href_stem, data in chosen.items(): out_path = PUBLIC_DIR / f"{href_stem}.webp" out_path.write_bytes(data) file_sizes[href_stem] = len(data) total_bytes = sum(file_sizes.values()) print(f"입술 결 인코딩: quality={chosen_quality} (무손실 시도 시 합계 {lossless_total} bytes)") for href_stem, size in file_sizes.items(): print(f" {href_stem}.webp = {size} bytes") print( f"입술 결 합계 = {total_bytes} bytes (예산 {LIP_SIZE_BUDGET_BYTES} bytes) " f"{'OK' if total_bytes <= LIP_SIZE_BUDGET_BYTES else '[초과]'}" ) # --- 검사(4) WebP 대 PNG 평균차(알파 채널 포함) --- webp_vs_png = webp_vs_png_diff(chosen) for href_stem in file_sizes: key = next(k for k, (_im, _bbox, hs) in cropped.items() if hs == href_stem) print(f" 검사(4) [{href_stem}] WebP 대 PNG 평균차={webp_vs_png[key]}") lip_field = {} for key, (_im, bbox, href_stem) in cropped.items(): x0, y0, w, h = bbox lip_field[key] = { "href": f"/avatar/v3/p1/{href_stem}.webp", "x": round(x0, 1), "y": round(y0, 1), "w": round(w, 1), "h": round(h, 1), } publish_report = { "quality": chosen_quality, "fileSizes": file_sizes, "totalBytes": total_bytes, "budgetBytes": LIP_SIZE_BUDGET_BYTES, "webpVsPngMeanAbsDiff": webp_vs_png, } return {"upper": lip_field["upper"], "lower": lip_field["lower"], "shadow": lip_field["shadow"]}, publish_report # --- 턱 조각 게시(결정문 §8.4 하관 띠 변형, 작업 패킷 A5) ------------------- JAW_LAYER_KEY_FOR = {"head": "head", "detail": "faceDetail"} # jaw rig key -> layers_report 키 JAW_DIFF_TARGET = 2.0 # 오케스트레이터 지시: 평균 절대차 목표 def publish_jaw_pieces(manifest: dict, layers_report: dict, encode_meta: dict) -> tuple[dict | None, dict | None]: """layers/v2/jaw-{head,detail}.png(build_jaw_pieces.py 산출, 원본 레이어 픽셀을 그대로 잘라낸 것)를 게시한다. A5 1차 시도(무손실 WebP)는 오케스트레이터 판정으로 반려됐다: 화면에 실제로 그려지는 원본은 게시된 `head.webp`·`face-detail.webp`이고 **그 둘 다 이미 손실 압축**이라, 턱 조각만 무손실로 게시하면 PNG와는 같아도 화면의 원본과는 달라 이음매가 드러난다. 그래서 턱 조각을 **같은 레이어가 쓴 것과 같은 인코딩 설정(quality·alpha_quality·method, publish_layers의 encode_meta)**으로 다시 인코딩한다. 픽셀 동일성 기준도 PNG가 아니라 **게시된 head.webp/face-detail.webp를 같은 캔버스 영역으로 잘라 디코드한 값**으로 바꾼다(둘 다 화면 표시 크기로 맞춘 뒤 비교 — encode_meta.scale!=1이면 원본 alpha-bbox 크기로 늘려 브라우저가 표시할 모습을 재현한다).""" jaw_manifest = manifest.get("jaw") if jaw_manifest is None: return None, None bbox = jaw_manifest["bboxCanvas"] x0, y0, x1, y1 = bbox w, h = x1 - x0, y1 - y0 sources: dict[str, tuple[Path, str]] = {} for key, stem, href_stem in JAW_SPECS: p = LAYERS_V2_DIR / f"{stem}.png" if not p.exists(): return None, None sources[key] = (p, href_stem) quality = encode_meta["quality"] scale = encode_meta["scale"] PUBLIC_DIR.mkdir(parents=True, exist_ok=True) file_sizes: dict[str, int] = {} compare_report: dict[str, dict] = {} for key, (path, href_stem) in sources.items(): im = Image.open(path).convert("RGBA") if im.size != (w, h): raise SystemExit(f"[중단] {path.name} 크기({im.size})가 manifest.jaw.bboxCanvas({w}x{h})와 다르다.") work = im if scale != 1.0: sw, sh = max(1, round(w * scale)), max(1, round(h * scale)) work = im.resize((sw, sh), Image.LANCZOS) data = encode_webp(work, quality) out_path = PUBLIC_DIR / f"{href_stem}.webp" out_path.write_bytes(data) file_sizes[href_stem] = len(data) # 화면에 그려지는 모습 재현: 디코드 후 축소돼 있었다면(scale!=1) 원래 캔버스 # 크기(w,h)로 다시 늘린다 — 브라우저가 태그를 w,h 사각형에 맞춰 # 늘리는 것과 같다. jaw_decoded = Image.open(io.BytesIO(data)).convert("RGBA") if jaw_decoded.size != (w, h): jaw_decoded = jaw_decoded.resize((w, h), Image.LANCZOS) jaw_arr = np.array(jaw_decoded).astype(np.float64) layer_key = JAW_LAYER_KEY_FOR[key] layer_info = layers_report[layer_key] layer_cx0, layer_cy0 = int(round(layer_info["x"])), int(round(layer_info["y"])) layer_w, layer_h = int(round(layer_info["w"])), int(round(layer_info["h"])) layer_webp_path = PUBLIC_DIR / Path(layer_info["href"]).name layer_decoded = Image.open(layer_webp_path).convert("RGBA") if layer_decoded.size != (layer_w, layer_h): layer_decoded = layer_decoded.resize((layer_w, layer_h), Image.LANCZOS) layer_arr = np.array(layer_decoded).astype(np.float64) # 게시된 레이어는 자기 알파 bbox로만 잘려 있어(faceDetail처럼) 턱 조각 사각형을 # 다 못 덮을 수 있다 — 그 밖은 레이어가 화면에 아무것도 안 그리는(완전 투명) # 자리이므로 0으로 채운 캔버스에 교집합만 옮겨 담는다. layer_crop = np.zeros((h, w, 4), dtype=np.float64) ix0, iy0 = max(x0, layer_cx0), max(y0, layer_cy0) ix1, iy1 = min(x0 + w, layer_cx0 + layer_w), min(y0 + h, layer_cy0 + layer_h) if ix1 > ix0 and iy1 > iy0: layer_crop[iy0 - y0:iy1 - y0, ix0 - x0:ix1 - x0] = layer_arr[ iy0 - layer_cy0:iy1 - layer_cy0, ix0 - layer_cx0:ix1 - layer_cx0 ] # 알파=0인 자리는 화면에 전혀 그려지지 않으므로 그 자리의 RGB 값은 "화면에 # 보이는 차이"와 무관하다(원본 크롭·게시 레이어 각각 인코더가 자유롭게 다른 # 값을 남길 수 있다 — 실측: 이 값을 그냥 빼면 평균차가 19.7까지 치솟는데, # 전부 두 쪽 다 알파 0인 자리의 RGB 잡음이었다). 알파를 곱한(premultiplied) # 색으로 비교해야 "실제로 그려지는 결과"의 차이가 된다. def _premultiplied(arr: np.ndarray) -> np.ndarray: return arr[..., :3] * (arr[..., 3:4] / 255.0) diff_rgb = np.abs(_premultiplied(jaw_arr) - _premultiplied(layer_crop)) diff_alpha = np.abs(jaw_arr[..., 3] - layer_crop[..., 3]) mean_diff = round(float(diff_rgb.mean()), 3) max_diff = round(float(diff_rgb.max()), 3) mean_diff_alpha = round(float(diff_alpha.mean()), 3) max_diff_alpha = round(float(diff_alpha.max()), 3) compare_report[key] = { "comparedAgainstPublishedLayer": layer_webp_path.name, "meanAbsDiffPremultipliedRgb": mean_diff, "maxAbsDiffPremultipliedRgb": max_diff, "meanAbsDiffAlpha": mean_diff_alpha, "maxAbsDiffAlpha": max_diff_alpha, "meetsTarget": mean_diff <= JAW_DIFF_TARGET, } if key == "head" and not compare_report[key]["meetsTarget"]: compare_report[key]["orchestratorAcceptanceNote"] = ( "평균차 3.13(목표 2.0 초과) 수용(오케스트레이터, 2026-10-01). 근거: " "최대차가 난 자리(왼쪽 위 머리카락)는 띠 clip(얼굴 윤곽 18px 바깥 " "다각형) 밖이라 렌더러가 그리지 않는다. 띠가 켜지는 동안에도 윗경계(y_n) " "부근은 변위 f(y)가 0에 가까워 이 조각의 위쪽 여백이 눈에 띄게 움직이지 " "않는다. quality를 올려도 이미 게시된 head.webp 자체의 압축 오차만큼은 " "남아 이득이 없다(비교 대상 자체가 손실 압축본)." ) total_bytes = sum(file_sizes.values()) print(f"턱 조각 인코딩: quality={quality} scale={scale}(head·faceDetail 레이어와 동일 설정)") for href_stem, size in file_sizes.items(): print(f" {href_stem}.webp = {size} bytes") print( f"턱 조각 합계 = {total_bytes} bytes (예산 {JAW_SIZE_BUDGET_BYTES} bytes) " f"{'OK' if total_bytes <= JAW_SIZE_BUDGET_BYTES else '[초과]'}" ) for key, href_stem in (("head", "jaw-head"), ("detail", "jaw-detail")): r = compare_report[key] print(f" 검사(1, A5 재게시) [{href_stem}] 게시된 {r['comparedAgainstPublishedLayer']}(같은 영역, 알파 곱한 색) 대비 " f"평균차={r['meanAbsDiffPremultipliedRgb']} 최대차={r['maxAbsDiffPremultipliedRgb']} " f"(알파 평균차={r['meanAbsDiffAlpha']} 최대차={r['maxAbsDiffAlpha']}) " f"(목표 평균<={JAW_DIFF_TARGET}) {'OK' if r['meetsTarget'] else '[미달]'}") jaw_field = { "head": {"href": "/avatar/v3/p1/jaw-head.webp", "x": x0, "y": y0, "w": w, "h": h}, "detail": {"href": "/avatar/v3/p1/jaw-detail.webp", "x": x0, "y": y0, "w": w, "h": h}, } publish_report = { "quality": quality, "scale": scale, "fileSizes": file_sizes, "totalBytes": total_bytes, "budgetBytes": JAW_SIZE_BUDGET_BYTES, "vsPublishedLayerAbsDiff": compare_report, } return jaw_field, publish_report # --- 모티프 스프라이트 게시(꽃봉오리·날씨) --------------------------------- def publish_motif() -> tuple[dict, dict]: """motif/sprites/*.png를 WebP로 게시하고(알파 bbox 크롭 없음 — 스프라이트 캔버스 그대로), 좌표는 motif/manifest.json에서 읽어 하드코딩하지 않는다. 반환: (rig의 motif 필드, manifest.json 기록용 리포트).""" motif_manifest = json.loads(MOTIF_MANIFEST_PATH.read_text(encoding="utf-8")) MOTIF_PUBLIC_DIR.mkdir(parents=True, exist_ok=True) bud_canvas = motif_manifest["bud"]["canvas"] bud_base = motif_manifest["bud"]["base_anchor_px"] weather_sprite_canvases = {s["name"]: s["canvas"] for s in motif_manifest["weather"]["sprites"]} weather_canvas = None for name in WEATHER_GROUPS: c = weather_sprite_canvases[f"weather-{name}"] if weather_canvas is None: weather_canvas = c elif c != weather_canvas: raise SystemExit(f"[중단] weather 스프라이트 캔버스 크기가 서로 다르다: {weather_sprite_canvases}") sources: dict[str, Path] = {} for state in BUD_STATES: sources[f"bud-{state}"] = MOTIF_SPRITES_DIR / f"bud-{state}.png" for name in WEATHER_GROUPS: sources[f"weather-{name}"] = MOTIF_SPRITES_DIR / f"weather-{name}.png" for key, p in sources.items(): if not p.exists(): raise SystemExit(f"[중단] 모티프 스프라이트가 없다: {p}") images = {key: Image.open(p).convert("RGBA") for key, p in sources.items()} def encode_all(quality: int) -> dict[str, bytes]: out: dict[str, bytes] = {} for key, im in images.items(): out[key] = encode_webp(im, quality) return out chosen: dict[str, bytes] | None = None chosen_quality: int | None = None for q in (82, 72): encoded = encode_all(q) total = sum(len(b) for b in encoded.values()) if total <= MOTIF_SIZE_BUDGET_BYTES or q == 72: chosen, chosen_quality = encoded, q break assert chosen is not None and chosen_quality is not None file_sizes: dict[str, int] = {} for key, data in chosen.items(): out_path = MOTIF_PUBLIC_DIR / f"{key}.webp" out_path.write_bytes(data) file_sizes[key] = len(data) total_bytes = sum(file_sizes.values()) print(f"모티프 인코딩: quality={chosen_quality}") for key, size in file_sizes.items(): print(f" motif/{key}.webp = {size} bytes") print( f"모티프 합계 = {total_bytes} bytes (예산 {MOTIF_SIZE_BUDGET_BYTES} bytes) " f"{'OK' if total_bytes <= MOTIF_SIZE_BUDGET_BYTES else '[초과]'}" ) bud_paper = np.array(motif_manifest["bud"]["raw_sheet"]["paper_color_rgb"], dtype=np.float64) weather_paper = np.array(motif_manifest["weather"]["raw_sheet"]["paper_color_rgb"], dtype=np.float64) halo_report: dict[str, float] = {} for key in images: webp_path = MOTIF_PUBLIC_DIR / f"{key}.webp" arr = np.array(Image.open(webp_path).convert("RGBA")).astype(np.float64) rgb, alpha = arr[..., :3], arr[..., 3] paper = bud_paper if key.startswith("bud") else weather_paper hm = halo_metric(rgb, alpha, paper) halo_report[key] = hm print(f" halo({key}) = {hm:.3f}% (기준 <=2%) {'OK' if hm <= 2.0 else '[초과]'}") motif_field = { "bud": { "canvas": {"w": bud_canvas[0], "h": bud_canvas[1]}, "base": [round(bud_base[0], 2), round(bud_base[1], 2)], "closed": "/avatar/v3/p1/motif/bud-closed.webp", "half": "/avatar/v3/p1/motif/bud-half.webp", "open": "/avatar/v3/p1/motif/bud-open.webp", "droop": "/avatar/v3/p1/motif/bud-droop.webp", }, "weather": { "canvas": {"w": weather_canvas[0], "h": weather_canvas[1]}, "sprites": {name: f"/avatar/v3/p1/motif/weather-{name}.webp" for name in WEATHER_GROUPS}, }, } publish_report = { "quality": chosen_quality, "fileSizes": file_sizes, "totalBytes": total_bytes, "budgetBytes": MOTIF_SIZE_BUDGET_BYTES, "halo": halo_report, "haloThreshold": 2.0, } return motif_field, publish_report # --- 얼굴 랜드마크(FaceLandmarker 서브프로세스, faceOval·mouthCenter용) ---- FACE_OVAL_LOOP = [ 10, 338, 297, 332, 284, 251, 389, 356, 454, 323, 361, 288, 397, 365, 379, 378, 400, 377, 152, 148, 176, 149, 150, 136, 172, 58, 132, 93, 234, 127, 162, 21, 54, 103, 67, 109, ] FACE_OVAL_SCALE = 1.04 MOUTH_CENTER_INNER_UPPER = 13 MOUTH_CENTER_INNER_LOWER = 14 def detect_face_landmarks(image_path: Path) -> list[tuple[float, float]]: with tempfile.TemporaryDirectory() as td: out_json = Path(td) / "landmarks.json" proc = subprocess.run( [sys.executable, "-u", str(SCRIPTS_DIR / "_run_face_landmarks.py"), str(image_path), str(out_json)], capture_output=True, text=True, ) print(proc.stdout.strip()) if proc.returncode != 0 or not out_json.exists(): raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker 서브프로세스 실패.\n{proc.stderr}") data = json.loads(out_json.read_text(encoding="utf-8")) if not data.get("ok"): raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker가 얼굴을 찾지 못했다.") return [(p[0], p[1]) for p in data["points"]] def compute_face_oval(points: list[tuple[float, float]]) -> list[tuple[float, float]]: loop_pts = [points[i] for i in FACE_OVAL_LOOP] cx = sum(p[0] for p in loop_pts) / len(loop_pts) cy = sum(p[1] for p in loop_pts) / len(loop_pts) return [((x - cx) * FACE_OVAL_SCALE + cx, (y - cy) * FACE_OVAL_SCALE + cy) for x, y in loop_pts] def compute_mouth_center(points: list[tuple[float, float]]) -> tuple[float, float]: a = points[MOUTH_CENTER_INNER_UPPER] b = points[MOUTH_CENTER_INNER_LOWER] return ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0) # --- 팔레트 표본 ------------------------------------------------------------ def hexc(rgb: np.ndarray) -> str: return "#%02X%02X%02X" % tuple(int(round(c)) for c in np.clip(rgb, 0, 255)) def compute_palette(base_front_rgb: np.ndarray, manifest: dict) -> tuple[dict, dict]: im = base_front_rgb.astype(np.float64) lm = manifest["landmarks"] samples_report: dict = {} def eye_samples(eye: dict) -> dict: cx, cy = eye["iris"]["center"] r = eye["iris"]["radius"] pad = 6 x0, x1 = int(cx - r - pad), int(cx + r + pad) y0, y1 = int(cy - r - pad), int(cy + r + pad) region = im[y0:y1, x0:x1] yy, xx = np.mgrid[y0:y1, x0:x1] dist = np.sqrt((xx - cx) ** 2 + (yy - cy) ** 2) lum = region.mean(axis=2) sclera_mask = (dist > r * 1.05) & (lum > 90) & (lum < 225) sclera_px = region[sclera_mask] body_mask = (dist < r * 0.85) & (lum > 15) body_px = region[body_mask] body_lum = lum[body_mask] order = np.argsort(body_lum) band = body_px[order[int(len(order) * 0.65):int(len(order) * 0.85)]] if len(order) else body_px ring_mask = (dist >= r * 0.90) & (dist <= r * 1.05) ring_px = region[ring_mask] return { "sclera": np.median(sclera_px, axis=0) if len(sclera_px) else None, "iris": np.median(band, axis=0) if len(band) else None, "irisRing": np.median(ring_px, axis=0) if len(ring_px) else None, "n": {"sclera": len(sclera_px), "iris": len(band), "irisRing": len(ring_px)}, } eL = eye_samples(lm["eyeLeft"]) eR = eye_samples(lm["eyeRight"]) # sclera/iris/irisRing 측정값은 참고용으로만 기록한다. base-front의 흰자는 조각 # 해칭 그늘 때문에 밝은 픽셀이 거의 없고(명도>140 픽셀 4개뿐, 표본 중앙값 # #A5917A), irisRing 측정값(#3E3028)이 iris 측정값보다 밝아 테두리가 안 보인다. # 벡터 눈은 그늘을 별도로 그리므로(윗눈꺼풀 그늘 띠) 이 3개는 표본이 아니라 # 오케스트레이터가 정한 고정 설계값을 쓴다(2단계-B-1a 팔레트 정정 지시). sclera_measured = (eL["sclera"] + eR["sclera"]) / 2.0 iris_measured = (eL["iris"] + eR["iris"]) / 2.0 iris_ring_measured = (eL["irisRing"] + eR["irisRing"]) / 2.0 sclera = np.array([0xD8, 0xCE, 0xBD], dtype=np.float64) iris = np.array([0x4F, 0x3B, 0x2C], dtype=np.float64) iris_ring = np.array([0x1E, 0x1F, 0x1F], dtype=np.float64) samples_report["eyeLeft"] = {k: (hexc(v) if isinstance(v, np.ndarray) else v) for k, v in eL.items()} samples_report["eyeRight"] = {k: (hexc(v) if isinstance(v, np.ndarray) else v) for k, v in eR.items()} samples_report["scleraIrisIrisRingFixedOverride"] = { "reason": "고정값(오케스트레이터 결정), 측정 참고값: sclera 중앙값 #A5917A, 홍채 중간 링 #3E3028", "measured": {"sclera": hexc(sclera_measured), "iris": hexc(iris_measured), "irisRing": hexc(iris_ring_measured)}, "fixed": {"sclera": hexc(sclera), "iris": hexc(iris), "irisRing": hexc(iris_ring)}, } # ink: 머리카락 덩어리 안 어두운 픽셀(hair 영역 bbox, lum<55) hair_region = im[100:350, 280:720] hair_lum = hair_region.mean(axis=2) dark_px = hair_region[hair_lum < 55] ink = np.median(dark_px, axis=0) samples_report["ink"] = {"box": [280, 100, 720, 350], "lumThreshold": 55, "n": int(len(dark_px)), "hex": hexc(ink)} # 입술: mouthCorner/upperLipTop/lowerLipBottom bbox를 mouthCenter y로 위/아래 분리 mc = compute_mouth_center_from_manifest(manifest) lc = lm["mouthCornerLeft"] rc = lm["mouthCornerRight"] ut = lm["upperLipTopCenter"] lb = lm["lowerLipBottomCenter"] x0, x1 = int(min(lc[0], rc[0])) - 5, int(max(lc[0], rc[0])) + 5 upper_region = im[int(ut[1] - 3):int(mc[1] - 2), x0:x1] lower_region = im[int(mc[1] + 2):int(lb[1] + 3), x0:x1] lip_upper = np.median(upper_region.reshape(-1, 3), axis=0) lip_lower = np.median(lower_region.reshape(-1, 3), axis=0) mouthline_region = im[int(mc[1]) - 2:int(mc[1]) + 3, x0 + 20:x1 - 20] mouth_line = np.median(mouthline_region.reshape(-1, 3), axis=0) samples_report["lipUpper"] = {"box": [x0, int(ut[1] - 3), x1, int(mc[1] - 2)], "hex": hexc(lip_upper)} samples_report["lipLower"] = {"box": [x0, int(mc[1] + 2), x1, int(lb[1] + 3)], "hex": hexc(lip_lower)} samples_report["mouthLine"] = {"box": [x0 + 20, int(mc[1]) - 2, x1 - 20, int(mc[1]) + 3], "hex": hexc(mouth_line)} # 모티프: 스타일 프레임 r2-b-linocut.png에서 해/튤립(ochre), 구름/잎(slate blue) 표본 style = np.array(Image.open(STYLE_FRAME_PATH).convert("RGB")).astype(np.float64) def box_median(x0, y0, x1, y1, kind): box = style[y0:y1, x0:x1].reshape(-1, 3) r, g, b = box[:, 0], box[:, 1], box[:, 2] lum = box.mean(axis=1) if kind == "ochre": mask = (r > 150) & (r - b > 50) & (r - g > 15) else: mask = (b > r) & (lum > 60) & (lum < 190) sel = box[mask] return np.median(sel, axis=0) if len(sel) else None, len(sel) sun, n_sun = box_median(1230, 10, 1536, 210, "ochre") tulip, n_tulip = box_median(1030, 280, 1170, 560, "ochre") motif_petal = (sun + tulip) / 2.0 cloud1, n_c1 = box_median(20, 20, 380, 190, "blue") raincloud, n_rc = box_median(520, 10, 930, 230, "blue") wilted, n_wf = box_median(520, 280, 650, 580, "blue") bud, n_bud = box_median(10, 290, 110, 570, "blue") motif_leaf = (cloud1 + raincloud + wilted + bud) / 4.0 samples_report["motifPetal"] = { "sourceImage": "docs/avatar-art/art-direction-v3/p1/r2-b-linocut.png", "sun": {"box": [1230, 10, 1536, 210], "n": n_sun, "hex": hexc(sun)}, "tulip": {"box": [1030, 280, 1170, 560], "n": n_tulip, "hex": hexc(tulip)}, "hex": hexc(motif_petal), } samples_report["motifLeaf"] = { "sourceImage": "docs/avatar-art/art-direction-v3/p1/r2-b-linocut.png", "cloudLeft": {"box": [20, 20, 380, 190], "n": n_c1, "hex": hexc(cloud1)}, "raincloud": {"box": [520, 10, 930, 230], "n": n_rc, "hex": hexc(raincloud)}, "wiltedFlowerLeaf": {"box": [520, 280, 650, 580], "n": n_wf, "hex": hexc(wilted)}, "closedBudLeaf": {"box": [10, 290, 110, 570], "n": n_bud, "hex": hexc(bud)}, "hex": hexc(motif_leaf), } palette = { "ink": hexc(ink), "sclera": hexc(sclera), "iris": hexc(iris), "irisRing": hexc(iris_ring), "lipUpper": hexc(lip_upper), "lipLower": hexc(lip_lower), "mouthLine": hexc(mouth_line), "mouthInner": "#3B201B", "teeth": "#E9E0CF", "blush": "#C0624A", "tear": "#EEE5D3", "pallor": "#9AA3A6", "paper": "#EEE5D3", "motifPetal": hexc(motif_petal), "motifLeaf": hexc(motif_leaf), } return palette, samples_report _MOUTH_CENTER_CACHE: tuple[float, float] | None = None def compute_mouth_center_from_manifest(manifest: dict) -> tuple[float, float]: global _MOUTH_CENTER_CACHE if _MOUTH_CENTER_CACHE is None: raise SystemExit("[내부오류] mouthCenter가 먼저 계산되지 않았다.") return _MOUTH_CENTER_CACHE # --- landmarks 매핑(manifest 값을 이름만 바꿔 그대로 사용) ----------------- def build_landmarks(manifest: dict, mouth_center: tuple[float, float]) -> dict: lm = manifest["landmarks"] def pt(p) -> list[float]: return [round(p[0], 1), round(p[1], 1)] def eye(e) -> dict: return { "inner": pt(e["innerCorner"]), "outer": pt(e["outerCorner"]), "upperLidTop": pt(e["upperLidTop"]), "lowerLidBottom": pt(e["lowerLidBottom"]), "iris": {"center": pt(e["iris"]["center"]), "radius": round(e["iris"]["radius"], 1)}, } def brow(b) -> dict: return {"inner": pt(b["inner"]), "peak": pt(b["peak"]), "outer": pt(b["outer"])} return { "eyeLeft": eye(lm["eyeLeft"]), "eyeRight": eye(lm["eyeRight"]), "browLeft": brow(lm["eyebrowLeft"]), "browRight": brow(lm["eyebrowRight"]), "noseTip": pt(lm["noseTip"]), "mouthCornerLeft": pt(lm["mouthCornerLeft"]), "mouthCornerRight": pt(lm["mouthCornerRight"]), "upperLipTop": pt(lm["upperLipTopCenter"]), "lowerLipBottom": pt(lm["lowerLipBottomCenter"]), "mouthCenter": [round(mouth_center[0], 1), round(mouth_center[1], 1)], "chinTip": pt(lm["chinTip"]), } # --- crops.face 계산 -------------------------------------------------------- def compute_face_crop(face_oval: list[tuple[float, float]]) -> tuple[float, float, float, float]: xs = [p[0] for p in face_oval] ys = [p[1] for p in face_oval] minx, maxx = min(xs), max(xs) miny, maxy = min(ys), max(ys) bbox_h = maxy - miny side = bbox_h * 1.12 cx = (minx + maxx) / 2.0 cy = (miny + maxy) / 2.0 return (cx - side / 2.0, cy - side / 2.0, side, side) # --- TypeScript 파일 렌더링 -------------------------------------------------- def ts_num(x: float) -> str: r = round(x, 1) if r == int(r): return f"{int(r)}" return f"{r}" def ts_point(p) -> str: return f"[{ts_num(p[0])}, {ts_num(p[1])}]" def render_rig_ts(rig: dict) -> str: lines: list[str] = [] lines.append("/* 생성 파일 — docs/avatar-art/p1-linocut/scripts/export_rig.py 가 만든다. 손으로 고치지 않는다. */") lines.append('import type { LinocutRig } from "../linocutRig";') lines.append("") lines.append("export const P1_LINOCUT_RIG: LinocutRig = {") lines.append(f' schemaVersion: "{rig["schemaVersion"]}",') lines.append(f' persona: "{rig["persona"]}",') lines.append(f' canvas: {{ w: {rig["canvas"]["w"]}, h: {rig["canvas"]["h"]} }},') lines.append(" layers: {") for key in ("body", "head", "hairFront"): layer = rig["layers"][key] lines.append( f' {key}: {{ href: "{layer["href"]}", x: {ts_num(layer["x"])}, y: {ts_num(layer["y"])}, ' f'w: {ts_num(layer["w"])}, h: {ts_num(layer["h"])} }},' ) if "faceDetail" in rig["layers"]: layer = rig["layers"]["faceDetail"] lines.append( f' faceDetail: {{ href: "{layer["href"]}", x: {ts_num(layer["x"])}, y: {ts_num(layer["y"])}, ' f'w: {ts_num(layer["w"])}, h: {ts_num(layer["h"])} }},' ) lines.append(" },") if rig.get("grain") is not None: lines.append(f' grain: {{ href: "{rig["grain"]["href"]}", size: {rig["grain"]["size"]} }},') if rig.get("lipTexture") is not None: lip = rig["lipTexture"] lines.append(" lipTexture: {") for key in ("upper", "lower"): layer = lip[key] lines.append( f' {key}: {{ href: "{layer["href"]}", x: {ts_num(layer["x"])}, y: {ts_num(layer["y"])}, ' f'w: {ts_num(layer["w"])}, h: {ts_num(layer["h"])} }},' ) if lip.get("shadow") is not None: layer = lip["shadow"] lines.append( f' shadow: {{ href: "{layer["href"]}", x: {ts_num(layer["x"])}, y: {ts_num(layer["y"])}, ' f'w: {ts_num(layer["w"])}, h: {ts_num(layer["h"])} }},' ) lines.append(" },") if rig.get("jaw") is not None: jaw = rig["jaw"] lines.append(" jaw: {") for key in ("head", "detail"): layer = jaw[key] lines.append( f' {key}: {{ href: "{layer["href"]}", x: {ts_num(layer["x"])}, y: {ts_num(layer["y"])}, ' f'w: {ts_num(layer["w"])}, h: {ts_num(layer["h"])} }},' ) lines.append(" },") if rig.get("motif") is not None: motif = rig["motif"] bud = motif["bud"] weather = motif["weather"] lines.append(" motif: {") lines.append(" bud: {") lines.append(f' canvas: {{ w: {bud["canvas"]["w"]}, h: {bud["canvas"]["h"]} }},') lines.append(f' base: {ts_point(bud["base"])},') lines.append(f' closed: "{bud["closed"]}",') lines.append(f' half: "{bud["half"]}",') lines.append(f' open: "{bud["open"]}",') lines.append(f' droop: "{bud["droop"]}",') lines.append(" },") lines.append(" weather: {") lines.append(f' canvas: {{ w: {weather["canvas"]["w"]}, h: {weather["canvas"]["h"]} }},') lines.append(" sprites: {") for key in ("positive", "negative", "defensive", "cognitive", "energy"): lines.append(f' {key}: "{weather["sprites"][key]}",') lines.append(" },") lines.append(" },") lines.append(" },") lines.append(" pivots: {") lines.append(f' neck: {ts_point(rig["pivots"]["neck"])},') lines.append(f' body: {ts_point(rig["pivots"]["body"])},') lines.append(f' face: {ts_point(rig["pivots"]["face"])},') lines.append(" },") lines.append(" crops: {") for key in ("portrait", "bust", "face"): r = rig["crops"][key] lines.append(f" {key}: [{ts_num(r[0])}, {ts_num(r[1])}, {ts_num(r[2])}, {ts_num(r[3])}],") lines.append(" },") oval_str = ", ".join(ts_point(p) for p in rig["faceOval"]) lines.append(f" faceOval: [{oval_str}],") lines.append(" landmarks: {") lmk = rig["landmarks"] def emit_eye(name, e): lines.append(f" {name}: {{") lines.append(f' inner: {ts_point(e["inner"])},') lines.append(f' outer: {ts_point(e["outer"])},') lines.append(f' upperLidTop: {ts_point(e["upperLidTop"])},') lines.append(f' lowerLidBottom: {ts_point(e["lowerLidBottom"])},') lines.append(f' iris: {{ center: {ts_point(e["iris"]["center"])}, radius: {ts_num(e["iris"]["radius"])} }},') lines.append(" },") def emit_brow(name, b): lines.append( f' {name}: {{ inner: {ts_point(b["inner"])}, peak: {ts_point(b["peak"])}, outer: {ts_point(b["outer"])} }},' ) emit_eye("eyeLeft", lmk["eyeLeft"]) emit_eye("eyeRight", lmk["eyeRight"]) emit_brow("browLeft", lmk["browLeft"]) emit_brow("browRight", lmk["browRight"]) lines.append(f' noseTip: {ts_point(lmk["noseTip"])},') lines.append(f' mouthCornerLeft: {ts_point(lmk["mouthCornerLeft"])},') lines.append(f' mouthCornerRight: {ts_point(lmk["mouthCornerRight"])},') lines.append(f' upperLipTop: {ts_point(lmk["upperLipTop"])},') lines.append(f' lowerLipBottom: {ts_point(lmk["lowerLipBottom"])},') lines.append(f' mouthCenter: {ts_point(lmk["mouthCenter"])},') lines.append(f' chinTip: {ts_point(lmk["chinTip"])},') lines.append(" },") lines.append(" palette: {") for key, val in rig["palette"].items(): lines.append(f' {key}: "{val}",') lines.append(" },") lines.append(" backdrop: {") for key, val in rig["backdrop"].items(): lines.append(f' {key}: "{val}",') lines.append(" },") lines.append("};") lines.append("") return "\n".join(lines) # --- main ------------------------------------------------------------------- def main() -> int: manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) base_front_rgb = np.array(Image.open(BASE_DIR / "base-front.png").convert("RGB")) print("=== 얼굴 랜드마크 검출(base-front.png) ===") points = detect_face_landmarks(BASE_DIR / "base-front.png") face_oval = compute_face_oval(points) mouth_center = compute_mouth_center(points) global _MOUTH_CENTER_CACHE _MOUTH_CENTER_CACHE = mouth_center print(f"mouthCenter(13,14 중점) = {mouth_center}") fx0 = min(p[0] for p in face_oval); fx1 = max(p[0] for p in face_oval) fy0 = min(p[1] for p in face_oval); fy1 = max(p[1] for p in face_oval) print(f"faceOval bbox = [{fx0:.1f}, {fy0:.1f}, {fx1:.1f}, {fy1:.1f}] (w={fx1-fx0:.1f} h={fy1-fy0:.1f})") print("=== 팔레트 표본 ===") palette, palette_report = compute_palette(base_front_rgb, manifest) for k, v in palette.items(): print(f" {k} = {v}") landmarks = build_landmarks(manifest, mouth_center) face_crop = compute_face_crop(face_oval) print(f"crops.face = {[round(v,1) for v in face_crop]}") print("=== 레이어 게시 ===") layer_sources: dict[str, tuple[Path, str]] = {} for key, stem, href_stem, optional in LAYER_SPECS: src = resolve_layer_source(stem, optional) if src is not None: layer_sources[key] = (src, href_stem) print(f" {key} <- {src.relative_to(REPO_ROOT)}") elif not optional: raise SystemExit(f"[중단] 필수 레이어 {key} 소스가 없다.") else: print(f" {key} 없음(생략)") grain_source = resolve_grain_source() if grain_source is not None: print(f" grain <- {grain_source.relative_to(REPO_ROOT)}") else: print(" grain 없음(생략)") layers_report, grain_report, publish_meta = publish_layers(layer_sources, grain_source) print(f"인코딩: quality={publish_meta['encodeMeta']['quality']} scale={publish_meta['encodeMeta']['scale']}") total = 0 for name, size in publish_meta["fileSizes"].items(): print(f" {name}.webp = {size} bytes") total += size print(f"합계 = {total} bytes (예산 {SIZE_BUDGET_BYTES} bytes) {'OK' if total <= SIZE_BUDGET_BYTES else '[초과]'}") print("=== 입술 결 게시 ===") lip_texture_field, lip_publish_report = publish_lip_texture(manifest) if lip_texture_field is None: print(" lip-upper.png/lip-lower.png/lip-shadow.png 없음(생략) — lipTexture 없는 리그") print("=== 턱 조각 게시 ===") jaw_field, jaw_publish_report = publish_jaw_pieces(manifest, layers_report, publish_meta["encodeMeta"]) if jaw_field is None: print(" jaw-head.png/jaw-detail.png 없음(생략) — jaw 없는 리그") print("=== 모티프 게시 ===") motif_field, motif_publish_report = publish_motif() rig = { "schemaVersion": "vignette.avatar.v3.rig.v1", "persona": "P1", "canvas": {"w": manifest["canvas"]["w"], "h": manifest["canvas"]["h"]}, "layers": layers_report, "grain": grain_report, "lipTexture": lip_texture_field, "jaw": jaw_field, "motif": motif_field, "pivots": {"neck": [500, 990], "body": [502, 1566], "face": [490, 660]}, "crops": { "portrait": [0, 0, manifest["canvas"]["w"], manifest["canvas"]["h"]], "bust": [0, 40, 1005, 1005], "face": face_crop, }, "faceOval": [(round(x, 1), round(y, 1)) for x, y in face_oval], "landmarks": landmarks, "palette": palette, "backdrop": { "cognitive": "#ECE3D1", "positive": "#F1DEC2", "negative": "#DCE0E2", "defensive": "#E6DAD3", "energy": "#E2E0D0", }, } RIG_TS_PATH.parent.mkdir(parents=True, exist_ok=True) RIG_TS_PATH.write_text(render_rig_ts(rig), encoding="utf-8", newline="\n") print(f"저장: {RIG_TS_PATH.relative_to(REPO_ROOT)}") report = { "faceOvalBBox": [round(fx0, 1), round(fy0, 1), round(fx1, 1), round(fy1, 1)], "cropsFace": [round(v, 1) for v in face_crop], "mouthCenter": [round(mouth_center[0], 1), round(mouth_center[1], 1)], "palette": palette, "paletteSamples": palette_report, "publish": {"fileSizes": publish_meta["fileSizes"], "encodeMeta": publish_meta["encodeMeta"], "totalBytes": total}, "grain": grain_report, "lipTexturePublish": lip_publish_report, "jawPublish": jaw_publish_report, "motifPublish": motif_publish_report, "rig": rig, } report_path = ROOT / "preview" / "v2" / "export-rig-report.json" report_path.parent.mkdir(parents=True, exist_ok=True) report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") print(f"저장: {report_path.relative_to(REPO_ROOT)}") manifest["lipTexturePublish"] = lip_publish_report if lip_publish_report is not None and "webpVsPngMeanAbsDiff" in lip_publish_report: manifest.setdefault("lipTexture", {})["check4WebpVsPngMeanAbsDiff"] = lip_publish_report["webpVsPngMeanAbsDiff"] manifest["jawPublish"] = jaw_publish_report if jaw_publish_report is not None and "vsPublishedLayerAbsDiff" in jaw_publish_report: manifest.setdefault("jaw", {})["check1VsPublishedLayerAbsDiff"] = jaw_publish_report["vsPublishedLayerAbsDiff"] manifest["motifPublish"] = motif_publish_report MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") print(f"manifest.json 갱신(lipTexturePublish, jawPublish, motifPublish): {MANIFEST_PATH.relative_to(REPO_ROOT)}") return 0 if __name__ == "__main__": sys.exit(main())