Initial Dansori character workspace
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
from psd_tools import PSDImage
|
||||
from psd_tools.api.layers import PixelLayer
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LIVE2D = ROOT / "03_Assets" / "Live2D"
|
||||
LAYER_BASE = LIVE2D / "LayerPNGs"
|
||||
MANIFEST = LIVE2D / "layer_manifest.json"
|
||||
|
||||
MATERIAL_PSD = LIVE2D / "sori_live2d_material_separation.psd"
|
||||
IMPORT_PSD = LIVE2D / "sori_live2d_import.psd"
|
||||
|
||||
|
||||
def load_manifest() -> dict:
|
||||
return json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def make_psd(name: str, size: tuple[int, int]) -> PSDImage:
|
||||
psd = PSDImage.new("RGB", size, color=(0, 0, 0), depth=8)
|
||||
return psd
|
||||
|
||||
|
||||
def add_grouped_layer(psd: PSDImage, groups: OrderedDict[str, object], layer: dict, visible: bool) -> None:
|
||||
group_name = layer["group"]
|
||||
group = groups.get(group_name)
|
||||
if group is None:
|
||||
group = psd.create_group(name=group_name)
|
||||
groups[group_name] = group
|
||||
|
||||
path = LAYER_BASE / layer["file"]
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(path)
|
||||
|
||||
image = Image.open(path).convert("RGBA")
|
||||
pixel_layer = PixelLayer.frompil(image, parent=group, name=layer["id"], top=0, left=0)
|
||||
pixel_layer.visible = visible
|
||||
group.append(pixel_layer)
|
||||
|
||||
|
||||
def save_psd(path: Path, layers: list[dict], size: tuple[int, int], material: bool) -> None:
|
||||
psd = make_psd(path.stem, size)
|
||||
groups: OrderedDict[str, object] = OrderedDict()
|
||||
|
||||
for layer in layers:
|
||||
if not material and not layer.get("import", False):
|
||||
continue
|
||||
visible = layer.get("group") not in ("Guide", "SwapParts")
|
||||
add_grouped_layer(psd, groups, layer, visible)
|
||||
|
||||
psd.save(path)
|
||||
reopened = PSDImage.open(path)
|
||||
if reopened.size != size:
|
||||
raise RuntimeError(f"Unexpected PSD size for {path}: {reopened.size}")
|
||||
if not list(reopened.descendants()):
|
||||
raise RuntimeError(f"No layers saved in {path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
manifest = load_manifest()
|
||||
canvas = manifest["canvas"]
|
||||
size = (int(canvas["width"]), int(canvas["height"]))
|
||||
layers = manifest["layers"]
|
||||
|
||||
save_psd(MATERIAL_PSD, layers, size, material=True)
|
||||
save_psd(IMPORT_PSD, layers, size, material=False)
|
||||
print(MATERIAL_PSD)
|
||||
print(IMPORT_PSD)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,577 @@
|
||||
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 / "sori_live2d_layer_preview.png"
|
||||
PREVIEW_CHECKER = LIVE2D / "sori_live2d_layer_preview_checker.png"
|
||||
SWAP_PREVIEW_CHECKER = LIVE2D / "sori_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((198, 58, 322, 198), (238, 184, 156, 190), 0.3),
|
||||
ellipse_layer((218, 80, 302, 198), (248, 199, 174, 95), 5.0),
|
||||
)
|
||||
return base
|
||||
|
||||
|
||||
def ear_underpaint_layer(side: str) -> Image.Image:
|
||||
if side == "L":
|
||||
base_box = (328, 104, 365, 178)
|
||||
inner_box = (338, 122, 356, 162)
|
||||
else:
|
||||
base_box = (155, 104, 192, 178)
|
||||
inner_box = (164, 122, 182, 162)
|
||||
return merge_source_layers(
|
||||
ellipse_layer(base_box, (236, 181, 154, 210), 0.45),
|
||||
ellipse_layer(inner_box, (207, 132, 118, 85), 1.2),
|
||||
)
|
||||
|
||||
|
||||
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":
|
||||
layer.putpixel((1, 1), (238, 184, 156, 1))
|
||||
elif kind == "arm_fore_L":
|
||||
layer.putpixel((2, 1), (238, 184, 156, 1))
|
||||
elif kind == "arm_upper_R":
|
||||
layer.putpixel((3, 1), (238, 184, 156, 1))
|
||||
elif kind == "arm_fore_R":
|
||||
layer.putpixel((4, 1), (238, 184, 156, 1))
|
||||
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 source_facial_layers(head: Image.Image, masks: dict[str, np.ndarray]) -> dict[str, Image.Image]:
|
||||
layers: dict[str, Image.Image] = {}
|
||||
head_alpha = alpha(head)
|
||||
dark = masks["head:black"] | masks["head:dark_teal"] | masks["head:hair"]
|
||||
warm = masks["head:skin"]
|
||||
|
||||
eye_regions = {
|
||||
"L": rect(260, 95, 330, 165),
|
||||
"R": rect(190, 95, 260, 165),
|
||||
}
|
||||
for side, region in eye_regions.items():
|
||||
full_eye = head_alpha & region & ~warm
|
||||
layers[f"eye_{side}_white"] = masked(head, full_eye, 0.35)
|
||||
layers[f"eye_{side}_iris"] = masked(head, full_eye & ~masks["head:white"] & ~dark, 0.25)
|
||||
layers[f"eye_{side}_pupil"] = masked(head, dark & region, 0.25)
|
||||
layers[f"eye_{side}_highlight"] = masked(head, masks["head:white"] & region, 0.25, 0.75)
|
||||
layers[f"eye_{side}_upper_lash"] = masked(head, dark & region & rect(0, 95, SRC_W, 135), 0.2)
|
||||
layers[f"eye_{side}_lower_lash"] = masked(head, dark & region & rect(0, 130, SRC_W, 165), 0.2)
|
||||
layers[f"eye_{side}_lid"] = masked(head, warm & region & rect(0, 95, SRC_W, 120), 0.25, 0.35)
|
||||
|
||||
layers["brow_L"] = masked(head, dark & rect(260, 78, 330, 112), 0.25)
|
||||
layers["brow_R"] = masked(head, dark & rect(190, 78, 260, 112), 0.25)
|
||||
|
||||
mouth_region = rect(228, 154, 292, 192)
|
||||
mouth = head_alpha & mouth_region & ~warm
|
||||
layers["mouth_inside"] = masked(head, mouth, 0.25)
|
||||
layers["teeth_upper"] = masked(head, masks["head:white"] & mouth_region, 0.2)
|
||||
layers["teeth_lower"] = masked(head, masks["head:white"] & mouth_region, 0.2, 0.5)
|
||||
layers["tongue"] = masked(head, mouth & ~dark & ~masks["head:white"], 0.2)
|
||||
layers["mouth_line_upper"] = masked(head, mouth | (dark & mouth_region), 0.2)
|
||||
layers["mouth_line_lower"] = masked(head, mouth | (dark & mouth_region), 0.2, 0.45)
|
||||
layers["lip_highlight"] = masked(head, masks["head:white"] & mouth_region, 0.2, 0.35)
|
||||
|
||||
layers["nose"] = masked(head, (dark | warm) & rect(242, 128, 278, 168), 0.25, 0.55)
|
||||
layers["cheek_L"] = ellipse_layer((288, 155, 323, 177), (255, 112, 130, 38), 2.5)
|
||||
layers["cheek_R"] = ellipse_layer((197, 155, 232, 177), (255, 112, 130, 38), 2.5)
|
||||
layers["face_shadow"] = masked(head, warm & rect(190, 65, 330, 220), 1.0, 0.14)
|
||||
return layers
|
||||
|
||||
|
||||
def string_and_accessory_primitives() -> dict[str, Image.Image]:
|
||||
layers: dict[str, Image.Image] = {}
|
||||
layers["hoodie_string_L"] = line_layer([(276, 272), (280, 315), (279, 350)], (54, 54, 54, 120), 1)
|
||||
layers["hoodie_string_R"] = line_layer([(244, 272), (240, 315), (241, 350)], (54, 54, 54, 120), 1)
|
||||
layers["choker_band_draw"] = blank()
|
||||
layers["pendant_draw"] = blank()
|
||||
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 / "sori_part_master_apose.png"),
|
||||
"head": load_rgba(PARTS / "sori_part_head.png"),
|
||||
"chest": load_rgba(PARTS / "sori_part_chest.png"),
|
||||
"neck": load_rgba(PARTS / "sori_part_neck.png"),
|
||||
"pelvis": load_rgba(PARTS / "sori_part_pelvis.png"),
|
||||
"upperarm_l": load_rgba(PARTS / "sori_part_upperarm_l.png"),
|
||||
"upperarm_r": load_rgba(PARTS / "sori_part_upperarm_r.png"),
|
||||
"forearm_l": load_rgba(PARTS / "sori_part_forearm_l.png"),
|
||||
"forearm_r": load_rgba(PARTS / "sori_part_forearm_r.png"),
|
||||
"hand_l": load_rgba(PARTS / "sori_part_hand_l.png"),
|
||||
"hand_r": load_rgba(PARTS / "sori_part_hand_r.png"),
|
||||
"thigh_l": load_rgba(PARTS / "sori_part_thigh_l.png"),
|
||||
"thigh_r": load_rgba(PARTS / "sori_part_thigh_r.png"),
|
||||
"shin_l": load_rgba(PARTS / "sori_part_shin_l.png"),
|
||||
"shin_r": load_rgba(PARTS / "sori_part_shin_r.png"),
|
||||
"foot_l": load_rgba(PARTS / "sori_part_foot_l.png"),
|
||||
"foot_r": load_rgba(PARTS / "sori_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"]
|
||||
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), 0.45, 0.75)
|
||||
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"] & rect(160, 20, 365, 245), 0.8, 0.62)
|
||||
|
||||
# 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(205, 245, 315, 355) | rect(210, 342, 310, 425))
|
||||
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"]
|
||||
jacket_chest = alpha(chest) & ~masks["chest:skin"] & ~(white_chest & rect(195, 230, 330, 350))
|
||||
layers["hood_back"] = masked(chest, white_chest & rect(160, 222, 362, 292), 0.55, 0.65)
|
||||
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, 58, 335, 198)
|
||||
layers["face_base"] = merge_source_layers(face_underpaint_layer(), masked(head, face_mask, 0.5))
|
||||
layers["ear_L"] = merge_source_layers(ear_underpaint_layer("L"), masked(head, masks["head:skin"] & rect(315, 90, 382, 210), 0.5))
|
||||
layers["ear_R"] = merge_source_layers(ear_underpaint_layer("R"), masked(head, masks["head:skin"] & rect(138, 90, 205, 210), 0.5))
|
||||
layers.update(source_facial_layers(head, masks))
|
||||
|
||||
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 LeeSori 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 / "sori_sheet.png"), 1520, 1140, 80)
|
||||
guide_apose = source_to_output(load_rgba(PARTS / "sori_part_master_apose.png"))
|
||||
return {
|
||||
"guide_sori_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"))
|
||||
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"]])
|
||||
preview_composite = source_to_output(load_rgba(PARTS / "sori_part_master_apose.png"))
|
||||
preview_composite.save(PREVIEW)
|
||||
checker_bg = checker((OUT_W, OUT_H))
|
||||
checker_bg.alpha_composite(preview_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()
|
||||
@@ -0,0 +1,44 @@
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SRC = ROOT / "03_Assets" / "Parts" / "Images"
|
||||
OUT = ROOT / "03_Assets" / "Live2D" / "_parts_contact_sheet.png"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
files = sorted(p for p in SRC.glob("*.png") if p.name != "sori_part_master_apose.png")
|
||||
thumb_w, thumb_h = 220, 380
|
||||
label_h = 34
|
||||
cols = 4
|
||||
rows = (len(files) + cols - 1) // cols
|
||||
sheet = Image.new("RGBA", (cols * thumb_w, rows * (thumb_h + label_h)), (32, 32, 32, 255))
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
font = ImageFont.load_default()
|
||||
|
||||
for i, path in enumerate(files):
|
||||
img = Image.open(path).convert("RGBA")
|
||||
img.thumbnail((thumb_w - 16, thumb_h - 16), Image.Resampling.LANCZOS)
|
||||
col = i % cols
|
||||
row = i // cols
|
||||
x = col * thumb_w + (thumb_w - img.width) // 2
|
||||
y = row * (thumb_h + label_h) + 8
|
||||
checker = Image.new("RGBA", (thumb_w, thumb_h), (255, 255, 255, 255))
|
||||
cd = ImageDraw.Draw(checker)
|
||||
for yy in range(0, thumb_h, 20):
|
||||
for xx in range(0, thumb_w, 20):
|
||||
if (xx // 20 + yy // 20) % 2:
|
||||
cd.rectangle((xx, yy, xx + 19, yy + 19), fill=(218, 218, 218, 255))
|
||||
sheet.alpha_composite(checker, (col * thumb_w, row * (thumb_h + label_h)))
|
||||
sheet.alpha_composite(img, (x, y))
|
||||
draw.text((col * thumb_w + 8, row * (thumb_h + label_h) + thumb_h + 8), path.stem, fill=(255, 255, 255, 255), font=font)
|
||||
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
sheet.convert("RGB").save(OUT)
|
||||
print(OUT)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
REFERENCE = ROOT / "03_Assets" / "Reference"
|
||||
PARTS = ROOT / "03_Assets" / "Parts" / "Images"
|
||||
RAW = REFERENCE / "sori_generated_apose_raw.png"
|
||||
|
||||
SRC_W, SRC_H = 520, 900
|
||||
|
||||
|
||||
def load_source() -> Image.Image:
|
||||
return Image.open(RAW).convert("RGBA")
|
||||
|
||||
|
||||
def checker_background_mask(img: Image.Image) -> np.ndarray:
|
||||
arr = np.array(img.convert("RGBA"))
|
||||
rgb = arr[:, :, :3].astype(np.int16)
|
||||
alpha = arr[:, :, 3]
|
||||
light_gray = (rgb.min(axis=2) > 218) & ((rgb.max(axis=2) - rgb.min(axis=2)) < 18)
|
||||
transparent = alpha < 8
|
||||
candidate = light_gray | transparent
|
||||
|
||||
h, w = candidate.shape
|
||||
seen = np.zeros_like(candidate, dtype=bool)
|
||||
q: deque[tuple[int, int]] = deque()
|
||||
for x in range(w):
|
||||
if candidate[0, x]:
|
||||
q.append((x, 0))
|
||||
if candidate[h - 1, x]:
|
||||
q.append((x, h - 1))
|
||||
for y in range(h):
|
||||
if candidate[y, 0]:
|
||||
q.append((0, y))
|
||||
if candidate[y, w - 1]:
|
||||
q.append((w - 1, y))
|
||||
|
||||
while q:
|
||||
x, y = q.popleft()
|
||||
if seen[y, x] or not candidate[y, x]:
|
||||
continue
|
||||
seen[y, x] = True
|
||||
if x > 0:
|
||||
q.append((x - 1, y))
|
||||
if x < w - 1:
|
||||
q.append((x + 1, y))
|
||||
if y > 0:
|
||||
q.append((x, y - 1))
|
||||
if y < h - 1:
|
||||
q.append((x, y + 1))
|
||||
return seen
|
||||
|
||||
|
||||
def remove_checker_background(img: Image.Image) -> Image.Image:
|
||||
bg = checker_background_mask(img)
|
||||
alpha = Image.fromarray((~bg).astype(np.uint8) * 255, "L")
|
||||
alpha = alpha.filter(ImageFilter.GaussianBlur(0.65)).point(lambda p: 0 if p < 28 else min(255, int(p * 1.18)))
|
||||
out = img.copy()
|
||||
out.putalpha(alpha)
|
||||
return out
|
||||
|
||||
|
||||
def normalize_canvas(img: Image.Image) -> Image.Image:
|
||||
bbox = img.getbbox()
|
||||
if bbox is None:
|
||||
raise RuntimeError("source image has no visible pixels")
|
||||
crop = img.crop(bbox)
|
||||
ratio = min(430 / crop.width, 850 / crop.height)
|
||||
resized = crop.resize((round(crop.width * ratio), round(crop.height * ratio)), Image.Resampling.LANCZOS)
|
||||
canvas = Image.new("RGBA", (SRC_W, SRC_H), (0, 0, 0, 0))
|
||||
canvas.alpha_composite(resized, ((SRC_W - resized.width) // 2, 24))
|
||||
return canvas
|
||||
|
||||
|
||||
def save_part(master: Image.Image, name: str, box: tuple[int, int, int, int]) -> None:
|
||||
mask = Image.new("L", master.size, 0)
|
||||
part_alpha = master.getchannel("A").crop(box)
|
||||
mask.paste(part_alpha, box)
|
||||
out = master.copy()
|
||||
out.putalpha(mask.filter(ImageFilter.GaussianBlur(0.25)))
|
||||
out.save(PARTS / name)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
PARTS.mkdir(parents=True, exist_ok=True)
|
||||
master = normalize_canvas(remove_checker_background(load_source()))
|
||||
master.save(PARTS / "sori_part_master_apose.png")
|
||||
|
||||
# Coarse Live2D source parts. Boxes are in the normalized 520x900 master coordinate system.
|
||||
boxes = {
|
||||
"sori_part_head.png": (135, 8, 385, 255),
|
||||
"sori_part_neck.png": (215, 220, 305, 295),
|
||||
"sori_part_chest.png": (112, 212, 408, 435),
|
||||
"sori_part_pelvis.png": (145, 385, 375, 505),
|
||||
"sori_part_upperarm_l.png": (342, 250, 465, 452),
|
||||
"sori_part_forearm_l.png": (370, 360, 500, 535),
|
||||
"sori_part_hand_l.png": (382, 428, 520, 590),
|
||||
"sori_part_upperarm_r.png": (55, 250, 178, 452),
|
||||
"sori_part_forearm_r.png": (20, 360, 150, 535),
|
||||
"sori_part_hand_r.png": (0, 428, 138, 590),
|
||||
"sori_part_thigh_l.png": (260, 438, 385, 675),
|
||||
"sori_part_thigh_r.png": (135, 438, 260, 675),
|
||||
"sori_part_shin_l.png": (262, 640, 370, 825),
|
||||
"sori_part_shin_r.png": (150, 640, 258, 825),
|
||||
"sori_part_foot_l.png": (258, 780, 375, 900),
|
||||
"sori_part_foot_r.png": (145, 780, 262, 900),
|
||||
}
|
||||
for name, box in boxes.items():
|
||||
save_part(master, name, box)
|
||||
print(PARTS / "sori_part_master_apose.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
MANIFEST = ROOT / "03_Assets" / "Live2D" / "layer_manifest.json"
|
||||
OUT = ROOT / "03_Assets" / "Live2D" / "photoshop_assemble_live2d_psd.jsx"
|
||||
|
||||
|
||||
def js_string(value: str) -> str:
|
||||
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def main() -> None:
|
||||
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||
layers = manifest["layers"]
|
||||
layer_rows = []
|
||||
for layer in layers:
|
||||
layer_rows.append(
|
||||
"{id:%s,file:%s,group:%s,importLayer:%s,guide:%s}"
|
||||
% (
|
||||
js_string(layer["id"]),
|
||||
js_string(layer["file"].replace("\\", "/")),
|
||||
js_string(layer["group"]),
|
||||
"true" if layer.get("import") else "false",
|
||||
"true" if layer["group"] == "Guide" else "false",
|
||||
)
|
||||
)
|
||||
|
||||
jsx = f"""#target photoshop
|
||||
app.displayDialogs = DialogModes.NO;
|
||||
|
||||
var CANVAS_W = 1600;
|
||||
var CANVAS_H = 2800;
|
||||
var LAYERS = [
|
||||
{', '.join(layer_rows)}
|
||||
];
|
||||
|
||||
function requireFolder(path) {{
|
||||
var f = new Folder(path);
|
||||
if (!f.exists) {{
|
||||
throw new Error("Missing folder: " + path);
|
||||
}}
|
||||
return f;
|
||||
}}
|
||||
|
||||
function copyPngIntoDoc(doc, pngFile, layerName, visible) {{
|
||||
var src = app.open(pngFile);
|
||||
src.selection.selectAll();
|
||||
src.selection.copy();
|
||||
src.close(SaveOptions.DONOTSAVECHANGES);
|
||||
app.activeDocument = doc;
|
||||
doc.paste();
|
||||
doc.activeLayer.name = layerName;
|
||||
doc.activeLayer.visible = visible;
|
||||
}}
|
||||
|
||||
function makeDoc(name) {{
|
||||
return app.documents.add(
|
||||
UnitValue(CANVAS_W, "px"),
|
||||
UnitValue(CANVAS_H, "px"),
|
||||
72,
|
||||
name,
|
||||
NewDocumentMode.RGB,
|
||||
DocumentFill.TRANSPARENT,
|
||||
1,
|
||||
BitsPerChannelType.EIGHT,
|
||||
"sRGB IEC61966-2.1"
|
||||
);
|
||||
}}
|
||||
|
||||
function savePsd(doc, outFile) {{
|
||||
app.activeDocument = doc;
|
||||
var opts = new PhotoshopSaveOptions();
|
||||
opts.layers = true;
|
||||
opts.embedColorProfile = true;
|
||||
opts.alphaChannels = true;
|
||||
doc.saveAs(outFile, opts, true, Extension.LOWERCASE);
|
||||
}}
|
||||
|
||||
var repo = Folder.selectDialog("Select LeeSori_Live2D project folder");
|
||||
if (repo == null) {{
|
||||
throw new Error("Cancelled");
|
||||
}}
|
||||
|
||||
var layerBase = requireFolder(repo.fsName + "/03_Assets/Live2D/LayerPNGs");
|
||||
var live2dBase = requireFolder(repo.fsName + "/03_Assets/Live2D");
|
||||
|
||||
var materialDoc = makeDoc("sori_live2d_material_separation");
|
||||
for (var i = 0; i < LAYERS.length; i++) {{
|
||||
var layer = LAYERS[i];
|
||||
var file = new File(layerBase.fsName + "/" + layer.file);
|
||||
if (!file.exists) {{
|
||||
throw new Error("Missing PNG: " + file.fsName);
|
||||
}}
|
||||
copyPngIntoDoc(materialDoc, file, layer.id, !layer.guide && layer.group != "SwapParts");
|
||||
}}
|
||||
savePsd(materialDoc, new File(live2dBase.fsName + "/sori_live2d_material_separation.psd"));
|
||||
|
||||
var importDoc = makeDoc("sori_live2d_import");
|
||||
for (var j = 0; j < LAYERS.length; j++) {{
|
||||
var importLayer = LAYERS[j];
|
||||
if (!importLayer.importLayer) {{
|
||||
continue;
|
||||
}}
|
||||
var importFile = new File(layerBase.fsName + "/" + importLayer.file);
|
||||
if (!importFile.exists) {{
|
||||
throw new Error("Missing PNG: " + importFile.fsName);
|
||||
}}
|
||||
copyPngIntoDoc(importDoc, importFile, importLayer.id, importLayer.group != "SwapParts");
|
||||
}}
|
||||
savePsd(importDoc, new File(live2dBase.fsName + "/sori_live2d_import.psd"));
|
||||
|
||||
alert("Saved Live2D PSD files in " + live2dBase.fsName);
|
||||
"""
|
||||
OUT.write_text(jsx, encoding="utf-8")
|
||||
print(OUT)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user