"""按 schema v3 清单确定性生成不透明长背景与 G01 暖金边框。""" import argparse import json from pathlib import Path from typing import Any from PIL import Image, PngImagePlugin def _resolve_inside(workspace: Path, relative_path: str) -> Path: """解析仓库相对路径,并在任何读写发生前拒绝目录逃逸。""" absolute = (workspace / relative_path).resolve() try: absolute.relative_to(workspace.resolve()) except ValueError as error: raise ValueError(f"path escapes workspace: {relative_path}") from error return absolute def build_opaque_resize(source: Image.Image, output_size: tuple[int, int]) -> Image.Image: """把同宽高比母版按 LANCZOS 直接缩放为无透明通道的 RGB 正式图。""" image = source.convert("RGB") if image.size != output_size: image = image.resize(output_size, Image.Resampling.LANCZOS) return image def build_opaque_cover_crop(source: Image.Image, output_size: tuple[int, int]) -> Image.Image: """等比 cover 后从中心裁切;算法与旧五模块构建器的可见像素完全一致。""" width, height = output_size image = source.convert("RGB") scale = max(width / image.width, height / image.height) resized = image.resize( (round(image.width * scale), round(image.height * scale)), Image.Resampling.LANCZOS, ) left = max(0, (resized.width - width) // 2) top = max(0, (resized.height - height) // 2) return resized.crop((left, top, left + width, top + height)) def extract_warm_gold_frame(source: Image.Image, config: dict[str, Any]) -> Image.Image: """复刻旧 Sharp 暖金边框阈值,同时清零全透明像素的隐藏 RGB。""" image = source.convert("RGBA") width, height = image.size border_band = int(config["borderBand"]) output = Image.new("RGBA", image.size, (0, 0, 0, 0)) result: list[tuple[int, int, int, int]] = [] for index, (red, green, blue, source_alpha) in enumerate(image.get_flattened_data()): x = index % width y = index // width distance_to_edge = min(x, y, width - 1 - x, height - 1 - y) warm_gold = ( red > green > blue and red - green >= int(config["redGreenMin"]) and green - blue >= int(config["greenBlueMin"]) and red - blue >= int(config["redBlueMin"]) and red < int(config["redMaxExclusive"]) and blue < int(config["blueMaxExclusive"]) ) if distance_to_edge >= border_band or not warm_gold: result.append((0, 0, 0, 0)) continue edge_alpha = max( 0, min( 255, (red - blue - int(config["alphaOffset"])) * int(config["alphaScale"]), ), ) alpha = min(source_alpha, edge_alpha) result.append((red, green, blue, alpha) if alpha else (0, 0, 0, 0)) output.putdata(result) return output def save_png(image: Image.Image, path: Path) -> None: """以固定压缩参数和标准 sRGB chunk 保存,保证同输入得到同字节。""" path.parent.mkdir(parents=True, exist_ok=True) png_info = PngImagePlugin.PngInfo() png_info.add(b"sRGB", b"\x00") image.save(path, format="PNG", optimize=True, compress_level=9, pnginfo=png_info) def build_manifest(manifest_path: Path, workspace: Path) -> None: """执行已经由 Node 严格校验的清单,并再次核对真实母版像素尺寸。""" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) for asset in manifest["assets"]: source_path = _resolve_inside(workspace, asset["source"]) output_path = _resolve_inside(workspace, asset["output"]) expected_source = asset["sourcePixels"] output_pixels = asset["outputPixels"] output_size = (int(output_pixels["width"]), int(output_pixels["height"])) with Image.open(source_path) as source: expected_size = (int(expected_source["width"]), int(expected_source["height"])) if source.size != expected_size: raise ValueError( f"{asset['id']} source expected {expected_size[0]}x{expected_size[1]}, " f"got {source.width}x{source.height}" ) mode = asset["processing"]["mode"] if mode == "opaque-resize": output = build_opaque_resize(source, output_size) elif mode == "opaque-cover-crop": output = build_opaque_cover_crop(source, output_size) elif mode == "warm-gold-frame-extract": output = extract_warm_gold_frame(source, asset["processing"]) else: raise ValueError(f"unsupported raster processing mode: {mode}") if output.size != output_size: raise ValueError(f"{asset['id']} produced unexpected output size: {output.size}") save_png(output, output_path) print(f"BUILT {asset['id']} -> {asset['output']}") 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()