271 lines
10 KiB
Python
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())
|