#!/usr/bin/env python3 """Build the bounded FG-3 Siltwake runtime art package from retained media.""" from __future__ import annotations import argparse from collections import deque import hashlib import json from pathlib import Path from PIL import Image ROOT = Path(__file__).resolve().parents[1] MEDIA_ROOT = ROOT / "docs/runtime/fg3/media" RUNTIME_ROOT = ROOT / "runtime/assets/fg3" ENVIRONMENT_SOURCE = MEDIA_ROOT / "raw/siltwake-layers.png" BOSS_ACTION_SOURCE = MEDIA_ROOT / "raw/rg02-boss-actions.png" IDENTITY_SOURCE = MEDIA_ROOT / "raw/rg02-company-enemy-identities.png" TARGET_SIZES = ((380, 180), (422, 195), (320, 180)) LAYER_NAMES = ("background", "midground", "foreground") PALETTE_SIZE = 32 IDENTITY_LOGICAL_SIZE = 36 IDENTITY_CELL_SIZE = 72 IDENTITY_PALETTE_SIZE = 16 BOSSES = ( { "identity_id": "BO-R2-01", "slug": "sluiceback_mother", "source_row": 0, "state": "boss_action", "fps": 8, "loop": False, "play_size": [78, 58], "pivot": [39, 56], "cell_size": [128, 96], "provider_task_id": "task_01M056P0CCPYVFS2GXD91GK7KZ", }, { "identity_id": "BO-R2-02", "slug": "drowned_bell", "source_row": 1, "state": "boss_action", "fps": 8, "loop": False, "play_size": [62, 72], "pivot": [31, 70], "cell_size": [96, 96], "provider_task_id": "task_01M056P0CCPYVFS2GXD91GK7KZ", }, ) IDENTITIES = ( {"identity_id": "PF-B09", "slug": "coilsmith", "source_cell": 0, "play_size": [36, 40]}, {"identity_id": "PF-B07", "slug": "kindhand", "source_cell": 1, "play_size": [34, 40]}, {"identity_id": "EN-R2-01", "slug": "reed_hook", "source_cell": 2, "play_size": [34, 40]}, {"identity_id": "EN-R2-02", "slug": "mudlamp_leech", "source_cell": 3, "play_size": [38, 32]}, {"identity_id": "EN-R2-03", "slug": "silt_mimic", "source_cell": 4, "play_size": [36, 34]}, {"identity_id": "EN-R2-04", "slug": "bank_tortoise", "source_cell": 5, "play_size": [38, 32]}, {"identity_id": "EN-R2-05", "slug": "fen_piper", "source_cell": 6, "play_size": [34, 40]}, {"identity_id": "EN-R2-06", "slug": "brackwater_wisp", "source_cell": 7, "play_size": [32, 38]}, {"identity_id": "EN-R2-07", "slug": "ferry_crab", "source_cell": 8, "play_size": [40, 32]}, {"identity_id": "EN-R2-08", "slug": "drownseed_pod", "source_cell": 9, "play_size": [34, 36]}, {"identity_id": "EN-R2-09", "slug": "mire_medic", "source_cell": 10, "play_size": [34, 40]}, {"identity_id": "EN-R2-10", "slug": "fogjaw", "source_cell": 11, "play_size": [40, 32]}, {"identity_id": "EN-R2-11", "slug": "channel_widow", "source_cell": 12, "play_size": [36, 42]}, {"identity_id": "EN-R2-12", "slug": "sluice_elder", "source_cell": 13, "play_size": [36, 42]}, ) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--rows-only", action="store_true", help="write the two deterministic raw action rows, then stop", ) return parser.parse_args() 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-3 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, (7, 26, 44, 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) limited = image.convert("RGB").quantize( palette=palette, dither=Image.Dither.NONE, ).convert("RGBA") limited.putalpha(alpha) return limited def unique_colors(image: Image.Image) -> int: return len({pixel for pixel in image.getdata() if pixel[3] > 0}) def remove_tiny_components(image: Image.Image, min_area: int = 4) -> Image.Image: rgba = image.convert("RGBA") alpha = list(rgba.getchannel("A").getdata()) width, height = rgba.size visited = bytearray(width * height) retained = bytearray(alpha) for start, value in enumerate(alpha): if value == 0 or visited[start]: continue visited[start] = 1 queue: deque[int] = deque([start]) component: list[int] = [] while queue: current = queue.popleft() component.append(current) x, y = current % width, current // width for next_y in range(max(0, y - 1), min(height, y + 2)): row_start = next_y * width for next_x in range(max(0, x - 1), min(width, x + 2)): neighbor = row_start + next_x if alpha[neighbor] and not visited[neighbor]: visited[neighbor] = 1 queue.append(neighbor) if len(component) < min_area: for pixel in component: retained[pixel] = 0 cleaned_alpha = Image.new("L", rgba.size) cleaned_alpha.putdata(retained) rgba.putalpha(cleaned_alpha) return rgba def fit_identity(image: Image.Image) -> Image.Image: visible = crop_visible(remove_magenta(image)) content_limit = IDENTITY_LOGICAL_SIZE - 6 scale = min(content_limit / visible.width, content_limit / visible.height) resized = visible.resize( (max(1, round(visible.width * scale)), max(1, round(visible.height * scale))), Image.Resampling.BOX, ) alpha = resized.getchannel("A").point(lambda value: 255 if value >= 128 else 0) limited = resized.convert("RGB").quantize( colors=IDENTITY_PALETTE_SIZE, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE, ).convert("RGBA") limited.putalpha(alpha) logical = Image.new( "RGBA", (IDENTITY_LOGICAL_SIZE, IDENTITY_LOGICAL_SIZE), (0, 0, 0, 0), ) left = (IDENTITY_LOGICAL_SIZE - limited.width) // 2 top = IDENTITY_LOGICAL_SIZE - 3 - limited.height logical.alpha_composite(limited, (left, top)) logical = remove_tiny_components(logical) return logical.resize( (IDENTITY_CELL_SIZE, IDENTITY_CELL_SIZE), Image.Resampling.NEAREST, ) def prepare_boss_rows() -> list[dict[str, object]]: source = Image.open(BOSS_ACTION_SOURCE).convert("RGBA") records: list[dict[str, object]] = [] for boss in BOSSES: row_index = int(boss["source_row"]) top = round(row_index * source.height / len(BOSSES)) bottom = round((row_index + 1) * source.height / len(BOSSES)) run_dir = MEDIA_ROOT / "runs" / str(boss["slug"]) output = run_dir / "raw" / f"{boss['state']}.png" output.parent.mkdir(parents=True, exist_ok=True) source.crop((0, top, source.width, bottom)).save( output, format="PNG", optimize=False, compress_level=9, ) records.append( { "identity_id": boss["identity_id"], "source_row": row_index, "source_rect": [0, top, source.width, bottom - top], "path": output.relative_to(ROOT).as_posix(), "sha256": sha256(output), } ) rows_manifest = MEDIA_ROOT / "runs/boss-action-rows.json" rows_manifest.write_text( json.dumps( { "kind": "abg-fg3-boss-action-rows", "source": BOSS_ACTION_SOURCE.relative_to(ROOT).as_posix(), "source_sha256": sha256(BOSS_ACTION_SOURCE), "source_dimensions": list(source.size), "rows": records, }, ensure_ascii=True, indent=2, sort_keys=True, ) + "\n", encoding="utf-8", ) return records def process_environment() -> dict[str, object]: 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[str, object]] = {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": "FG3-BG-SILTWAKE-01", "region_id": "RG-02", "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_01M056E56CNT64A0M3VB617Z9R", "palette_size": PALETTE_SIZE, "layers": records, } def process_bosses() -> dict[str, dict[str, object]]: records: dict[str, dict[str, object]] = {} for boss in BOSSES: run_dir = MEDIA_ROOT / "runs" / str(boss["slug"]) manifest_path = run_dir / "manifest.json" if not manifest_path.is_file(): raise FileNotFoundError(f"sprite-gen manifest missing: {manifest_path}") source_manifest = json.loads(manifest_path.read_text(encoding="utf-8")) state = str(boss["state"]) frame_rects = source_manifest["frame_layout"]["rows"][state] cell_width, cell_height = (int(value) for value in boss["cell_size"]) if len(frame_rects) != 4: raise ValueError(f"{boss['identity_id']} must have four action frames") output_dir = RUNTIME_ROOT / "bosses" / str(boss["slug"]) frame_dir = output_dir / state frame_dir.mkdir(parents=True, exist_ok=True) rendered_frames: list[Image.Image] = [] runtime_frames: list[dict[str, object]] = [] for index, _frame_rect in enumerate(frame_rects): source_frame = run_dir / "frames" / state / f"frame-{index}.png" with Image.open(source_frame) as opened: frame = opened.convert("RGBA") if frame.size != (cell_width, cell_height): raise ValueError(f"unexpected Boss frame size: {source_frame} {frame.size}") alpha = frame.getchannel("A").point(lambda value: 255 if value >= 128 else 0) frame.putalpha(alpha) output_frame = frame_dir / f"frame-{index}.png" frame.save(output_frame, format="PNG", optimize=False, compress_level=9) rendered_frames.append(frame) runtime_frames.append( { "path": output_frame.relative_to(ROOT / "runtime").as_posix(), "dimensions": list(frame.size), "atlas_rect": [index * cell_width, 0, cell_width, cell_height], "sha256": sha256(output_frame), } ) atlas_output = output_dir / "atlas.png" atlas = Image.new( "RGBA", (cell_width * len(rendered_frames), cell_height), (0, 0, 0, 0), ) for index, frame in enumerate(rendered_frames): atlas.alpha_composite(frame, (index * cell_width, 0)) atlas.save(atlas_output, format="PNG", optimize=False, compress_level=9) raw_row = run_dir / "raw" / f"{state}.png" records[str(boss["identity_id"])] = { "asset_id": f"FG3-BOSS-{str(boss['identity_id']).replace('-', '')}-01", "source": BOSS_ACTION_SOURCE.relative_to(ROOT).as_posix(), "source_sha256": sha256(BOSS_ACTION_SOURCE), "source_row": int(boss["source_row"]), "raw_row": raw_row.relative_to(ROOT).as_posix(), "raw_row_sha256": sha256(raw_row), "sprite_manifest": manifest_path.relative_to(ROOT).as_posix(), "sprite_manifest_sha256": sha256(manifest_path), "provider_task_id": boss["provider_task_id"], "state": state, "fps": int(boss["fps"]), "loop": bool(boss["loop"]), "pivot": list(boss["pivot"]), "play_size": list(boss["play_size"]), "atlas": atlas_output.relative_to(ROOT / "runtime").as_posix(), "atlas_sha256": sha256(atlas_output), "frames": runtime_frames, } return records def process_identities() -> dict[str, dict[str, object]]: source = Image.open(IDENTITY_SOURCE).convert("RGBA") records: dict[str, dict[str, object]] = {} for identity in IDENTITIES: source_cell = int(identity["source_cell"]) row, column = divmod(source_cell, 4) bounds = ( round(column * source.width / 4), round(row * source.height / 4), round((column + 1) * source.width / 4), round((row + 1) * source.height / 4), ) frame = fit_identity(source.crop(bounds)) output_dir = RUNTIME_ROOT / "actors" / str(identity["slug"]) frame_dir = output_dir / "idle" frame_dir.mkdir(parents=True, exist_ok=True) output_frame = frame_dir / "frame-0.png" atlas_output = output_dir / "atlas.png" frame.save(output_frame, format="PNG", optimize=False, compress_level=9) frame.save(atlas_output, format="PNG", optimize=False, compress_level=9) identity_id = str(identity["identity_id"]) records[identity_id] = { "asset_id": f"FG3-ACTOR-{identity_id.replace('-', '')}-01", "source": IDENTITY_SOURCE.relative_to(ROOT).as_posix(), "source_sha256": sha256(IDENTITY_SOURCE), "source_cell": source_cell, "source_rect": [bounds[0], bounds[1], bounds[2] - bounds[0], bounds[3] - bounds[1]], "provider_task_id": "task_01M058059WFB3F4G96SC9SDRWD", "state": "idle", "fps": 1, "loop": True, "pivot": [int(identity["play_size"][0]) // 2, int(identity["play_size"][1]) - 2], "play_size": list(identity["play_size"]), "logical_grid": [IDENTITY_LOGICAL_SIZE, IDENTITY_LOGICAL_SIZE], "palette_size": IDENTITY_PALETTE_SIZE, "colors": unique_colors(frame), "atlas": atlas_output.relative_to(ROOT / "runtime").as_posix(), "atlas_sha256": sha256(atlas_output), "frames": [ { "path": output_frame.relative_to(ROOT / "runtime").as_posix(), "dimensions": list(frame.size), "atlas_rect": [0, 0, IDENTITY_CELL_SIZE, IDENTITY_CELL_SIZE], "sha256": sha256(output_frame), } ], } return records def main() -> int: args = parse_args() rows = prepare_boss_rows() if args.rows_only: print( json.dumps( {"ok": True, "rows": len(rows), "mode": "rows-only"}, sort_keys=True, ) ) return 0 RUNTIME_ROOT.mkdir(parents=True, exist_ok=True) manifest = { "version": 1, "kind": "abg-fg3-runtime-media", "license": "owner-authorized original generation for Aetherbound Guild", "filter": "nearest", "integer_placement": True, "environment": process_environment(), "actors": process_identities(), "bosses": process_bosses(), } 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), "bosses": len(manifest["bosses"]), "actors": len(manifest["actors"]), "environment_sizes": len(TARGET_SIZES), }, sort_keys=True, ) ) return 0 if __name__ == "__main__": raise SystemExit(main())