igneum/brand/icons/make-icons.py
igneum-labs f14cf0ffbf Share images: the master mark replaces the ringed coin on every og image; og.png (was a 404) added; ?v=2 cache bust
make-icons.py make_social(): og-small.png (256 square, the compact card index/live/litepaper declare), og-square.png
(1024), og-coin.png and og.png (1200x630: mark left, IGNEUM wordmark, tagline, subline; same layout as before, no ring,
no glow). bench.html, evidence.html and build.mjs referenced /og.png, which did not exist; they now get the 1200x630 card
with width/height 1200x630 and twitter:card summary_large_image. Every og:image and twitter:image carries ?v=2 so
Slack, X and iMessage refetch. brand/profile: github-social-1280x640.png (repository social preview) and
vercel-avatar-512.png. bench.html edited by hand for the meta only (a build.mjs run would publish uncommitted log entries).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 17:39:05 +00:00

325 lines
16 KiB
Python

#!/usr/bin/env python3
"""Builds every Igneum icon from the master mark, brand/master/igneum-mark-square.svg (4 October 2026).
the project lead's rule (4 October 2026): one global logo for apps, profile pictures, favicons, everything. The mark sits in a black
square (#0C0C0E, the site's obsidian token), centred, no circle, no ring, no border, never on another colour. The Mac app
is the model. The rounded variant (brand/master/igneum-mark-square-rounded.svg, Apple's 824-on-1024 icon grid) is used
only where the shape has to be baked into the file: the DMG volume icon. Tahoe masks the plain square itself.
Outputs:
brand/icons/
igneum.icns the app bundle icon (plain square, 16 to 1024, iconutil)
igneum-volume.icns the DMG volume icon (rounded variant)
igneum.ico Windows icon, 16 24 32 48 64 128 256 px, uncompressed BMP entries, each rendered from the vector
igneum-256.png the square at 256 px
inno-wizard-164x314.bmp Inno Setup WizardImageFile
inno-wizard-small-55x58.bmp Inno Setup WizardSmallImageFile
inno-banner-150x57.bmp header banner (NSIS size)
dmg-background.png / @2x 660x400 DMG window background; dmg-background.tiff holds both when tiffutil is present
site/ favicon.ico (16 32 48), favicon-32.png, apple-touch-icon.png (180, square: iOS rounds it), icon-192.png,
icon-512.png, maskable-512.png (the mark at 50% of the square, inside the maskable safe zone)
relay/ favicon.ico, favicon-32.png, apple-touch-icon.png (the relay serves static files from its root)
brand/profile/ profile-400.png, profile-512.png, profile-1024.png (X, Discord, Telegram, LinkedIn), github-org-512.png,
x-banner-1500x500.png (the mark centred on obsidian)
brand/igneum-icon-512.png and igneum-icon-1024.png are refreshed from the master as well.
site/og-small.png (256), og-square.png (1024), og-coin.png and og.png (1200x630 card), brand/profile/github-social-1280x640.png,
vercel-avatar-512.png: the share images (make_social), the mark on obsidian, never the old ringed coin.
Rendering: rsvg-convert when it is installed (brew install librsvg), else Pillow draws the master's rect and polygons
itself at 4x to 16x supersampling (the mark is two polygons, so this is exact). cairosvg is not used: the conda build on
this Mac has no libcairo. Needs Pillow and, on a Mac, iconutil and tiffutil (both ship with macOS).
python3 brand/icons/make-icons.py
"""
import os, re, subprocess, sys, shutil, tempfile
import xml.etree.ElementTree as ET
from PIL import Image, ImageDraw, ImageFont
HERE = os.path.dirname(os.path.abspath(__file__))
BRAND = os.path.dirname(HERE)
ROOT = os.path.dirname(BRAND)
MASTER = os.path.join(BRAND, 'master', 'igneum-mark-square.svg')
MASTER_ROUNDED = os.path.join(BRAND, 'master', 'igneum-mark-square-rounded.svg')
SITE = os.path.join(ROOT, 'site')
RELAY = os.path.join(ROOT, 'relay')
PROFILE = os.path.join(BRAND, 'profile')
FONT = os.path.join(BRAND, 'Unbounded.ttf')
OBSIDIAN = (12, 12, 14)
EMBER = (242, 84, 27)
MOLTEN = (255, 179, 92)
BONE = (244, 241, 236)
ARTBOARD = 1024
SVG = '{http://www.w3.org/2000/svg}'
# ---- the vector -------------------------------------------------------------------------------------------------
def hex_rgb(s):
s = s.strip().lstrip('#')
return tuple(int(s[i:i + 2], 16) for i in (0, 2, 4))
def parse_transform(t):
"""translate(tx ty) scale(s) -> (tx, ty, s); only the two forms the masters use."""
tx = ty = 0.0
s = 1.0
for name, args in re.findall(r'(\w+)\(([^)]*)\)', t or ''):
v = [float(x) for x in re.split(r'[\s,]+', args.strip()) if x]
if name == 'translate':
tx, ty = v[0], (v[1] if len(v) > 1 else 0.0)
elif name == 'scale':
s = v[0]
return tx, ty, s
def load_master(path):
"""The master as a list of shapes: ('rect', fill) or ('poly', [(x, y), ...], fill), in document order, on the 1024 artboard."""
root = ET.parse(path).getroot()
shapes = []
def walk(el, tx, ty, s):
tag = el.tag.replace(SVG, '')
if tag == 'g':
gx, gy, gs = parse_transform(el.get('transform'))
for c in el:
walk(c, tx + gx * s, ty + gy * s, s * gs)
elif tag == 'rect':
shapes.append(('rect', hex_rgb(el.get('fill'))))
elif tag == 'polygon':
pts = [float(v) for v in re.split(r'[\s,]+', el.get('points').strip()) if v]
poly = [(tx + pts[i] * s, ty + pts[i + 1] * s) for i in range(0, len(pts), 2)]
shapes.append(('poly', poly, hex_rgb(el.get('fill'))))
else:
for c in el:
walk(c, tx, ty, s)
walk(root, 0.0, 0.0, 1.0)
return shapes
def render_pillow(shapes, size, mark_scale=1.0):
"""The shapes at `size` px, supersampled, on transparency. mark_scale shrinks the polygons about the centre
(the maskable icon wants the mark at 50% of the square instead of 62%)."""
ss = max(2, min(16, 4096 // size))
big = size * ss
k = big / ARTBOARD
im = Image.new('RGBA', (big, big), (0, 0, 0, 0))
d = ImageDraw.Draw(im)
c = ARTBOARD / 2
for sh in shapes:
if sh[0] == 'rect':
d.rectangle((0, 0, big, big), fill=sh[1] + (255,))
else:
pts = [((c + (x - c) * mark_scale) * k, (c + (y - c) * mark_scale) * k) for x, y in sh[1]]
d.polygon(pts, fill=sh[2] + (255,))
return im.resize((size, size), Image.LANCZOS)
def render_rsvg(path, size):
out = subprocess.run(['rsvg-convert', '-w', str(size), '-h', str(size), path], capture_output=True, check=True).stdout
import io
return Image.open(io.BytesIO(out)).convert('RGBA')
_cache = {}
def render(size, rounded=False, mark_scale=1.0):
key = (size, rounded, mark_scale)
if key not in _cache:
path = MASTER_ROUNDED if rounded else MASTER
if mark_scale == 1.0 and shutil.which('rsvg-convert'):
im = render_rsvg(path, size)
else:
im = render_pillow(load_master(path), size, mark_scale)
_cache[key] = im
return _cache[key].copy()
# ---- outputs ----------------------------------------------------------------------------------------------------
def save_png(im, path):
os.makedirs(os.path.dirname(path), exist_ok=True)
im.save(path, optimize=True)
return path
def make_icns(name, rounded):
tmp = tempfile.mkdtemp()
iconset = os.path.join(tmp, 'igneum.iconset')
os.mkdir(iconset)
for s in (16, 32, 128, 256, 512):
render(s, rounded).save(os.path.join(iconset, f'icon_{s}x{s}.png'))
render(s * 2, rounded).save(os.path.join(iconset, f'icon_{s}x{s}@2x.png'))
out = os.path.join(HERE, name)
subprocess.run(['iconutil', '-c', 'icns', iconset, '-o', out], check=True)
shutil.rmtree(tmp)
return out
def make_ico(path, sizes):
frames = [render(s).convert('RGBA') for s in sizes]
os.makedirs(os.path.dirname(path), exist_ok=True)
base, rest = frames[-1], frames[:-1]
try:
base.save(path, format='ICO', sizes=[(s, s) for s in sizes], append_images=rest, bitmap_format='bmp')
except TypeError:
base.save(path, format='ICO', sizes=[(s, s) for s in sizes], append_images=rest)
return path
def font(size, weight='SemiBold'):
f = ImageFont.truetype(FONT, size)
try:
f.set_variation_by_name(weight)
except Exception:
pass
return f
def wordmark(text, size, colour=BONE, weight='SemiBold'):
f = font(size, weight)
l, t, r, b = f.getbbox(text)
im = Image.new('RGBA', (r - l + 4, b - t + 4), (0, 0, 0, 0))
ImageDraw.Draw(im).text((2 - l, 2 - t), text, font=f, fill=colour)
return im
def paste_centre(dst, src, cx, cy):
dst.alpha_composite(src, (int(cx - src.width / 2), int(cy - src.height / 2)))
def bmp(im, path):
im.convert('RGB').save(path, format='BMP')
def make_inno():
# The installer pages are obsidian already, so the square tile merges with the page: the mark reads centred, no edge.
big = Image.new('RGBA', (164, 314), OBSIDIAN + (255,))
paste_centre(big, render(124), 82, 110)
paste_centre(big, wordmark('IGNEUM', 19, BONE, 'Bold'), 82, 205)
paste_centre(big, wordmark('MINER', 13, MOLTEN, 'Medium'), 82, 232)
bmp(big, os.path.join(HERE, 'inno-wizard-164x314.bmp'))
small = Image.new('RGBA', (55, 58), OBSIDIAN + (255,))
paste_centre(small, render(55), 27.5, 29)
bmp(small, os.path.join(HERE, 'inno-wizard-small-55x58.bmp'))
banner = Image.new('RGBA', (150, 57), OBSIDIAN + (255,))
paste_centre(banner, render(50), 28, 28.5)
w1 = wordmark('IGNEUM', 15, BONE, 'Bold')
w2 = wordmark('MINER', 10, MOLTEN, 'Medium')
banner.alpha_composite(w1, (56, 14))
banner.alpha_composite(w2, (57, 34))
bmp(banner, os.path.join(HERE, 'inno-banner-150x57.bmp'))
def arrow(draw, x0, x1, y, colour, width):
draw.line((x0, y, x1 - width * 2, y), fill=colour, width=width)
h = width * 3
draw.polygon([(x1, y), (x1 - h * 1.6, y - h), (x1 - h * 1.6, y + h)], fill=colour)
# Finder icon slots on the DMG (1x pixels, icon size 96): the app at APP_SLOT, the Applications link at APPS_SLOT,
# README.txt and the Stop command in the row below. packaging/mac/dmg/settings.py uses the same numbers.
ICON = 96
APP_SLOT = (165, 130)
APPS_SLOT = (495, 130)
ROW2_Y = 330
def dashed_box(im, cx, cy, r, colour, width=3, dash=16, gap=10, radius=18):
"""A dashed rounded square: the outline drawn once, then masked by diagonal bars."""
W, H = im.size
layer = Image.new('RGBA', (W, H), (0, 0, 0, 0))
ImageDraw.Draw(layer).rounded_rectangle((cx - r, cy - r, cx + r, cy + r), radius=radius, outline=colour, width=width)
bars = Image.new('L', (W, H), 0)
bd = ImageDraw.Draw(bars)
k = -H
while k < W + H:
bd.line((k, 0, k + H, H), fill=255, width=dash)
k += dash + gap
alpha = layer.split()[3]
alpha = Image.composite(alpha, Image.new('L', (W, H), 0), bars)
layer.putalpha(alpha)
im.alpha_composite(layer)
def make_dmg_background():
"""660x400 at 1x; drawn at 2x and downsampled. The rounded icon is painted under the app slot, so the slot reads as
the product even before Finder draws the app icon on top of it."""
S = 2
W, H = 660 * S, 400 * S
im = Image.new('RGBA', (W, H), OBSIDIAN + (255,))
d = ImageDraw.Draw(im)
ax, ay = APP_SLOT[0] * S, APP_SLOT[1] * S
bx, by = APPS_SLOT[0] * S, APPS_SLOT[1] * S
glow = Image.new('RGBA', (W, H), (0, 0, 0, 0))
gd = ImageDraw.Draw(glow)
for i, a in ((76 * S, 10), (66 * S, 12), (58 * S, 14)):
gd.ellipse((ax - i, ay - i, ax + i, ay + i), fill=EMBER + (a,))
im.alpha_composite(glow)
paste_centre(im, render(ICON * S, rounded=True), ax, ay)
dashed_box(im, bx, by, (ICON // 2 + 14) * S, MOLTEN + (190,), width=2 * S, dash=9 * S, gap=6 * S, radius=9 * S)
arrow(d, (APP_SLOT[0] + 78) * S, (APPS_SLOT[0] - 78) * S, ay, EMBER + (255,), 4 * S)
paste_centre(im, wordmark('Drag Igneum Miner to Applications', 16 * S, BONE, 'Medium'), 330 * S, 236 * S)
paste_centre(im, wordmark('Then open it from Applications. Nobody from Igneum will ever ask for your seed.', 8 * S, (150, 148, 145), 'Regular'), 330 * S, 262 * S)
im2x = im.convert('RGB')
im2x.save(os.path.join(HERE, 'dmg-background@2x.png'))
im1x = im2x.resize((660, 400), Image.LANCZOS)
im1x.save(os.path.join(HERE, 'dmg-background.png'))
tiff = os.path.join(HERE, 'dmg-background.tiff')
if shutil.which('tiffutil'):
subprocess.run(['tiffutil', '-cathidpicheck', os.path.join(HERE, 'dmg-background.png'),
os.path.join(HERE, 'dmg-background@2x.png'), '-out', tiff], check=True)
def make_site(folder, full=True):
out = []
out.append(make_ico(os.path.join(folder, 'favicon.ico'), (16, 32, 48)))
out.append(save_png(render(32).convert('RGB'), os.path.join(folder, 'favicon-32.png')))
out.append(save_png(render(180).convert('RGB'), os.path.join(folder, 'apple-touch-icon.png')))
if full:
out.append(save_png(render(192).convert('RGB'), os.path.join(folder, 'icon-192.png')))
out.append(save_png(render(512).convert('RGB'), os.path.join(folder, 'icon-512.png')))
out.append(save_png(render(512, mark_scale=50 / 62).convert('RGB'), os.path.join(folder, 'maskable-512.png')))
return out
def make_profile():
out = []
for s in (400, 512, 1024):
out.append(save_png(render(s).convert('RGB'), os.path.join(PROFILE, f'profile-{s}.png')))
out.append(save_png(render(512).convert('RGB'), os.path.join(PROFILE, 'github-org-512.png')))
banner = Image.new('RGB', (1500, 500), OBSIDIAN)
tile = render(500).convert('RGB')
banner.paste(tile, (500, 0))
out.append(save_png(banner, os.path.join(PROFILE, 'x-banner-1500x500.png')))
out.append(save_png(render(512).convert('RGB'), os.path.join(BRAND, 'igneum-icon-512.png')))
out.append(save_png(render(1024).convert('RGB'), os.path.join(BRAND, 'igneum-icon-1024.png')))
return out
def card(w, h, tile, cx, cy, title_x):
"""The share-card layout: the mark on obsidian at (cx, cy), the IGNEUM wordmark, tagline and subline to the right.
Same layout as the old og-coin.png; the ringed coin and its glow are replaced by the master mark (the square merges
with the ground, so nothing but the mark shows: no ring, no edge)."""
im = Image.new('RGBA', (w, h), OBSIDIAN + (255,))
paste_centre(im, render(tile), cx, cy)
k = h / 630
im.alpha_composite(wordmark('IGNEUM', int(84 * k), BONE, 'Bold'), (title_x, int(cy - 92 * k)))
im.alpha_composite(wordmark('Mined by GPUs. Proven by fire.', int(31 * k), MOLTEN, 'SemiBold'), (title_x, int(cy + 20 * k)))
im.alpha_composite(wordmark('The GPU-mined layer 1 \u00b7 igneum.network', int(19 * k), (150, 148, 145), 'Regular'), (title_x, int(cy + 72 * k)))
return im.convert('RGB')
def make_social():
"""Share images. og-small.png stays a 256 square (the compact card chat apps show; its pages declare 256x256 and
twitter:card summary), og-square.png 1024 square, og-coin.png and og.png the 1200x630 card (og.png is what bench.html,
evidence.html and build.mjs reference), github-social-1280x640.png the repository social preview."""
out = []
out.append(save_png(render(256).convert('RGB'), os.path.join(SITE, 'og-small.png')))
out.append(save_png(render(1024).convert('RGB'), os.path.join(SITE, 'og-square.png')))
c = card(1200, 630, 300, 276, 315, 532)
out.append(save_png(c, os.path.join(SITE, 'og-coin.png')))
out.append(save_png(c, os.path.join(SITE, 'og.png')))
out.append(save_png(card(1280, 640, 306, 300, 320, 560), os.path.join(PROFILE, 'github-social-1280x640.png')))
out.append(save_png(render(512).convert('RGB'), os.path.join(PROFILE, 'vercel-avatar-512.png')))
return out
def main():
made = []
made.append(save_png(render(256), os.path.join(HERE, 'igneum-256.png')))
if sys.platform == 'darwin' and shutil.which('iconutil'):
made.append(make_icns('igneum.icns', rounded=False))
made.append(make_icns('igneum-volume.icns', rounded=True))
else:
print('icns skipped: iconutil is a macOS tool')
made.append(make_ico(os.path.join(HERE, 'igneum.ico'), (16, 24, 32, 48, 64, 128, 256)))
make_inno()
make_dmg_background()
made += make_site(SITE)
made += make_site(RELAY, full=False)
made += make_profile()
made += make_social()
for f in sorted(os.listdir(HERE)):
p = os.path.join(HERE, f)
if os.path.isfile(p) and not f.endswith('.py'):
made.append(p)
print('renderer:', 'rsvg-convert' if shutil.which('rsvg-convert') else 'Pillow (polygons from the master SVG)')
for p in sorted(set(made)):
print(f'{os.path.getsize(p):>8} {os.path.relpath(p, ROOT)}')
if __name__ == '__main__':
main()