Files
aetherbound-guild/tools/process_fg2_media.py
T

271 lines
10 KiB
Python

#!/usr/bin/env python3
"""Build the bounded FG-2 runtime art package from retained source media."""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
MEDIA_ROOT = ROOT / "docs/runtime/fg2/media"
RUNTIME_ROOT = ROOT / "runtime/assets/fg2"
ENVIRONMENT_SOURCE = MEDIA_ROOT / "raw/windglass-layers.png"
TARGET_SIZES = ((380, 180), (422, 195), (320, 180))
LAYER_NAMES = ("background", "midground", "foreground")
PALETTE_SIZE = 32
ACTOR_CELL_SIZE = 72
ACTOR_MARGIN = 4
ACTORS = (
{
"identity_id": "PF-B01",
"slug": "aegis",
"state": "brace_knock",
"fps": 10,
"loop": False,
"play_size": [36, 36],
"pivot": [18, 34],
"provider_task_id": "task_01M04FHH85F3MFKC5NWWSZXDV8",
"raw": "raw/aegis-brace-knock-r2.png",
},
{
"identity_id": "EN-R1-09",
"slug": "crosswind_scribe",
"state": "draw_release",
"fps": 9,
"loop": False,
"play_size": [36, 36],
"pivot": [18, 34],
"provider_task_id": "task_01M04FDDTANDVT4FC1RG9BG76F",
"raw": "raw/enemy-identities.png",
},
{
"identity_id": "EN-R1-08",
"slug": "glassroot_sprout",
"state": "growth",
"fps": 8,
"loop": False,
"play_size": [36, 36],
"pivot": [18, 34],
"provider_task_id": "task_01M04FDDTANDVT4FC1RG9BG76F",
"raw": "raw/enemy-identities.png",
},
)
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def remove_magenta(image: Image.Image) -> Image.Image:
rgba = image.convert("RGBA")
cleaned = []
for red, green, blue, alpha in rgba.getdata():
magenta = (
red >= 140
and blue >= 140
and green <= 165
and min(red, blue) - green >= 45
and abs(red - blue) <= 110
)
cleaned.append((red, green, blue, 0 if magenta else alpha))
rgba.putdata(cleaned)
return rgba
def crop_visible(image: Image.Image) -> Image.Image:
bounds = image.getchannel("A").getbbox()
if bounds is None:
raise ValueError("FG-2 source layer has no non-key pixels")
return image.crop(bounds)
def resized_cover(image: Image.Image, size: tuple[int, int]) -> Image.Image:
scale = max(size[0] / image.width, size[1] / image.height)
resized = image.resize(
(max(1, round(image.width * scale)), max(1, round(image.height * scale))),
Image.Resampling.BOX,
)
left = max(0, (resized.width - size[0]) // 2)
top = max(0, (resized.height - size[1]) // 2)
return resized.crop((left, top, left + size[0], top + size[1]))
def resized_contain(image: Image.Image, size: tuple[int, int]) -> Image.Image:
scale = min(size[0] / image.width, size[1] / image.height)
return image.resize(
(max(1, round(image.width * scale)), max(1, round(image.height * scale))),
Image.Resampling.BOX,
)
def build_base_layers(size: tuple[int, int], bands: list[Image.Image]) -> dict[str, Image.Image]:
base_size = (size[0] // 2, (size[1] + 1) // 2)
background = Image.new("RGBA", base_size, (10, 22, 43, 255))
background.alpha_composite(resized_cover(bands[0], base_size))
midground = Image.new("RGBA", base_size, (0, 0, 0, 0))
middle = resized_contain(bands[1], base_size)
midground.alpha_composite(middle, ((base_size[0] - middle.width) // 2, base_size[1] - middle.height))
foreground = Image.new("RGBA", base_size, (0, 0, 0, 0))
front = resized_contain(bands[2], base_size)
foreground.alpha_composite(front, ((base_size[0] - front.width) // 2, base_size[1] - front.height))
return {"background": background, "midground": midground, "foreground": foreground}
def make_shared_palette(layers: dict[str, Image.Image]) -> Image.Image:
samples: list[tuple[int, int, int]] = []
for layer in layers.values():
samples.extend((r, g, b) for r, g, b, a in layer.getdata() if a > 0)
stride = max(1, len(samples) // 100000)
selected = samples[::stride]
source = Image.new("RGB", (len(selected), 1))
source.putdata(selected)
return source.quantize(colors=PALETTE_SIZE, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE)
def apply_palette(image: Image.Image, palette: Image.Image) -> Image.Image:
alpha = image.getchannel("A").point(lambda value: 255 if value >= 128 else 0)
quantized = image.convert("RGB").quantize(palette=palette, dither=Image.Dither.NONE).convert("RGBA")
quantized.putalpha(alpha)
return quantized
def unique_colors(image: Image.Image) -> int:
return len({pixel for pixel in image.getdata() if pixel[3] > 0})
def pad_actor_frame(source: Path) -> Image.Image:
image = Image.open(source).convert("RGBA")
bounds = image.getchannel("A").getbbox()
if bounds is None:
raise ValueError(f"empty actor frame: {source}")
visible = image.crop(bounds)
if visible.width > ACTOR_CELL_SIZE - ACTOR_MARGIN * 2 or visible.height > ACTOR_CELL_SIZE - ACTOR_MARGIN * 2:
raise ValueError(f"actor frame exceeds padded cell: {source}")
output = Image.new("RGBA", (ACTOR_CELL_SIZE, ACTOR_CELL_SIZE), (0, 0, 0, 0))
left = (ACTOR_CELL_SIZE - visible.width) // 2
top = ACTOR_CELL_SIZE - ACTOR_MARGIN - visible.height
output.alpha_composite(visible, (left, top))
return output
def process_environment() -> dict:
source = Image.open(ENVIRONMENT_SOURCE).convert("RGBA")
cuts = (0, source.height // 3, (source.height * 2) // 3, source.height)
bands = [
crop_visible(remove_magenta(source.crop((0, cuts[index], source.width, cuts[index + 1]))))
for index in range(3)
]
reference_layers = build_base_layers((422, 195), bands)
palette = make_shared_palette(reference_layers)
records: dict[str, dict] = {name: {} for name in LAYER_NAMES}
for width, height in TARGET_SIZES:
size_key = f"{width}x{height}"
base_layers = build_base_layers((width, height), bands)
output_dir = RUNTIME_ROOT / "environment" / size_key
output_dir.mkdir(parents=True, exist_ok=True)
for name in LAYER_NAMES:
limited = apply_palette(base_layers[name], palette)
rendered = limited.resize((width, limited.height * 2), Image.Resampling.NEAREST).crop((0, 0, width, height))
output = output_dir / f"{name}.png"
rendered.save(output, format="PNG", optimize=False, compress_level=9)
records[name][size_key] = {
"path": output.relative_to(ROOT / "runtime").as_posix(),
"dimensions": [width, height],
"logical_source_dimensions": list(limited.size),
"cluster_pitch": 2,
"colors": unique_colors(rendered),
"sha256": sha256(output),
}
return {
"asset_id": "FG2-BG-WINDGLASS-01",
"source": ENVIRONMENT_SOURCE.relative_to(ROOT).as_posix(),
"source_sha256": sha256(ENVIRONMENT_SOURCE),
"source_dimensions": list(source.size),
"source_band_cuts": list(cuts),
"provider_task_id": "task_01M04FDED19RK2PKRZ4NEP7R2Z",
"palette_size": PALETTE_SIZE,
"layers": records,
}
def process_actors() -> dict[str, dict]:
records: dict[str, dict] = {}
for actor in ACTORS:
run_dir = MEDIA_ROOT / "runs" / actor["slug"]
source_manifest = json.loads((run_dir / "manifest.json").read_text(encoding="utf-8"))
state = actor["state"]
frame_rects = source_manifest["frame_layout"]["rows"][state]
output_dir = RUNTIME_ROOT / "actors" / actor["slug"]
frame_dir = output_dir / state
frame_dir.mkdir(parents=True, exist_ok=True)
runtime_frames = []
rendered_frames: list[Image.Image] = []
for index, _frame_rect in enumerate(frame_rects):
source_frame = run_dir / "frames" / state / f"frame-{index}.png"
output_frame = frame_dir / f"frame-{index}.png"
frame_image = pad_actor_frame(source_frame)
frame_image.save(output_frame, format="PNG", optimize=False, compress_level=9)
rendered_frames.append(frame_image)
runtime_frames.append(
{
"path": output_frame.relative_to(ROOT / "runtime").as_posix(),
"dimensions": list(frame_image.size),
"atlas_rect": [index * ACTOR_CELL_SIZE, 0, ACTOR_CELL_SIZE, ACTOR_CELL_SIZE],
"sha256": sha256(output_frame),
}
)
atlas_output = output_dir / "atlas.png"
atlas_image = Image.new("RGBA", (ACTOR_CELL_SIZE * len(rendered_frames), ACTOR_CELL_SIZE), (0, 0, 0, 0))
for index, frame_image in enumerate(rendered_frames):
atlas_image.alpha_composite(frame_image, (index * ACTOR_CELL_SIZE, 0))
atlas_image.save(atlas_output, format="PNG", optimize=False, compress_level=9)
records[actor["identity_id"]] = {
"asset_id": "FG2-ACTOR-PFB01-01" if actor["identity_id"] == "PF-B01" else f"FG2-ENEMY-{actor['identity_id'].replace('-', '')}-01",
"source": (MEDIA_ROOT / actor["raw"]).relative_to(ROOT).as_posix(),
"source_sha256": sha256(MEDIA_ROOT / actor["raw"]),
"provider_task_id": actor["provider_task_id"],
"state": state,
"fps": actor["fps"],
"loop": actor["loop"],
"pivot": actor["pivot"],
"play_size": actor["play_size"],
"atlas": atlas_output.relative_to(ROOT / "runtime").as_posix(),
"atlas_sha256": sha256(atlas_output),
"frames": runtime_frames,
}
return records
def main() -> int:
RUNTIME_ROOT.mkdir(parents=True, exist_ok=True)
manifest = {
"version": 1,
"kind": "abg-fg2-runtime-media",
"license": "owner-authorized original generation for Aetherbound Guild",
"filter": "nearest",
"integer_placement": True,
"environment": process_environment(),
"actors": process_actors(),
}
manifest_path = RUNTIME_ROOT / "asset-manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=True, indent=2, sort_keys=True) + "\n", encoding="utf-8")
print(json.dumps({"ok": True, "manifest": str(manifest_path), "actors": len(manifest["actors"]), "environment_sizes": len(TARGET_SIZES)}, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())