#!/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())