#!/usr/bin/env python3 """Generates the app icons and the splash screen. Written against nothing but the standard library on purpose: the icons are part of the package, so regenerating them must not depend on Pillow being installed or on a checked-in binary nobody can edit. python3 tools/make-assets.py Everything is drawn supersampled and boxed down, which is what gives the rounded corners and bar tops their edges. """ import os import struct import sys import zlib HERE = os.path.dirname(os.path.abspath(__file__)) ASSETS = os.path.join(HERE, os.pardir, "frontend", "assets") # Same palette as the UI. BACKDROP = (11, 14, 19) TILE_TOP = (18, 32, 52) TILE_BOTTOM = (13, 17, 25) BAR_TOP = (74, 163, 255) BAR_BOTTOM = (31, 77, 118) ACCENT = (87, 217, 138) TEXT = (200, 214, 232) # Fraction of the drawing height each bar reaches. Reads as a level meter # caught mid-song rather than a generic equaliser. BARS = [0.34, 0.62, 0.95, 0.48, 0.78, 0.40] class Canvas: """RGBA pixel buffer with the handful of primitives this needs.""" def __init__(self, width, height, fill=(0, 0, 0, 0)): self.w = width self.h = height self.px = bytearray(fill * width * height) if len(fill) == 4 else None if self.px is None: self.px = bytearray((fill + (255,)) * width * height) def blend(self, x, y, colour, alpha=255): if x < 0 or y < 0 or x >= self.w or y >= self.h or alpha <= 0: return i = (y * self.w + x) * 4 if alpha >= 255: self.px[i:i + 4] = bytes(colour) + b"\xff" return a = alpha / 255.0 for c in range(3): self.px[i + c] = int(self.px[i + c] * (1 - a) + colour[c] * a) self.px[i + 3] = max(self.px[i + 3], alpha) def rect(self, x0, y0, x1, y1, colour): for y in range(max(0, int(y0)), min(self.h, int(y1))): for x in range(max(0, int(x0)), min(self.w, int(x1))): self.blend(x, y, colour) def rounded_rect(self, x0, y0, x1, y1, radius, top, bottom=None): """Filled rounded rectangle, optionally with a vertical gradient.""" bottom = bottom if bottom is not None else top height = max(1, y1 - y0 - 1) for y in range(max(0, int(y0)), min(self.h, int(y1))): # Fractional edges mean y can sit just outside the span; clamping # keeps the gradient from extrapolating past either colour. t = min(1.0, max(0.0, (y - y0) / height)) colour = tuple(int(top[c] + (bottom[c] - top[c]) * t) for c in range(3)) for x in range(max(0, int(x0)), min(self.w, int(x1))): # Only the corners need the distance test. cx = None if x < x0 + radius and y < y0 + radius: cx, cy = x0 + radius, y0 + radius elif x >= x1 - radius and y < y0 + radius: cx, cy = x1 - radius - 1, y0 + radius elif x < x0 + radius and y >= y1 - radius: cx, cy = x0 + radius, y1 - radius - 1 elif x >= x1 - radius and y >= y1 - radius: cx, cy = x1 - radius - 1, y1 - radius - 1 if cx is not None: dx, dy = x - cx, y - cy if dx * dx + dy * dy > radius * radius: continue self.blend(x, y, colour) def downsample(self, factor): """Box filter. This is the whole anti-aliasing strategy.""" w, h = self.w // factor, self.h // factor out = Canvas(w, h) area = factor * factor for y in range(h): for x in range(w): r = g = b = a = 0 for sy in range(factor): row = ((y * factor + sy) * self.w + x * factor) * 4 for sx in range(factor): i = row + sx * 4 r += self.px[i] g += self.px[i + 1] b += self.px[i + 2] a += self.px[i + 3] i = (y * w + x) * 4 out.px[i] = r // area out.px[i + 1] = g // area out.px[i + 2] = b // area out.px[i + 3] = a // area return out def paste(self, other, x0, y0): for y in range(other.h): for x in range(other.w): i = (y * other.w + x) * 4 self.blend(x0 + x, y0 + y, tuple(other.px[i:i + 3]), other.px[i + 3]) def write_png(self, path): raw = bytearray() stride = self.w * 4 for y in range(self.h): raw.append(0) # filter: none raw += self.px[y * stride:(y + 1) * stride] def chunk(kind, data): head = struct.pack(">I", len(data)) + kind + data return head + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF) png = b"\x89PNG\r\n\x1a\n" png += chunk(b"IHDR", struct.pack(">IIBBBBB", self.w, self.h, 8, 6, 0, 0, 0)) png += chunk(b"IDAT", zlib.compress(bytes(raw), 9)) png += chunk(b"IEND", b"") with open(path, "wb") as fh: fh.write(png) # 5x7, only the letters the splash needs. GLYPHS = { "A": ["01110", "10001", "10001", "11111", "10001", "10001", "10001"], "C": ["01110", "10001", "10000", "10000", "10000", "10001", "01110"], "D": ["11110", "10001", "10001", "10001", "10001", "10001", "11110"], "I": ["11111", "00100", "00100", "00100", "00100", "00100", "11111"], "O": ["01110", "10001", "10001", "10001", "10001", "10001", "01110"], "P": ["11110", "10001", "10001", "11110", "10000", "10000", "10000"], "U": ["10001", "10001", "10001", "10001", "10001", "10001", "01110"], " ": ["00000"] * 7, } def text_width(text, scale, spacing): return len(text) * (5 * scale + spacing) - spacing def draw_text(canvas, text, x0, y0, scale, spacing, colour): x = x0 for ch in text: rows = GLYPHS[ch] for ry, row in enumerate(rows): for rx, on in enumerate(row): if on == "1": canvas.rect(x + rx * scale, y0 + ry * scale, x + (rx + 1) * scale, y0 + (ry + 1) * scale, colour) x += 5 * scale + spacing def render_tile(size, supersample=4): """The logo: a rounded tile with a level meter on it.""" s = size * supersample c = Canvas(s, s) radius = int(s * 0.22) c.rounded_rect(0, 0, s, s, radius, TILE_TOP, TILE_BOTTOM) margin = s * 0.18 inner_w = s - margin * 2 inner_h = s - margin * 2 gap = inner_w / (len(BARS) * 4) bar_w = (inner_w - gap * (len(BARS) - 1)) / len(BARS) bar_radius = max(1, int(bar_w * 0.35)) base = s - margin for i, height in enumerate(BARS): x0 = margin + i * (bar_w + gap) top = base - inner_h * height colour_top = ACCENT if height > 0.9 else BAR_TOP c.rounded_rect(x0, top, x0 + bar_w, base, bar_radius, colour_top, BAR_BOTTOM) return c.downsample(supersample) def render_splash(width=1920, height=1080): c = Canvas(width, height, BACKDROP) tile = render_tile(300, supersample=2) c.paste(tile, (width - tile.w) // 2, height // 2 - 260) scale, spacing = 10, 10 label = "AUDIO CAP" draw_text(c, label, (width - text_width(label, scale, spacing)) // 2, height // 2 + 120, scale, spacing, TEXT) return c def main(): os.makedirs(ASSETS, exist_ok=True) targets = [ ("icon.png", lambda: render_tile(80)), ("largeIcon.png", lambda: render_tile(130)), ("splash.png", render_splash), ] for name, build in targets: path = os.path.join(ASSETS, name) build().write_png(path) print("wrote %s (%d bytes)" % (os.path.relpath(path), os.path.getsize(path))) return 0 if __name__ == "__main__": sys.exit(main())