from __future__ import annotations import json from datetime import datetime from pathlib import Path from typing import Callable import numpy as np from PIL import Image, ImageDraw, ImageFilter ROOT = Path(__file__).resolve().parents[1] ASSETS = ROOT / "03_Assets" LIVE2D = ASSETS / "Live2D" PARTS = ASSETS / "Parts" / "Images" REFERENCE = ASSETS / "Reference" MANIFEST = LIVE2D / "layer_manifest.json" OUT_BASE = LIVE2D / "LayerPNGs" PREVIEW = LIVE2D / "isabel_live2d_layer_preview.png" PREVIEW_CHECKER = LIVE2D / "isabel_live2d_layer_preview_checker.png" SWAP_PREVIEW_CHECKER = LIVE2D / "isabel_live2d_swap_parts_preview_checker.png" REPORT_JSON = LIVE2D / "layer_generation_report.json" REPORT_MD = LIVE2D / "LayerPNGs_README.md" SRC_W, SRC_H = 520, 900 OUT_W, OUT_H = 1600, 2800 SCALE = 3 OFFSET_X, OFFSET_Y = 20, 50 def load_rgba(path: Path) -> Image.Image: return Image.open(path).convert("RGBA") def blank(size: tuple[int, int] = (SRC_W, SRC_H)) -> Image.Image: return Image.new("RGBA", size, (0, 0, 0, 0)) def arr(img: Image.Image) -> np.ndarray: return np.array(img.convert("RGBA")) def alpha(img: Image.Image, min_alpha: int = 8) -> np.ndarray: return arr(img)[:, :, 3] > min_alpha def rect(x0: int, y0: int, x1: int, y1: int) -> np.ndarray: mask = np.zeros((SRC_H, SRC_W), dtype=bool) mask[max(0, y0) : min(SRC_H, y1), max(0, x0) : min(SRC_W, x1)] = True return mask def soften_mask(mask: np.ndarray, radius: float = 0.6) -> Image.Image: img = Image.fromarray((mask.astype(np.uint8) * 255), "L") if radius: img = img.filter(ImageFilter.GaussianBlur(radius)) return img def masked(src: Image.Image, mask: np.ndarray, radius: float = 0.45, opacity: float = 1.0) -> Image.Image: out = src.copy().convert("RGBA") source_alpha = out.getchannel("A") mask_img = soften_mask(mask, radius) if opacity != 1.0: mask_img = mask_img.point(lambda p: int(p * opacity)) new_alpha = Image.composite(source_alpha, Image.new("L", source_alpha.size, 0), mask_img) out.putalpha(new_alpha) return out def merge_source_layers(*imgs: Image.Image) -> Image.Image: out = blank() for img in imgs: out.alpha_composite(img.convert("RGBA")) return out def source_to_output(img: Image.Image) -> Image.Image: scaled = img.resize((SRC_W * SCALE, SRC_H * SCALE), Image.Resampling.LANCZOS) out = Image.new("RGBA", (OUT_W, OUT_H), (0, 0, 0, 0)) out.alpha_composite(scaled, (OFFSET_X, OFFSET_Y)) return out def fit_to_output(img: Image.Image, max_w: int, max_h: int, y: int) -> Image.Image: src = img.convert("RGBA") ratio = min(max_w / src.width, max_h / src.height) size = (int(src.width * ratio), int(src.height * ratio)) scaled = src.resize(size, Image.Resampling.LANCZOS) out = Image.new("RGBA", (OUT_W, OUT_H), (0, 0, 0, 0)) out.alpha_composite(scaled, ((OUT_W - size[0]) // 2, y)) return out def anti_alias_draw(draw_fn: Callable[[ImageDraw.ImageDraw, int], None]) -> Image.Image: factor = 4 img = Image.new("RGBA", (SRC_W * factor, SRC_H * factor), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) draw_fn(draw, factor) return img.resize((SRC_W, SRC_H), Image.Resampling.LANCZOS) def ellipse_layer(box: tuple[int, int, int, int], fill: tuple[int, int, int, int], blur: float = 0.0) -> Image.Image: def draw_fn(draw: ImageDraw.ImageDraw, f: int) -> None: draw.ellipse(tuple(v * f for v in box), fill=fill) img = anti_alias_draw(draw_fn) if blur: img = img.filter(ImageFilter.GaussianBlur(blur)) return img def line_layer( points: list[tuple[int, int]], fill: tuple[int, int, int, int], width: int = 2, joint: str = "curve", ) -> Image.Image: def draw_fn(draw: ImageDraw.ImageDraw, f: int) -> None: pts = [(x * f, y * f) for x, y in points] draw.line(pts, fill=fill, width=width * f, joint=joint) return anti_alias_draw(draw_fn) def polygon_layer(points: list[tuple[int, int]], fill: tuple[int, int, int, int]) -> Image.Image: def draw_fn(draw: ImageDraw.ImageDraw, f: int) -> None: draw.polygon([(x * f, y * f) for x, y in points], fill=fill) return anti_alias_draw(draw_fn) def face_underpaint_layer() -> Image.Image: base = merge_source_layers( ellipse_layer((196, 62, 324, 229), (238, 184, 156, 245), 0.3), polygon_layer([(210, 168), (310, 168), (289, 235), (260, 252), (231, 235)], (236, 178, 151, 245)), ellipse_layer((218, 80, 302, 198), (248, 199, 174, 125), 5.0), ) return base def draw_capsule(layer: Image.Image, p0: tuple[int, int], p1: tuple[int, int], width: int, fill: tuple[int, int, int, int]) -> None: draw = ImageDraw.Draw(layer) draw.line([p0, p1], fill=fill, width=width) r = width // 2 for x, y in (p0, p1): draw.ellipse((x - r, y - r, x + r, y + r), fill=fill) def body_limb_layer(kind: str) -> Image.Image: layer = blank() skin = (238, 184, 156, 220) if kind == "arm_upper_L": draw_capsule(layer, (354, 268), (390, 350), 28, skin) elif kind == "arm_fore_L": draw_capsule(layer, (386, 350), (404, 405), 23, skin) elif kind == "arm_upper_R": draw_capsule(layer, (166, 268), (128, 350), 28, skin) elif kind == "arm_fore_R": draw_capsule(layer, (134, 350), (116, 405), 23, skin) elif kind == "leg_upper_L": draw_capsule(layer, (294, 410), (298, 610), 46, skin) elif kind == "leg_lower_L": draw_capsule(layer, (292, 595), (287, 740), 34, skin) elif kind == "leg_upper_R": draw_capsule(layer, (226, 410), (222, 610), 46, skin) elif kind == "leg_lower_R": draw_capsule(layer, (228, 595), (233, 740), 34, skin) return layer.filter(ImageFilter.GaussianBlur(0.25)) def source_color_masks(sources: dict[str, Image.Image]) -> dict[str, np.ndarray]: masks: dict[str, np.ndarray] = {} for name, img in sources.items(): a = arr(img) r = a[:, :, 0].astype(np.int16) g = a[:, :, 1].astype(np.int16) b = a[:, :, 2].astype(np.int16) al = a[:, :, 3] > 8 masks[f"{name}:alpha"] = al masks[f"{name}:skin"] = al & (r > 125) & (g > 75) & (b > 55) & (r > g - 5) & (r > b + 10) masks[f"{name}:hair"] = al & (g > 65) & (b > 60) & (r < 135) & ((g - r) > 12) & ((b - r) > 3) masks[f"{name}:hair_hi"] = al & (g > 120) & (b > 105) & (r < 125) & ((g - r) > 30) masks[f"{name}:white"] = al & (r > 170) & (g > 170) & (b > 168) & (np.abs(r - g) < 45) & (np.abs(g - b) < 55) masks[f"{name}:black"] = al & (r < 90) & (g < 95) & (b < 100) masks[f"{name}:mint"] = al & (g > 115) & (b > 105) & (r < 165) & ((g - r) > 20) masks[f"{name}:dark_teal"] = al & (g > 55) & (b > 55) & (r < 85) & ((g - r) > 8) return masks def facial_layers() -> dict[str, Image.Image]: layers: dict[str, Image.Image] = {} eye_specs = { "L": {"cx": 294, "cy": 140, "tilt": -2}, "R": {"cx": 226, "cy": 140, "tilt": 2}, } for side, spec in eye_specs.items(): cx, cy = spec["cx"], spec["cy"] white = polygon_layer( [(cx - 22, cy), (cx - 14, cy - 8), (cx + 13, cy - 8), (cx + 22, cy - 1), (cx + 13, cy + 8), (cx - 13, cy + 7)], (255, 246, 236, 232), ) iris = ellipse_layer((cx - 8, cy - 10, cx + 8, cy + 10), (139, 89, 47, 240)) iris.alpha_composite(ellipse_layer((cx - 6, cy - 7, cx + 6, cy + 8), (180, 121, 62, 195))) pupil = ellipse_layer((cx - 4, cy - 6, cx + 4, cy + 6), (38, 26, 19, 240)) highlight = merge_source_layers( ellipse_layer((cx - 2, cy - 7, cx + 3, cy - 2), (255, 255, 255, 230)), ellipse_layer((cx + 4, cy + 1, cx + 6, cy + 3), (255, 255, 255, 170)), ) upper = line_layer([(cx - 23, cy - 3), (cx - 11, cy - 10), (cx + 10, cy - 10), (cx + 23, cy - 3)], (50, 28, 24, 235), 2) lower = line_layer([(cx - 19, cy + 6), (cx - 6, cy + 9), (cx + 11, cy + 8), (cx + 19, cy + 5)], (82, 44, 38, 165), 1) lid = line_layer([(cx - 20, cy - 13), (cx - 6, cy - 16), (cx + 11, cy - 15), (cx + 20, cy - 12)], (226, 160, 139, 125), 1) layers[f"eye_{side}_white"] = white layers[f"eye_{side}_iris"] = iris layers[f"eye_{side}_pupil"] = pupil layers[f"eye_{side}_highlight"] = highlight layers[f"eye_{side}_upper_lash"] = upper layers[f"eye_{side}_lower_lash"] = lower layers[f"eye_{side}_lid"] = lid layers["brow_L"] = line_layer([(274, 116), (288, 111), (310, 114)], (66, 55, 48, 210), 3) layers["brow_R"] = line_layer([(210, 114), (232, 111), (246, 116)], (66, 55, 48, 210), 3) layers["mouth_inside"] = ellipse_layer((249, 174, 271, 188), (85, 22, 28, 220)) layers["teeth_upper"] = polygon_layer([(252, 175), (268, 175), (266, 180), (254, 180)], (250, 245, 232, 220)) layers["teeth_lower"] = polygon_layer([(254, 184), (266, 184), (265, 187), (255, 187)], (242, 232, 220, 165)) layers["tongue"] = ellipse_layer((252, 181, 268, 190), (214, 98, 105, 195)) layers["mouth_line_upper"] = line_layer([(243, 174), (253, 171), (260, 173), (267, 171), (277, 174)], (94, 42, 36, 225), 2) layers["mouth_line_lower"] = line_layer([(251, 187), (260, 191), (269, 187)], (120, 65, 60, 145), 1) layers["lip_highlight"] = line_layer([(252, 170), (260, 168), (268, 170)], (255, 216, 205, 120), 1) layers["nose"] = merge_source_layers( line_layer([(260, 144), (257, 158), (261, 164)], (154, 91, 76, 135), 1), ellipse_layer((257, 161, 265, 167), (124, 70, 62, 70), 0.4), ) layers["cheek_L"] = ellipse_layer((288, 155, 323, 177), (255, 112, 130, 60), 2.5) layers["cheek_R"] = ellipse_layer((197, 155, 232, 177), (255, 112, 130, 60), 2.5) layers["face_shadow"] = merge_source_layers( ellipse_layer((201, 185, 318, 232), (140, 81, 65, 36), 4.0), ellipse_layer((220, 70, 300, 102), (190, 125, 100, 35), 3.0), ) return layers def string_and_accessory_primitives() -> dict[str, Image.Image]: layers: dict[str, Image.Image] = {} layers["hoodie_string_L"] = merge_source_layers( line_layer([(276, 265), (282, 305), (281, 356)], (54, 54, 54, 220), 2), line_layer([(281, 354), (283, 365)], (65, 207, 184, 230), 2), ) layers["hoodie_string_R"] = merge_source_layers( line_layer([(244, 265), (238, 305), (239, 356)], (54, 54, 54, 220), 2), line_layer([(239, 354), (237, 365)], (65, 207, 184, 230), 2), ) layers["choker_band_draw"] = merge_source_layers( line_layer([(218, 220), (245, 226), (275, 226), (302, 220)], (31, 28, 28, 235), 5), line_layer([(220, 217), (246, 222), (274, 222), (300, 217)], (72, 64, 63, 85), 1), ) layers["pendant_draw"] = merge_source_layers( ellipse_layer((253, 231, 267, 248), (38, 203, 188, 225)), ellipse_layer((257, 233, 262, 238), (180, 255, 247, 155)), line_layer([(260, 223), (260, 230)], (35, 35, 35, 230), 1), ) return layers def swap_layers(sources: dict[str, Image.Image]) -> dict[str, Image.Image]: layers: dict[str, Image.Image] = {} for side, hand_name, angle, xy in ( ("L", "hand_l", -32, (276, 292)), ("R", "hand_r", 32, (190, 292)), ): hand = sources[hand_name].crop(sources[hand_name].getbbox()) hand = hand.resize((64, 94), Image.Resampling.LANCZOS).rotate(angle, resample=Image.Resampling.BICUBIC, expand=True) layer = blank() layer.alpha_composite(hand, xy) layers[f"swap_hand_heart_{side}"] = layer sleeve_l = merge_source_layers(sources["upperarm_l"], sources["forearm_l"], sources["hand_l"]) sleeve_r = merge_source_layers(sources["upperarm_r"], sources["forearm_r"], sources["hand_r"]) for side, src, angle, xy in ( ("L", sleeve_l, -47, (220, 280)), ("R", sleeve_r, 47, (185, 276)), ): crop = src.crop(src.getbbox()) crop = crop.resize((190, 120), Image.Resampling.LANCZOS).rotate(angle, resample=Image.Resampling.BICUBIC, expand=True) layer = blank() layer.alpha_composite(crop, xy) layers[f"swap_arm_cross_{side}"] = layer return layers def build_source_layers() -> tuple[dict[str, Image.Image], dict[str, str]]: sources = { "master": load_rgba(PARTS / "isabel_part_master_apose.png"), "head": load_rgba(PARTS / "isabel_part_head.png"), "chest": load_rgba(PARTS / "isabel_part_chest.png"), "neck": load_rgba(PARTS / "isabel_part_neck.png"), "pelvis": load_rgba(PARTS / "isabel_part_pelvis.png"), "upperarm_l": load_rgba(PARTS / "isabel_part_upperarm_l.png"), "upperarm_r": load_rgba(PARTS / "isabel_part_upperarm_r.png"), "forearm_l": load_rgba(PARTS / "isabel_part_forearm_l.png"), "forearm_r": load_rgba(PARTS / "isabel_part_forearm_r.png"), "hand_l": load_rgba(PARTS / "isabel_part_hand_l.png"), "hand_r": load_rgba(PARTS / "isabel_part_hand_r.png"), "thigh_l": load_rgba(PARTS / "isabel_part_thigh_l.png"), "thigh_r": load_rgba(PARTS / "isabel_part_thigh_r.png"), "shin_l": load_rgba(PARTS / "isabel_part_shin_l.png"), "shin_r": load_rgba(PARTS / "isabel_part_shin_r.png"), "foot_l": load_rgba(PARTS / "isabel_part_foot_l.png"), "foot_r": load_rgba(PARTS / "isabel_part_foot_r.png"), } masks = source_color_masks(sources) layers: dict[str, Image.Image] = {} notes: dict[str, str] = {} head = sources["head"] chest = sources["chest"] neck = sources["neck"] pelvis = sources["pelvis"] # Hair split. L/R are from the character's point of view; L is screen right. hair = masks["head:hair"] | (alpha(head) & ~masks["head:skin"] & rect(120, 0, 400, 385)) layers["back_hair_base"] = masked(head, hair & rect(145, 45, 376, 370), 0.35, 0.9) layers["back_hair_shadow"] = masked(head, (hair & (masks["head:dark_teal"] | rect(145, 105, 376, 370))) & rect(145, 105, 376, 370), 0.45, 0.35) layers["back_hair_tip_L"] = masked(head, hair & rect(305, 170, 382, 370), 0.55) layers["back_hair_tip_R"] = masked(head, hair & rect(138, 170, 215, 370), 0.55) layers["back_hair_strand_L01"] = masked(head, hair & rect(325, 70, 382, 305), 0.5) layers["back_hair_strand_R01"] = masked(head, hair & rect(138, 70, 195, 305), 0.5) layers["front_hair_center"] = masked(head, hair & rect(205, 18, 315, 158), 0.35) layers["front_hair_L"] = masked(head, hair & rect(270, 28, 368, 190), 0.4) layers["front_hair_R"] = masked(head, hair & rect(152, 28, 250, 190), 0.4) layers["side_hair_L"] = masked(head, hair & rect(300, 115, 382, 350), 0.45) layers["side_hair_R"] = masked(head, hair & rect(138, 115, 220, 350), 0.45) layers["hair_highlight_front"] = masked(head, (masks["head:hair_hi"] | (hair & rect(160, 20, 365, 245))) & rect(160, 20, 365, 245), 0.8, 0.45) # Body and hidden under-paint. layers["neck_back_fill"] = merge_source_layers(masked(neck, masks["neck:skin"] | alpha(neck), 0.5, 0.55), body_limb_layer("leg_upper_L").crop((0, 0, 1, 1))) layers["neck_front"] = masked(neck, alpha(neck), 0.35) torso_mask = masks["chest:skin"] & rect(190, 240, 330, 402) layers["torso_skin"] = masked(chest, torso_mask, 0.6) for layer_id in ("arm_upper_L", "arm_fore_L", "arm_upper_R", "arm_fore_R", "leg_upper_L", "leg_lower_L", "leg_upper_R", "leg_lower_R"): layers[layer_id] = body_limb_layer(layer_id) layers["hand_L_base"] = masked(sources["hand_l"], alpha(sources["hand_l"]), 0.35) layers["hand_R_base"] = masked(sources["hand_r"], alpha(sources["hand_r"]), 0.35) # Clothes. white_chest = masks["chest:white"] | (alpha(chest) & ~masks["chest:skin"] & rect(150, 210, 370, 360)) jacket_chest = alpha(chest) & ~masks["chest:skin"] & ~(white_chest & rect(195, 230, 330, 350)) layers["hood_back"] = merge_source_layers(masked(chest, (white_chest | (alpha(chest) & ~masks["chest:skin"])) & rect(160, 222, 362, 292), 0.55, 0.45), polygon_layer([(178, 244), (232, 220), (288, 220), (342, 244), (315, 286), (205, 286)], (42, 45, 52, 72))) layers["hood_front_L"] = masked(chest, white_chest & rect(260, 225, 365, 325), 0.45) layers["hood_front_R"] = masked(chest, white_chest & rect(155, 225, 260, 325), 0.45) layers["jacket_body"] = masked(chest, jacket_chest, 0.45) layers["jacket_sleeve_L"] = merge_source_layers( masked(sources["upperarm_l"], alpha(sources["upperarm_l"]), 0.35), masked(sources["forearm_l"], alpha(sources["forearm_l"]), 0.35), ) layers["jacket_sleeve_R"] = merge_source_layers( masked(sources["upperarm_r"], alpha(sources["upperarm_r"]), 0.35), masked(sources["forearm_r"], alpha(sources["forearm_r"]), 0.35), ) layers["hoodie_front"] = masked(chest, white_chest & rect(198, 235, 324, 350), 0.4) layers["pants_base"] = merge_source_layers( masked(pelvis, alpha(pelvis), 0.35), masked(sources["thigh_l"], alpha(sources["thigh_l"]), 0.35), masked(sources["thigh_r"], alpha(sources["thigh_r"]), 0.35), masked(sources["shin_l"], alpha(sources["shin_l"]), 0.35), masked(sources["shin_r"], alpha(sources["shin_r"]), 0.35), ) layers["shoe_L"] = masked(sources["foot_l"], alpha(sources["foot_l"]), 0.35) layers["shoe_R"] = masked(sources["foot_r"], alpha(sources["foot_r"]), 0.35) # Face and Accessories from source masks plus primitives. face_mask = masks["head:skin"] & rect(185, 72, 335, 232) layers["face_base"] = merge_source_layers(face_underpaint_layer(), masked(head, face_mask, 0.5)) layers["ear_L"] = masked(head, masks["head:skin"] & rect(315, 105, 370, 210), 0.5) layers["ear_R"] = masked(head, masks["head:skin"] & rect(150, 105, 205, 210), 0.5) layers.update(facial_layers()) headphone_l = (masks["head:white"] | masks["head:mint"] | (alpha(head) & rect(322, 58, 376, 220))) & rect(310, 45, 382, 235) headphone_r = (masks["head:white"] | masks["head:mint"] | (alpha(head) & rect(145, 58, 198, 220))) & rect(138, 45, 210, 235) band = (masks["head:white"] | masks["head:mint"] | alpha(head)) & rect(195, 0, 330, 88) layers["headphone_band"] = masked(head, band, 0.45) layers["headphone_L"] = masked(head, headphone_l, 0.45) layers["headphone_R"] = masked(head, headphone_r, 0.45) primitive_Accessories = string_and_accessory_primitives() layers["hoodie_string_L"] = primitive_Accessories["hoodie_string_L"] layers["hoodie_string_R"] = primitive_Accessories["hoodie_string_R"] choker_src = masked(head, masks["head:black"] & rect(205, 205, 315, 240), 0.45) layers["choker_band"] = merge_source_layers(choker_src, primitive_Accessories["choker_band_draw"]) pendant_src = masked(head, (masks["head:mint"] | masks["head:white"]) & rect(242, 225, 280, 260), 0.45) layers["pendant"] = merge_source_layers(pendant_src, primitive_Accessories["pendant_draw"]) layers.update(swap_layers(sources)) for key in layers: notes[key] = "generated from existing Isabel A-pose assets and manifest mapping" return layers, notes def checker(size: tuple[int, int]) -> Image.Image: w, h = size img = Image.new("RGBA", size, (255, 255, 255, 255)) draw = ImageDraw.Draw(img) step = 40 for y in range(0, h, step): for x in range(0, w, step): if (x // step + y // step) % 2: draw.rectangle((x, y, x + step - 1, y + step - 1), fill=(220, 220, 220, 255)) return img def write_guides(manifest: dict) -> dict[str, Image.Image]: guide_sheet = fit_to_output(load_rgba(REFERENCE / "isabel_sheet.png"), 1520, 1140, 80) guide_apose = source_to_output(load_rgba(PARTS / "isabel_part_master_apose.png")) return { "guide_isabel_sheet": guide_sheet, "guide_apose_current": guide_apose, } def bbox_of(img: Image.Image) -> list[int] | None: box = img.getbbox() return list(box) if box else None def save_report(manifest: dict, layer_outputs: dict[str, Image.Image], notes: dict[str, str]) -> None: rows = [] missing: list[str] = [] nonempty_required = 0 for layer in manifest["layers"]: layer_id = layer["id"] file_rel = layer["file"] path = OUT_BASE / file_rel img = layer_outputs.get(layer_id) bbox = bbox_of(img) if img else None if not path.exists(): missing.append(file_rel) if layer.get("required") and bbox: nonempty_required += 1 rows.append( { "id": layer_id, "file": file_rel, "group": layer["group"], "required": bool(layer.get("required")), "import": bool(layer.get("import")), "exists": path.exists(), "size": list(img.size) if img else None, "bbox": bbox, "note": notes.get(layer_id, ""), } ) report = { "generatedAt": datetime.now().isoformat(timespec="seconds"), "canvas": {"width": OUT_W, "height": OUT_H}, "scaleFromSource": {"source": [SRC_W, SRC_H], "scale": SCALE, "offset": [OFFSET_X, OFFSET_Y]}, "layerCount": len(rows), "requiredLayerCount": sum(1 for layer in manifest["layers"] if layer.get("required")), "nonemptyRequiredLayerCount": nonempty_required, "missingFiles": missing, "rows": rows, "psdNote": "Layered PSD was not written in this environment. Use LayerPNGs in manifest order to assemble the Cubism import PSD.", } REPORT_JSON.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") lines = [ "# Live2D Layer PNG Bundle", "", f"- Generated: {report['generatedAt']}", f"- Canvas: {OUT_W}x{OUT_H}, transparent RGBA", f"- Layers: {report['layerCount']}", f"- Required non-empty: {nonempty_required}/{report['requiredLayerCount']}", "- PSD note: layered PSD was not written here; assemble these PNGs in manifest order in Photoshop/Clip Studio/Cubism workflow.", "", "## Files", "", "| Group | ID | File | Required | Non-empty |", "|---|---|---|---:|---:|", ] for row in rows: lines.append( f"| {row['group']} | `{row['id']}` | `{row['file']}` | {str(row['required']).lower()} | {str(row['bbox'] is not None).lower()} |" ) REPORT_MD.write_text("\n".join(lines) + "\n", encoding="utf-8") def main() -> None: manifest = json.loads(MANIFEST.read_text(encoding="utf-8-sig")) OUT_BASE.mkdir(parents=True, exist_ok=True) layer_outputs: dict[str, Image.Image] = {} notes: dict[str, str] = {} guide_layers = write_guides(manifest) source_layers, source_notes = build_source_layers() notes.update({key: "guide layer, not for Cubism import" for key in guide_layers}) notes.update(source_notes) for layer in manifest["layers"]: layer_id = layer["id"] rel = layer["file"] out_path = OUT_BASE / rel out_path.parent.mkdir(parents=True, exist_ok=True) if layer_id in guide_layers: out = guide_layers[layer_id] elif layer_id in source_layers: out = source_to_output(source_layers[layer_id]) else: raise KeyError(f"No generator for {layer_id}") out.save(out_path) layer_outputs[layer_id] = out composite = Image.new("RGBA", (OUT_W, OUT_H), (0, 0, 0, 0)) for layer in manifest["layers"]: if not layer.get("import"): continue if layer.get("group") == "SwapParts": continue composite.alpha_composite(layer_outputs[layer["id"]]) composite.save(PREVIEW) checker_bg = checker((OUT_W, OUT_H)) checker_bg.alpha_composite(composite) checker_bg.convert("RGB").save(PREVIEW_CHECKER) swap_composite = composite.copy() for layer in manifest["layers"]: if layer.get("group") == "SwapParts": swap_composite.alpha_composite(layer_outputs[layer["id"]]) swap_checker = checker((OUT_W, OUT_H)) swap_checker.alpha_composite(swap_composite) swap_checker.convert("RGB").save(SWAP_PREVIEW_CHECKER) save_report(manifest, layer_outputs, notes) print(f"wrote {len(layer_outputs)} layer PNGs to {OUT_BASE}") print(f"preview: {PREVIEW}") print(f"report: {REPORT_JSON}") if __name__ == "__main__": main()