Files
video_concat/tools/make_icons.py
T

249 lines
9.0 KiB
Python

#!/usr/bin/env python3
"""Regenerate every app icon from the vector source (src-tauri/icons/icon.svg).
The artwork is described once, in 512x512 units, and rasterised per target size
with 8x supersampling plus a little per-size hinting: the filmstrip sprocket
holes and the play-triangle knockout are dropped at the sizes where they
would be sub-pixel and only turn to mush.
Everything is produced with a real alpha channel - the rounded-square tile has
transparent corners, which is exactly what the old flattened .ico was missing.
python tools/make_icons.py
Requires: pillow, numpy.
"""
from __future__ import annotations
import math
import re
import struct
from io import BytesIO
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parent.parent
ICONS = ROOT / "src-tauri" / "icons"
SVG = ICONS / "icon.svg"
CANVAS = 512.0
SS = 8 # supersampling factor
# --- tile -------------------------------------------------------------------
# The original raster left a 22px margin for a drop shadow. The vector version
# has no shadow, so the tile reclaims that space and reads larger in the taskbar.
TILE = (14.0, 14.0, 484.0, 484.0) # x, y, w, h
TILE_R = 99.0
GRAD_SPAN = (22.0, 468.0) # gradient is still fitted against the original tile
# --- filmstrips -------------------------------------------------------------
STRIPS = [(113.0, 147.0), (96.0, 226.0), (113.0, 305.0)]
STRIP_W, STRIP_H, STRIP_R = 79.0, 60.0, 12.0
HOLE_DX, HOLE_DY, HOLE_S, HOLE_R = 68.0, 4.0, 7.5, 1.8
HOLE_PITCH, HOLE_N = 11.25, 5
def parse_svg(svg_text):
"""Pull the gradient stops and the glyph path back out of the vector source."""
lin = re.search(r'id="tile".*?</linearGradient>', svg_text, re.S).group(0)
lin_stops = [
(float(o), tuple(int(h[i:i + 2], 16) for i in (0, 2, 4)))
for o, h in re.findall(r'offset="([\d.]+)" stop-color="#([0-9A-Fa-f]{6})"', lin)
]
rad = re.search(r'id="glow".*?</radialGradient>', svg_text, re.S).group(0)
gc = re.search(r'cx="([-\d.]+)" cy="([-\d.]+)" r="([\d.]+)"', rad)
glow_xy = (float(gc.group(1)), float(gc.group(2)))
glow_r = float(gc.group(3))
ghex = re.search(r'stop-color="#([0-9A-Fa-f]{6})"', rad).group(1)
glow_rgb = tuple(int(ghex[i:i + 2], 16) for i in (0, 2, 4))
rad_stops = [
(float(o), float(a))
for o, a in re.findall(
r'offset="([\d.]+)" stop-color="#[0-9A-Fa-f]{6}" stop-opacity="([\d.]+)"', rad
)
]
path_d = re.search(r'<path d="([^"]+)"', svg_text).group(1)
return lin_stops, glow_xy, glow_r, glow_rgb, rad_stops, path_d
def ramp(stops, t):
"""Piecewise-linear colour ramp over an array of positions in [0, 1]."""
offs = np.array([s[0] for s in stops])
cols = np.array([s[1] for s in stops], dtype=np.float64)
idx = np.clip(np.searchsorted(offs, t) - 1, 0, len(offs) - 2)
span = np.maximum(offs[idx + 1] - offs[idx], 1e-9)
f = ((t - offs[idx]) / span)[..., None]
return cols[idx] * (1 - f) + cols[idx + 1] * f
def ramp1(stops, t):
offs = np.array([s[0] for s in stops])
vals = np.array([s[1] for s in stops], dtype=np.float64)
idx = np.clip(np.searchsorted(offs, t) - 1, 0, len(offs) - 2)
span = np.maximum(offs[idx + 1] - offs[idx], 1e-9)
f = (t - offs[idx]) / span
return vals[idx] * (1 - f) + vals[idx + 1] * f
def flatten_path(d, scale):
"""Flatten the glyph path data into polygons, subdividing beziers."""
toks = re.findall(r"([MLCZ])([-\d.,e ]*)", d)
polys, cur, pos, start = [], [], (0.0, 0.0), (0.0, 0.0)
def nums(s):
return [float(v) for v in re.split(r"[, ]+", s.strip()) if v]
for cmd, args in toks:
a = nums(args)
if cmd == "M":
if len(cur) > 2:
polys.append(cur)
pos = start = (a[0], a[1])
cur = [pos]
elif cmd == "L":
pos = (a[0], a[1])
cur.append(pos)
elif cmd == "C":
p0, p1, p2, p3 = pos, (a[0], a[1]), (a[2], a[3]), (a[4], a[5])
span = math.dist(p0, p1) + math.dist(p1, p2) + math.dist(p2, p3)
n = max(4, min(96, int(span * scale / 2.5)))
for i in range(1, n + 1):
t = i / n
mt = 1 - t
x = mt ** 3 * p0[0] + 3 * mt * mt * t * p1[0] + 3 * mt * t * t * p2[0] + t ** 3 * p3[0]
y = mt ** 3 * p0[1] + 3 * mt * mt * t * p1[1] + 3 * mt * t * t * p2[1] + t ** 3 * p3[1]
cur.append((x, y))
pos = p3
elif cmd == "Z":
if len(cur) > 2:
polys.append(cur)
cur, pos = [], start
if len(cur) > 2:
polys.append(cur)
return polys
def render(size, lin_stops, glow_xy, glow_r, glow_rgb, rad_stops, path_d):
n = size * SS
k = n / CANVAS # 512-space -> device pixels
# per-size hinting: sub-pixel detail is dropped rather than smeared, and the
# glyph is nudged up a little where it would otherwise get lost
keep_holes = size >= 48 # sprocket holes are < 0.7px below this
keep_triangle = size >= 64 # the play knockout reads as a speck below this
gscale = 1.09 if size <= 32 else 1.0
def S(v):
return v * k
def G(v): # glyph coordinate, scaled about the tile centre
return (CANVAS / 2 + (v - CANVAS / 2) * gscale) * k
# the tile shape is the icon's whole alpha channel - corners stay transparent
tile = Image.new("L", (n, n), 0)
ImageDraw.Draw(tile).rounded_rectangle(
[S(TILE[0]), S(TILE[1]), S(TILE[0] + TILE[2]), S(TILE[1] + TILE[3])],
radius=S(TILE_R),
fill=255,
)
# background: diagonal purple-to-blue ramp with a soft highlight near the top
yy, xx = np.mgrid[0:n, 0:n]
ux = (xx + 0.5) / k
uy = (yy + 0.5) / k
t = np.clip(((ux + uy) - 2 * GRAD_SPAN[0]) / (2 * GRAD_SPAN[1]), 0, 1)
base = ramp(lin_stops, t)
g = np.clip(np.hypot(ux - glow_xy[0], uy - glow_xy[1]) / glow_r, 0, 1)
a = ramp1(rad_stops, g)[..., None]
rgb = base * (1 - a) + np.array(glow_rgb, dtype=np.float64) * a
glyph = Image.new("L", (n, n), 0)
gd = ImageDraw.Draw(glyph)
polys = flatten_path(path_d, k)
outer, holes = polys[0], polys[1:] # outline first, play-triangle knockout after
gd.polygon([(G(x), G(y)) for x, y in outer], fill=255)
if keep_triangle:
for hole in holes:
gd.polygon([(G(x), G(y)) for x, y in hole], fill=0)
for sx, sy in STRIPS:
gd.rounded_rectangle(
[G(sx), G(sy), G(sx + STRIP_W), G(sy + STRIP_H)], radius=S(STRIP_R * gscale), fill=255
)
if keep_holes:
for i in range(HOLE_N):
hx, hy = sx + HOLE_DX, sy + HOLE_DY + i * HOLE_PITCH
gd.rounded_rectangle(
[G(hx), G(hy), G(hx + HOLE_S), G(hy + HOLE_S)],
radius=S(HOLE_R * gscale),
fill=0,
)
gm = (np.asarray(glyph, dtype=np.float64) / 255.0)[..., None]
rgb = rgb * (1 - gm) + 255.0 * gm
out = np.dstack([np.clip(rgb, 0, 255), np.asarray(tile, dtype=np.float64)]).astype(np.uint8)
return Image.fromarray(out, "RGBA").resize((size, size), Image.LANCZOS)
def write_ico(path, images):
"""Write a PNG-compressed .ico (Vista+ format), preserving full alpha."""
blobs = []
for im in images:
buf = BytesIO()
im.save(buf, "PNG", optimize=True)
blobs.append(buf.getvalue())
with open(path, "wb") as f:
f.write(struct.pack("<HHH", 0, 1, len(images)))
offset = 6 + 16 * len(images)
for im, blob in zip(images, blobs):
w = 0 if im.width >= 256 else im.width
h = 0 if im.height >= 256 else im.height
f.write(struct.pack("<BBBBHHII", w, h, 0, 0, 1, 32, len(blob), offset))
offset += len(blob)
for blob in blobs:
f.write(blob)
PNG_TARGETS = {
"32x32.png": 32,
"64x64.png": 64,
"128x128.png": 128,
"128x128@2x.png": 256,
"icon.png": 512,
"Square30x30Logo.png": 30,
"Square44x44Logo.png": 44,
"Square71x71Logo.png": 71,
"Square89x89Logo.png": 89,
"Square107x107Logo.png": 107,
"Square142x142Logo.png": 142,
"Square150x150Logo.png": 150,
"Square284x284Logo.png": 284,
"Square310x310Logo.png": 310,
"StoreLogo.png": 50,
}
ICO_SIZES = [16, 24, 32, 48, 64, 128, 256]
def main():
params = parse_svg(SVG.read_text(encoding="utf-8"))
cache = {}
def get(size):
if size not in cache:
cache[size] = render(size, *params)
return cache[size]
for name, size in PNG_TARGETS.items():
get(size).save(ICONS / name, "PNG", optimize=True)
print(" %-24s %dx%d" % (name, size, size))
write_ico(ICONS / "icon.ico", [get(s) for s in ICO_SIZES])
print(" %-24s %s" % ("icon.ico", ICO_SIZES))
if __name__ == "__main__":
main()