burrow/Scripts/generate-brand-icons.py
Conrad Kramer 60cc48a4b7
Some checks failed
Build: Android / Android Rust Core Stub (push) Failing after 25s
Build Rust / Cargo Test (push) Successful in 4m2s
Build Site / Next.js Build (push) Failing after 4s
Lint Governance / BEP Metadata (push) Successful in 2s
Add raster panel icon family
2026-06-08 00:20:25 -07:00

450 lines
16 KiB
Python
Executable file

#!/usr/bin/env python3
"""Generate Burrow app icons from locked raster panel masters."""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BRAND = ROOT / "design" / "brand"
PANEL_DIR = BRAND / "panel-variants"
APPLE_ASSETS = ROOT / "Apple" / "UI" / "Assets.xcassets"
APPICON_SET = APPLE_ASSETS / "AppIcon.appiconset"
WATCHOS_BRAND_APPICON_SET = BRAND / "watchos" / "AppIcon.appiconset"
ANDROID_RES = ROOT / "Android" / "app" / "src" / "main" / "res"
GTK_ICONS = ROOT / "burrow-gtk" / "data" / "icons" / "hicolor"
VARIANTS = [
("panel-01-guardian", "BurrowPanel01", "Guardian", "top-left panel"),
("panel-02-field", "BurrowPanel02", "Field", "top-middle panel"),
("panel-03-burrow", "BurrowPanel03", "Burrow", "top-right panel"),
("panel-04-sentinel", "BurrowPanel04", "Sentinel", "bottom-left panel"),
("panel-05-stride", "BurrowPanel05", "Stride", "bottom-middle panel"),
("panel-06-post", "BurrowPanel06", "Post", "bottom-right panel"),
]
PRIMARY_IOS = "panel-01-guardian"
PRIMARY_MACOS = "panel-05-stride"
PRIMARY_WATCHOS = "panel-03-burrow"
PRIMARY_ANDROID = PRIMARY_IOS
PRIMARY_LINUX = PRIMARY_IOS
# Rough cells in the supplied 2928x1896 panel screenshot. Extraction trims the
# white sheet background, makes the crop square with transparent padding, then
# normalizes to 1024px.
EXTRACTION_CELLS = {
"panel-01-guardian": (40, 40, 900, 900),
"panel-02-field": (940, 40, 900, 900),
"panel-03-burrow": (1840, 40, 900, 900),
"panel-04-sentinel": (40, 980, 900, 900),
"panel-05-stride": (940, 980, 900, 900),
"panel-06-post": (1840, 980, 900, 900),
}
IOS_ROWS = [
("iphone", "20x20", "2x", 40, {}),
("iphone", "20x20", "3x", 60, {}),
("iphone", "29x29", "1x", 29, {}),
("iphone", "29x29", "2x", 58, {}),
("iphone", "29x29", "3x", 87, {}),
("iphone", "40x40", "2x", 80, {}),
("iphone", "40x40", "3x", 120, {}),
("iphone", "57x57", "1x", 57, {}),
("iphone", "57x57", "2x", 114, {}),
("iphone", "60x60", "2x", 120, {}),
("iphone", "60x60", "3x", 180, {}),
("ipad", "20x20", "1x", 20, {}),
("ipad", "20x20", "2x", 40, {}),
("ipad", "29x29", "1x", 29, {}),
("ipad", "29x29", "2x", 58, {}),
("ipad", "40x40", "1x", 40, {}),
("ipad", "40x40", "2x", 80, {}),
("ipad", "50x50", "1x", 50, {}),
("ipad", "50x50", "2x", 100, {}),
("ipad", "72x72", "1x", 72, {}),
("ipad", "72x72", "2x", 144, {}),
("ipad", "76x76", "1x", 76, {}),
("ipad", "76x76", "2x", 152, {}),
("ipad", "83.5x83.5", "2x", 167, {}),
("ios-marketing", "1024x1024", "1x", 1024, {}),
]
MAC_ROWS = [
("mac", "16x16", "1x", 16, {}),
("mac", "16x16", "2x", 32, {}),
("mac", "32x32", "1x", 32, {}),
("mac", "32x32", "2x", 64, {}),
("mac", "128x128", "1x", 128, {}),
("mac", "128x128", "2x", 256, {}),
("mac", "256x256", "1x", 256, {}),
("mac", "256x256", "2x", 512, {}),
("mac", "512x512", "1x", 512, {}),
("mac", "512x512", "2x", 1024, {}),
]
WATCH_ROWS = [
("watch", "24x24", "2x", 48, {"role": "notificationCenter", "subtype": "38mm"}),
("watch", "27.5x27.5", "2x", 55, {"role": "notificationCenter", "subtype": "42mm"}),
("watch", "33x33", "2x", 66, {"role": "notificationCenter", "subtype": "45mm"}),
("watch", "29x29", "2x", 58, {"role": "companionSettings"}),
("watch", "29x29", "3x", 87, {"role": "companionSettings"}),
("watch", "40x40", "2x", 80, {"role": "appLauncher", "subtype": "38mm"}),
("watch", "44x44", "2x", 88, {"role": "appLauncher", "subtype": "40mm"}),
("watch", "46x46", "2x", 92, {"role": "appLauncher", "subtype": "41mm"}),
("watch", "50x50", "2x", 100, {"role": "appLauncher", "subtype": "44mm"}),
("watch", "51x51", "2x", 102, {"role": "appLauncher", "subtype": "45mm"}),
("watch", "54x54", "2x", 108, {"role": "appLauncher", "subtype": "49mm"}),
("watch", "86x86", "2x", 172, {"role": "quickLook", "subtype": "38mm"}),
("watch", "98x98", "2x", 196, {"role": "quickLook", "subtype": "42mm"}),
("watch", "108x108", "2x", 216, {"role": "quickLook", "subtype": "44mm"}),
("watch", "117x117", "2x", 234, {"role": "quickLook", "subtype": "45mm"}),
("watch", "129x129", "2x", 258, {"role": "quickLook", "subtype": "49mm"}),
("watch-marketing", "1024x1024", "1x", 1024, {}),
]
ANDROID_MIPMAPS = {
"mipmap-mdpi": 48,
"mipmap-hdpi": 72,
"mipmap-xhdpi": 96,
"mipmap-xxhdpi": 144,
"mipmap-xxxhdpi": 192,
}
LINUX_SIZES = [16, 24, 32, 48, 64, 128, 256, 512]
def run(args: list[str]) -> None:
subprocess.run(args, cwd=ROOT, check=True)
def output(args: list[str]) -> str:
return subprocess.check_output(args, cwd=ROOT, text=True).strip()
def write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def write_json(path: Path, value: object) -> None:
write_text(path, json.dumps(value, indent=2) + "\n")
def variant(asset_id: str) -> tuple[str, str, str, str]:
for item in VARIANTS:
if item[0] == asset_id:
return item
raise KeyError(asset_id)
def master_path(asset_id: str) -> Path:
return PANEL_DIR / f"{asset_id}.png"
def extract_masters(sheet: Path) -> None:
PANEL_DIR.mkdir(parents=True, exist_ok=True)
shutil.copyfile(sheet, PANEL_DIR / "source-sheet.png")
for asset_id, (x, y, w, h) in EXTRACTION_CELLS.items():
tmp = PANEL_DIR / f".{asset_id}-trim.png"
dest = master_path(asset_id)
run(
[
"magick",
str(sheet),
"-crop",
f"{w}x{h}+{x}+{y}",
"-fuzz",
"8%",
"-trim",
"+repage",
str(tmp),
]
)
geometry = output(["magick", str(tmp), "-format", "%w %h", "info:"])
width, height = [int(part) for part in geometry.split()]
extent = max(width, height)
run(
[
"magick",
str(tmp),
"-alpha",
"set",
"-fuzz",
"6%",
"-fill",
"none",
"-draw",
"color 0,0 floodfill",
"-draw",
f"color {width - 1},0 floodfill",
"-draw",
f"color 0,{height - 1} floodfill",
"-draw",
f"color {width - 1},{height - 1} floodfill",
"-background",
"none",
"-gravity",
"center",
"-extent",
f"{extent}x{extent}",
"-resize",
"1024x1024!",
"(",
"-size",
"1024x1024",
"xc:none",
"-fill",
"white",
"-draw",
"roundrectangle 10,10 1013,1013 166,166",
")",
"-compose",
"DstIn",
"-composite",
"-strip",
f"PNG32:{dest}",
]
)
tmp.unlink()
def ensure_masters() -> None:
missing = [asset_id for asset_id, *_ in VARIANTS if not master_path(asset_id).exists()]
if missing:
raise SystemExit(f"missing panel masters: {', '.join(missing)}")
def clean_generated() -> None:
APPICON_SET.mkdir(parents=True, exist_ok=True)
for path in APPICON_SET.glob("*.png"):
path.unlink()
for path in APPLE_ASSETS.glob("BurrowPanel*.appiconset"):
shutil.rmtree(path)
for path in APPLE_ASSETS.glob("BurrowPanel*Preview.imageset"):
shutil.rmtree(path)
shutil.rmtree(WATCHOS_BRAND_APPICON_SET, ignore_errors=True)
low_res = BRAND / "low-res"
low_res.mkdir(parents=True, exist_ok=True)
for path in low_res.glob("*.png"):
path.unlink()
report = low_res / "report.csv"
if report.exists():
report.unlink()
for folder in ANDROID_MIPMAPS:
shutil.rmtree(ANDROID_RES / folder, ignore_errors=True)
for size in LINUX_SIZES:
shutil.rmtree(GTK_ICONS / str(size), ignore_errors=True)
for path in PANEL_DIR.glob("*-preview.png"):
path.unlink()
preview = PANEL_DIR / "preview-strip.png"
if preview.exists():
preview.unlink()
def render_png(source: Path, dest: Path, size: int, *, png32: bool = True) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
prefix = "PNG32:" if png32 else "PNG24:"
run(
[
"magick",
str(source),
"-resize",
f"{size}x{size}!",
"-strip",
f"{prefix}{dest}",
]
)
def image_entry(idiom: str, size: str, scale: str, filename: str, extra: dict[str, str]) -> dict[str, str]:
row = {"filename": filename, "idiom": idiom, "scale": scale, "size": size}
row.update(extra)
return row
def write_appicon_contents(path: Path, rows: list[dict[str, str]]) -> None:
write_json(path / "Contents.json", {"images": rows, "info": {"author": "xcode", "version": 1}})
def generate_appicon_set(
path: Path,
source: Path,
prefix: str,
rows_def: list[tuple[str, str, str, int, dict[str, str]]],
) -> list[dict[str, str]]:
path.mkdir(parents=True, exist_ok=True)
rows = []
rendered: set[tuple[str, int]] = set()
for idiom, size_label, scale, pixels, extra in rows_def:
filename = f"{prefix}-{pixels}.png"
rows.append(image_entry(idiom, size_label, scale, filename, extra))
key = (filename, pixels)
if key not in rendered:
render_png(source, path / filename, pixels)
rendered.add(key)
return rows
def generate_apple_assets() -> None:
primary_rows = []
primary_rows.extend(generate_appicon_set(APPICON_SET, master_path(PRIMARY_IOS), "ios-panel-01", IOS_ROWS))
primary_rows.extend(generate_appicon_set(APPICON_SET, master_path(PRIMARY_MACOS), "mac-panel-05", MAC_ROWS))
write_appicon_contents(APPICON_SET, primary_rows)
watch_rows = generate_appicon_set(
WATCHOS_BRAND_APPICON_SET,
master_path(PRIMARY_WATCHOS),
"watch-panel-03",
WATCH_ROWS,
)
write_appicon_contents(WATCHOS_BRAND_APPICON_SET, watch_rows)
for asset_id, asset_name, _, _ in VARIANTS[1:]:
set_path = APPLE_ASSETS / f"{asset_name}.appiconset"
rows = generate_appicon_set(set_path, master_path(asset_id), asset_name, IOS_ROWS)
write_appicon_contents(set_path, rows)
for asset_id, asset_name, _, _ in VARIANTS:
preview_dir = APPLE_ASSETS / f"{asset_name}Preview.imageset"
preview_dir.mkdir(parents=True, exist_ok=True)
render_png(master_path(asset_id), preview_dir / "preview.png", 256)
write_json(
preview_dir / "Contents.json",
{
"images": [{"filename": "preview.png", "idiom": "universal", "scale": "1x"}],
"info": {"author": "xcode", "version": 1},
},
)
def generate_brand_outputs() -> None:
primary = master_path(PRIMARY_IOS)
render_png(primary, BRAND / "burrow-owl-icon-master.png", 1024)
render_png(primary, BRAND / "burrowing-owl-icon-flat.png", 1024)
render_png(primary, BRAND / "Burrow.icon" / "Assets" / "owl-figure.png", 1024)
render_png(primary, BRAND / "Burrow.icon" / "Assets" / "owl-body.png", 1024)
transparent = BRAND / "Burrow.icon" / "Assets" / "transparent.png"
run(["magick", "-size", "1024x1024", "xc:none", f"PNG32:{transparent}"])
for name in ["burrow-ground", "owl-face", "owl-legs", "owl-spots"]:
shutil.copyfile(transparent, BRAND / "Burrow.icon" / "Assets" / f"{name}.png")
transparent.unlink()
write_json(
BRAND / "Burrow.icon" / "icon.json",
{
"fill": {"type": "solid", "color": "#00000000"},
"layers": [
{
"blend-mode": "normal",
"image-name": "owl-figure.png",
"name": "panel-raster",
"position": {"scale": 1, "translation-in-points": [0, 0]},
}
],
},
)
low_res = BRAND / "low-res"
report_rows = ["size,colors,gray_stddev,status"]
preview_inputs = []
pixel_inputs = []
for size in [16, 20, 24, 29, 32, 40, 48, 64, 80, 128, 256, 1024]:
icon = low_res / f"burrow-icon-{size}.png"
render_png(primary, icon, size)
if size <= 256:
colors = output(["magick", str(icon), "-format", "%k", "info:"])
stddev = output(["magick", str(icon), "-colorspace", "Gray", "-format", "%[fx:standard_deviation]", "info:"])
status = "ok" if int(colors) >= 8 and float(stddev) >= 0.025 else "check"
report_rows.append(f"{size},{colors},{stddev},{status}")
preview = low_res / f"preview-{size}.png"
filter_name = "Point" if size <= 32 else "Lanczos"
run(["magick", str(icon), "-filter", filter_name, "-resize", "128x128", str(preview)])
preview_inputs.append(preview)
if size <= 32:
pixel = low_res / f"pixel-preview-{size}.png"
run(["magick", str(icon), "-filter", "Point", "-resize", "128x128", str(pixel)])
pixel_inputs.append(pixel)
write_text(low_res / "report.csv", "\n".join(report_rows) + "\n")
preview_inputs.reverse()
pixel_inputs.reverse()
run(["magick", "-background", "#202020", "-gravity", "center", *map(str, preview_inputs), "+append", str(low_res / "preview-strip.png")])
run(["magick", "-background", "#202020", "-gravity", "center", *map(str, pixel_inputs), "+append", str(low_res / "pixel-preview-strip.png")])
variant_previews = []
for asset_id, _, _, _ in VARIANTS:
preview = PANEL_DIR / f"{asset_id}-preview.png"
render_png(master_path(asset_id), preview, 256)
variant_previews.append(preview)
run(["magick", "-background", "#fbfaf6", "-gravity", "center", *map(str, variant_previews), "+append", str(PANEL_DIR / "preview-strip.png")])
lines = [
"# Burrow Raster Panel Icon Variants",
"",
"The six PNG masters here are cropped from `source-sheet.png` and are the",
"locked app icon source. Do not redraw them; regenerate package assets from",
"these rasters.",
"",
"| Variant | Use | Source |",
"| --- | --- | --- |",
]
for asset_id, _, title, source in VARIANTS:
uses = []
if asset_id == PRIMARY_IOS:
uses.append("iOS primary")
else:
uses.append("iOS alternate")
if asset_id == PRIMARY_MACOS:
uses.append("macOS primary")
if asset_id == PRIMARY_WATCHOS:
uses.append("watchOS primary")
if asset_id == PRIMARY_ANDROID:
uses.append("Android primary")
if asset_id == PRIMARY_LINUX:
uses.append("Linux primary")
use_text = ", ".join(uses)
lines.append(f"| `{asset_id}` | {use_text} | {title}, {source} |")
lines.append("")
lines.append("`preview-strip.png` shows the six raster masters side by side.")
write_text(PANEL_DIR / "README.md", "\n".join(lines) + "\n")
def generate_android_assets() -> None:
primary = master_path(PRIMARY_ANDROID)
for folder, size in ANDROID_MIPMAPS.items():
out_dir = ANDROID_RES / folder
render_png(primary, out_dir / "ic_launcher.png", size)
render_png(primary, out_dir / "ic_launcher_round.png", size)
render_png(primary, ROOT / "store-metadata" / "android" / "icon.png", 512)
def generate_linux_assets() -> None:
primary = master_path(PRIMARY_LINUX)
for size in LINUX_SIZES:
render_png(primary, GTK_ICONS / str(size) / "apps" / "burrow-gtk.png", size)
def main(argv: list[str]) -> None:
if len(argv) == 3 and argv[1] == "--extract-from":
extract_masters(Path(argv[2]))
elif len(argv) != 1:
raise SystemExit("usage: generate-brand-icons.py [--extract-from /path/to/panel-sheet.png]")
ensure_masters()
clean_generated()
generate_apple_assets()
generate_brand_outputs()
generate_android_assets()
generate_linux_assets()
if __name__ == "__main__":
main(sys.argv)