d798fedf55
- Replace generic Spotify-green circle with a refined "Beacon" design: squircle + deep-teal diagonal gradient (#0B3D3B → #1A6B5E) + warm parchment play triangle (#F5F1E8) with drop shadow, top sheen, and ghosted echo-chevrons hinting at broadcast/stream - Grow icon.ico from a single 16×16 frame (208 B) to a 10-frame multi-resolution ICO (16/20/24/32/40/48/64/96/128/256, ~37 KB) so Windows no longer upscales 16×16 into mush for the installer chrome, Start Menu, desktop shortcuts, Alt+Tab, and File Explorer tiles - Add scripts/generate-icon.py: SVG is the source of truth; resvg-py rasterizes every ICO size; Pillow packs the multi-resolution ICO - Update tray.py to pick a 64×64 frame from the new ICO and update its procedural fallback to the same Beacon palette so a missing ICO no longer regresses the tray to the old Spotify-green circle - Add resvg-py to [dev] deps (build-time only) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
114 lines
4.2 KiB
Python
114 lines
4.2 KiB
Python
"""Generate the Media Server application icon.
|
|
|
|
The SVG in ``media_server/static/icons/icon.svg`` is the single source of
|
|
truth. This script rasterizes it at every Windows ICO size via ``resvg-py``
|
|
(Rust-backed, identical math to Firefox's SVG renderer) and packs them all
|
|
into a multi-resolution ``icon.ico``.
|
|
|
|
This replaces the original 16x16-only ICO that Windows was upscaling into
|
|
mush for the installer chrome, Start Menu, desktop shortcuts, and Alt+Tab.
|
|
|
|
Usage:
|
|
python scripts/generate-icon.py
|
|
|
|
Dependencies:
|
|
pip install resvg-py Pillow
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
from pathlib import Path
|
|
|
|
import resvg_py
|
|
from PIL import Image
|
|
|
|
# Sizes packed into the ICO. Windows picks the closest match per surface;
|
|
# more sizes = sharper rendering everywhere (taskbar, installer header,
|
|
# Alt+Tab, jump lists, Start tile, desktop, file explorer details/tiles).
|
|
ICO_SIZES: tuple[int, ...] = (16, 20, 24, 32, 40, 48, 64, 96, 128, 256)
|
|
|
|
# The SVG source. Squircle + diagonal teal gradient + warm parchment play
|
|
# triangle with a drop shadow + ghosted echo-chevrons hinting at broadcast.
|
|
SVG_SOURCE = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="Media Server">
|
|
<defs>
|
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
<stop offset="0%" stop-color="#0B3D3B"/>
|
|
<stop offset="100%" stop-color="#1A6B5E"/>
|
|
</linearGradient>
|
|
<linearGradient id="sheen" x1="50%" y1="0%" x2="50%" y2="100%">
|
|
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="0.18"/>
|
|
<stop offset="55%" stop-color="#FFFFFF" stop-opacity="0"/>
|
|
</linearGradient>
|
|
<filter id="triShadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
<feGaussianBlur stdDeviation="3"/>
|
|
<feOffset dx="3" dy="5"/>
|
|
<feComponentTransfer><feFuncA type="linear" slope="0.45"/></feComponentTransfer>
|
|
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
|
|
</filter>
|
|
<clipPath id="clip"><rect x="0" y="0" width="256" height="256" rx="58" ry="58"/></clipPath>
|
|
</defs>
|
|
<g clip-path="url(#clip)">
|
|
<rect width="256" height="256" fill="url(#bg)"/>
|
|
<rect width="256" height="256" fill="url(#sheen)"/>
|
|
<g stroke="#F5F1E8" stroke-width="4.6" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.18">
|
|
<polyline points="66,72 105,128 66,184"/>
|
|
<polyline points="85,82 124,128 85,174" opacity="0.6"/>
|
|
</g>
|
|
<path d="M88.3 55 L88.3 201 L193.3 128 Z"
|
|
fill="#F5F1E8"
|
|
stroke="#F5F1E8" stroke-width="9" stroke-linejoin="round"
|
|
filter="url(#triShadow)"/>
|
|
</g>
|
|
<rect x="0.5" y="0.5" width="255" height="255" rx="58" ry="58"
|
|
fill="none" stroke="#000000" stroke-opacity="0.18" stroke-width="1"/>
|
|
</svg>
|
|
"""
|
|
|
|
|
|
def _render_png(size: int) -> Image.Image:
|
|
"""Rasterize the SVG to a PNG at ``size x size`` via resvg."""
|
|
data = resvg_py.svg_to_bytes(
|
|
svg_string=SVG_SOURCE,
|
|
width=size,
|
|
height=size,
|
|
shape_rendering="geometric_precision",
|
|
)
|
|
return Image.open(io.BytesIO(bytes(data))).convert("RGBA")
|
|
|
|
|
|
def main() -> None:
|
|
root = Path(__file__).resolve().parent.parent
|
|
out_dir = root / "media_server" / "static" / "icons"
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# SVG — canonical source, also used as the Web UI favicon.
|
|
svg_path = out_dir / "icon.svg"
|
|
svg_path.write_text(SVG_SOURCE, encoding="utf-8")
|
|
print(f"wrote {svg_path}")
|
|
|
|
# Rasterize every ICO size via resvg.
|
|
frames = [_render_png(size) for size in ICO_SIZES]
|
|
|
|
# Pack into a multi-resolution ICO. The "primary" image must be the
|
|
# largest; the rest go via append_images. Pillow's ICO writer then
|
|
# serializes one frame per size.
|
|
primary = frames[-1]
|
|
ico_path = out_dir / "icon.ico"
|
|
primary.save(
|
|
ico_path,
|
|
format="ICO",
|
|
sizes=[(s, s) for s in ICO_SIZES],
|
|
append_images=frames[:-1],
|
|
)
|
|
print(f"wrote {ico_path} ({ico_path.stat().st_size:,} bytes, sizes={list(ICO_SIZES)})")
|
|
|
|
# Largest PNG for documentation / non-Windows surfaces.
|
|
png_path = out_dir / "icon-256.png"
|
|
primary.save(png_path, format="PNG", optimize=True)
|
|
print(f"wrote {png_path}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|