Trace the flat-color logo to source/logo.svg (palette-locked to the canonical brand colors via tools/trace_logo.py), and render every avatar/favicon/logo master from the SVG at its exact size (tools/build_assets.py via resvg). Logo assets are now crisp at any resolution — removes the 640px upscale ceiling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
139 lines
4.7 KiB
Python
139 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Regenerate every platform asset in the brand kit from the canonical sources.
|
|
|
|
Sources:
|
|
source/logo.svg -- vector cartoon avatar (red polo); rendered crisply at each
|
|
target size. Falls back to source/logo.png if resvg is absent.
|
|
source/hero.png -- photoreal desk scene (red polo), used for all banners/covers.
|
|
|
|
Profile pictures + favicons come from the logo; banners/covers from the hero.
|
|
|
|
Requires Pillow, and (for crisp vector logo output) the `resvg` binary
|
|
(cargo install resvg). Regenerate the SVG itself with tools/trace_logo.py.
|
|
|
|
Run: python3 tools/build_assets.py
|
|
"""
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
from PIL import Image
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
SRC = ROOT / "source"
|
|
LOGO_SVG = SRC / "logo.svg"
|
|
LOGO_PNG = SRC / "logo.png"
|
|
HERO = Image.open(SRC / "hero.png").convert("RGB")
|
|
HAVE_RESVG = shutil.which("resvg") is not None and LOGO_SVG.exists()
|
|
|
|
|
|
def save(img, relpath):
|
|
out = ROOT / relpath
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
img.save(out)
|
|
print(f" {relpath}: {img.size[0]}x{img.size[1]}")
|
|
|
|
|
|
def logo_at(size):
|
|
"""Render the vector logo crisply at size x size (raster fallback)."""
|
|
if HAVE_RESVG:
|
|
with tempfile.NamedTemporaryFile(suffix=".png") as tf:
|
|
subprocess.run(
|
|
["resvg", "--width", str(size), "--height", str(size),
|
|
str(LOGO_SVG), tf.name],
|
|
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
)
|
|
return Image.open(tf.name).convert("RGB")
|
|
return Image.open(LOGO_PNG).convert("RGB").resize((size, size), Image.LANCZOS)
|
|
|
|
|
|
def cover(img, tw, th, fy=0.45, fx=0.5):
|
|
"""Scale-to-cover then crop to (tw,th), biased vertically by fy (0=top,1=bottom)."""
|
|
iw, ih = img.size
|
|
scale = max(tw / iw, th / ih)
|
|
nw, nh = round(iw * scale), round(ih * scale)
|
|
im = img.resize((nw, nh), Image.LANCZOS)
|
|
left = round((nw - tw) * fx)
|
|
top = round((nh - th) * fy)
|
|
return im.crop((left, top, left + tw, top + th))
|
|
|
|
|
|
# ---- Profile pictures (from logo) -------------------------------------------
|
|
AVATARS = {
|
|
"avatars/x-400.png": 400,
|
|
"avatars/linkedin-400.png": 400,
|
|
"avatars/github-460.png": 460,
|
|
"avatars/instagram-320.png": 320,
|
|
"avatars/facebook-320.png": 320,
|
|
"avatars/youtube-800.png": 800,
|
|
"avatars/tiktok-200.png": 200,
|
|
"avatars/mastodon-400.png": 400,
|
|
"avatars/bluesky-400.png": 400,
|
|
"avatars/threads-320.png": 320,
|
|
"avatars/discord-512.png": 512,
|
|
}
|
|
|
|
# ---- Banners / covers (from hero) -------------------------------------------
|
|
# fy biases the crop band; lower = higher in frame (favours monitors + head).
|
|
BANNERS = {
|
|
"banners/x-header-1500x500.png": (1500, 500, 0.42),
|
|
"banners/linkedin-personal-1584x396.png": (1584, 396, 0.42),
|
|
"banners/linkedin-company-1128x191.png": (1128, 191, 0.42),
|
|
"banners/facebook-cover-851x315.png": (851, 315, 0.42),
|
|
"banners/facebook-cover-2x-1702x630.png": (1702, 630, 0.42),
|
|
"banners/youtube-banner-2560x1440.png": (2560, 1440, 0.45),
|
|
"banners/discord-banner-960x540.png": (960, 540, 0.45),
|
|
"banners/github-social-1280x640.png": (1280, 640, 0.45),
|
|
"banners/open-graph-1200x630.png": (1200, 630, 0.45),
|
|
"banners/twitter-card-1200x628.png": (1200, 628, 0.45),
|
|
}
|
|
|
|
# ---- Logo masters & favicons (from logo) ------------------------------------
|
|
LOGO_SIZES = {
|
|
"logo/logo-1024.png": 1024,
|
|
"logo/logo-512.png": 512,
|
|
"logo/logo-256.png": 256,
|
|
}
|
|
FAVICON_SIZES = {
|
|
"favicon/favicon-16.png": 16,
|
|
"favicon/favicon-32.png": 32,
|
|
"favicon/favicon-48.png": 48,
|
|
"favicon/favicon-192.png": 192,
|
|
"favicon/favicon-512.png": 512,
|
|
"favicon/apple-touch-icon-180.png": 180,
|
|
}
|
|
|
|
|
|
def main():
|
|
print(f"logo source: {'vector (resvg)' if HAVE_RESVG else 'raster fallback'}")
|
|
|
|
print("avatars:")
|
|
for path, size in AVATARS.items():
|
|
save(logo_at(size), path)
|
|
|
|
print("logo masters:")
|
|
for path, size in LOGO_SIZES.items():
|
|
save(logo_at(size), path)
|
|
if LOGO_SVG.exists():
|
|
shutil.copy(LOGO_SVG, ROOT / "logo/logo.svg")
|
|
print(" logo/logo.svg: vector")
|
|
save(Image.open(LOGO_PNG).convert("RGB"), "logo/logo-original.png")
|
|
|
|
print("favicons:")
|
|
for path, size in FAVICON_SIZES.items():
|
|
save(logo_at(size), path)
|
|
ico = ROOT / "favicon/favicon.ico"
|
|
logo_at(256).save(
|
|
ico, sizes=[(16, 16), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]
|
|
)
|
|
print(" favicon/favicon.ico: multi-res")
|
|
|
|
print("banners:")
|
|
for path, (w, h, fy) in BANNERS.items():
|
|
save(cover(HERO, w, h, fy=fy), path)
|
|
|
|
print("done.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|