133 lines
5.2 KiB
Python
133 lines
5.2 KiB
Python
"""按 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()
|