"""从锁定的色键母版确定性生成项目共享卷轴资产。""" import argparse import json import math from pathlib import Path from PIL import Image, PngImagePlugin def remove_chroma_background( image: Image.Image, key: tuple[int, int, int], tolerance: int, feather: int = 72, ) -> Image.Image: source = image.convert("RGBA") cleaned = Image.new("RGBA", source.size, (0, 0, 0, 0)) result = [] key_red, key_green, key_blue = key for red, green, blue, source_alpha in source.get_flattened_data(): distance = math.sqrt( (red - key_red) ** 2 + (green - key_green) ** 2 + (blue - key_blue) ** 2 ) alpha_fraction = max(0.0, min(1.0, (distance - tolerance) / feather)) alpha_fraction *= source_alpha / 255 if alpha_fraction <= 0: result.append((0, 0, 0, 0)) continue recovered_red = round((red - (1 - alpha_fraction) * key_red) / alpha_fraction) recovered_green = round((green - (1 - alpha_fraction) * key_green) / alpha_fraction) recovered_blue = round((blue - (1 - alpha_fraction) * key_blue) / alpha_fraction) result.append( ( max(0, min(255, recovered_red)), max(0, min(255, recovered_green)), max(0, min(255, recovered_blue)), round(alpha_fraction * 255), ) ) cleaned.putdata(result) return cleaned def trim_transparent(image: Image.Image) -> Image.Image: alpha = image.getchannel("A") bounds = alpha.getbbox() if bounds is None: raise ValueError("chroma removal left no visible artwork") return image.crop(bounds) def stretch_safe_center( image: Image.Image, *, output_size: tuple[int, int], cap_width: int, padding: int, ) -> Image.Image: output_width, output_height = output_size inner_width = output_width - padding * 2 inner_height = output_height - padding * 2 if inner_width <= 0 or inner_height <= 0: raise ValueError("transparent padding leaves no drawable area") if cap_width <= 0 or cap_width * 2 >= image.width: raise ValueError(f"invalid capWidth {cap_width} for artwork width {image.width}") target_cap_width = max(1, round(cap_width * inner_height / image.height)) center_width = inner_width - target_cap_width * 2 if center_width <= 0: raise ValueError("scaled caps leave no room for the stretch-safe center") left = image.crop((0, 0, cap_width, image.height)) center = image.crop((cap_width, 0, image.width - cap_width, image.height)) right = image.crop((image.width - cap_width, 0, image.width, image.height)) resampling = Image.Resampling.LANCZOS left = left.resize((target_cap_width, inner_height), resampling) center = center.resize((center_width, inner_height), resampling) right = right.resize((target_cap_width, inner_height), resampling) output = Image.new("RGBA", output_size, (0, 0, 0, 0)) output.alpha_composite(left, (padding, padding)) output.alpha_composite(center, (padding + target_cap_width, padding)) output.alpha_composite(right, (output_width - padding - target_cap_width, padding)) return output def sanitize_output_edges(image: Image.Image) -> Image.Image: cleaned = [] for red, green, blue, alpha in image.convert("RGBA").get_flattened_data(): chroma_residue = alpha > 0 and green > 120 and green - max(red, blue) > 80 light_fringe = 0 < alpha < 255 and red > 235 and green > 235 and blue > 235 if alpha == 0 or chroma_residue or light_fringe: cleaned.append((0, 0, 0, 0)) else: cleaned.append((red, green, blue, alpha)) output = Image.new("RGBA", image.size, (0, 0, 0, 0)) output.putdata(cleaned) return output def reduce_png_palette(image: Image.Image, colors: int = 192) -> Image.Image: alpha = image.getchannel("A") rgb = image.convert("RGB").quantize( colors=colors, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE, ).convert("RGB") output = rgb.convert("RGBA") output.putalpha(alpha) return sanitize_output_edges(output) def parse_hex_color(value: str) -> tuple[int, int, int]: if len(value) != 7 or not value.startswith("#"): raise ValueError(f"invalid RGB hex color: {value}") return tuple(int(value[index:index + 2], 16) for index in (1, 3, 5)) def build_asset(asset: dict, workspace: Path) -> None: processing = asset["processing"] alpha = asset["alpha"] pixels = asset["outputPixels"] source_path = workspace / asset["source"] output_path = workspace / asset["output"] if processing["mode"] != "chroma-stretch": raise ValueError(f"unsupported scroll processing mode: {processing['mode']}") with Image.open(source_path) as source: expected = asset["sourcePixels"] if source.size != (expected["width"], expected["height"]): raise ValueError( f"{asset['id']} source expected {expected['width']}x{expected['height']}, " f"got {source.width}x{source.height}" ) cleaned = remove_chroma_background( source, parse_hex_color(processing["keyColor"]), int(processing["keyTolerance"]), ) artwork = trim_transparent(cleaned) output = stretch_safe_center( artwork, output_size=(pixels["width"], pixels["height"]), cap_width=int(processing["capWidth"]), padding=int(alpha["transparentOuterPadding"]), ) palette_colors = int(processing.get("paletteColors", 192)) output = sanitize_output_edges(output) if processing.get("indexedPng"): output = output.quantize( colors=palette_colors, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE, ) else: output = reduce_png_palette(output, colors=palette_colors) output_path.parent.mkdir(parents=True, exist_ok=True) png_info = PngImagePlugin.PngInfo() png_info.add(b"sRGB", b"\x00") output.save(output_path, format="PNG", optimize=True, pnginfo=png_info) print(f"BUILT {asset['id']} -> {asset['output']}") def build_manifest(manifest_path: Path, workspace: Path) -> None: manifest = json.loads(manifest_path.read_text(encoding="utf-8")) for asset in manifest["assets"]: build_asset(asset, workspace) def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("manifest", type=Path) parser.add_argument("--workspace", type=Path, required=True) args = parser.parse_args() build_manifest(args.manifest.resolve(), args.workspace.resolve()) if __name__ == "__main__": main()