diff --git a/build/generate_icon.py b/build/generate_icon.py new file mode 100644 index 0000000..d4a7701 --- /dev/null +++ b/build/generate_icon.py @@ -0,0 +1,327 @@ +"""Generate LedGrab app icon assets. + +Concept: "Spectrum Aperture" — a rounded-square frame (the screen/display) +traced by a continuous RGB color-wheel stroke (the bias-light LED strip), +on a near-black canvas with a soft chromatic bloom behind it. + +Outputs: + server/src/ledgrab/static/icons/icon-512.png (standard, opaque vignette bg) + server/src/ledgrab/static/icons/icon-192.png (downscale of 512) + server/src/ledgrab/static/icons/icon-512-maskable.png (safe-area padded, opaque) + server/src/ledgrab/static/icons/icon-tray.png (256, transparent bg, frame + glow) + server/src/ledgrab/static/icons/icon.ico (16/24/32/48/64/128/256) + +Run from repo root: + py -3.13 build/generate_icon.py +""" + +from __future__ import annotations + +import colorsys +import math +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFilter + +# ── Tunables ──────────────────────────────────────────────────────────── +SUPERSAMPLE = 4 # render at 4x and downsample for crispness +BASE = 1024 # logical canvas size +HQ = BASE * SUPERSAMPLE # render canvas + +BG_TOP = (12, 14, 22) # near-black, faint cool tint +BG_BOTTOM = (6, 7, 12) # darker at edges (vignette feel) + +FRAME_INSET = 0.18 # margin from canvas edge to frame (fraction) +FRAME_RADIUS = 0.22 # corner radius (fraction of frame side) +FRAME_STROKE = 0.085 # stroke width (fraction of canvas) +BLOOM_OPACITY = 0.62 # outer bloom strength (0–1) +INNER_GLOW_OPACITY = 0.38 # inner chromatic reflection strength + +# Hue rotation offset so red sits at the top +HUE_OFFSET = -90.0 # degrees (negative = counter-clockwise shift) + + +def lerp(a: float, b: float, t: float) -> float: + return a + (b - a) * t + + +def hue_to_rgb(hue_deg: float) -> tuple[int, int, int]: + """Bright, slightly desaturated spectral color (LED-like).""" + h = (hue_deg % 360) / 360.0 + r, g, b = colorsys.hls_to_rgb(h, 0.58, 0.92) + return int(r * 255), int(g * 255), int(b * 255) + + +def vignette_background(size: int) -> Image.Image: + """Dark canvas with a soft radial vignette + faint scanline noise.""" + img = Image.new("RGB", (size, size), BG_TOP) + px = img.load() + cx, cy = size / 2, size / 2 + max_r = math.hypot(cx, cy) + for y in range(size): + for x in range(size): + d = math.hypot(x - cx, y - cy) / max_r + t = min(1.0, d**1.6) + px[x, y] = ( + int(lerp(BG_TOP[0], BG_BOTTOM[0], t)), + int(lerp(BG_TOP[1], BG_BOTTOM[1], t)), + int(lerp(BG_TOP[2], BG_BOTTOM[2], t)), + ) + return img + + +def draw_chromatic_bloom(size: int) -> Image.Image: + """Soft, large chromatic glow behind the frame — the bias-light effect.""" + layer = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(layer) + + cx, cy = size / 2, size / 2 + radius = size * 0.36 + blob_r = int(size * 0.30) + n_blobs = 24 + + for i in range(n_blobs): + a = i / n_blobs * 360.0 + bx = cx + math.cos(math.radians(a - 90)) * radius + by = cy + math.sin(math.radians(a - 90)) * radius + r, g, b = hue_to_rgb(a + HUE_OFFSET) + alpha = int(255 * BLOOM_OPACITY * 0.55) + draw.ellipse( + (bx - blob_r, by - blob_r, bx + blob_r, by + blob_r), + fill=(r, g, b, alpha), + ) + + # Heavy blur → continuous, dreamy halo + layer = layer.filter(ImageFilter.GaussianBlur(radius=size * 0.10)) + return layer + + +def rounded_rect_mask(size: int, inset: int, radius: int, stroke: int) -> Image.Image: + """L-mode mask of a rounded-rect ring (the frame stroke region).""" + mask = Image.new("L", (size, size), 0) + draw = ImageDraw.Draw(mask) + box_outer = (inset, inset, size - inset, size - inset) + box_inner = ( + inset + stroke, + inset + stroke, + size - inset - stroke, + size - inset - stroke, + ) + r_outer = radius + r_inner = max(0, radius - stroke) + draw.rounded_rectangle(box_outer, radius=r_outer, fill=255) + draw.rounded_rectangle(box_inner, radius=r_inner, fill=0) + return mask + + +def draw_spectrum_frame(size: int) -> Image.Image: + """Draw the rounded-square frame stroke filled with a hue-rotation gradient. + + Strategy: paint a full-canvas angular hue gradient (centered), then + clip it with the rounded-ring mask. This guarantees a continuous, + seam-free color flow around the entire frame. + """ + cx, cy = size / 2, size / 2 + + gradient = Image.new("RGB", (size, size), (0, 0, 0)) + gpx = gradient.load() + for y in range(size): + dy = y - cy + for x in range(size): + dx = x - cx + ang = math.degrees(math.atan2(dy, dx)) + 90.0 # 0° = top + r, g, b = hue_to_rgb(ang + HUE_OFFSET) + gpx[x, y] = (r, g, b) + + inset = int(size * FRAME_INSET) + frame_side = size - 2 * inset + stroke = int(size * FRAME_STROKE) + radius = int(frame_side * FRAME_RADIUS) + + mask = rounded_rect_mask(size, inset, radius, stroke) + + out = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + out.paste(gradient, (0, 0), mask) + return out + + +def draw_inner_screen(size: int) -> Image.Image: + """Subtle dark rounded square inside the frame, with faint chromatic + inner reflection along the edges — like a screen catching ambient light.""" + inset = int(size * FRAME_INSET) + stroke = int(size * FRAME_STROKE) + frame_side = size - 2 * inset + radius = int(frame_side * FRAME_RADIUS) + + pad = int(stroke * 0.35) + box = ( + inset + stroke + pad, + inset + stroke + pad, + size - inset - stroke - pad, + size - inset - stroke - pad, + ) + r_inner = max(0, radius - stroke - pad) + + layer = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(layer) + # Dark fill, very slight cool tint + draw.rounded_rectangle(box, radius=r_inner, fill=(10, 12, 18, 255)) + + # Inner chromatic glow: same spectrum, very soft, clipped to the screen + bloom = draw_chromatic_bloom(size) + screen_mask = Image.new("L", (size, size), 0) + ImageDraw.Draw(screen_mask).rounded_rectangle(box, radius=r_inner, fill=255) + + bloom_alpha = bloom.split()[-1].point(lambda v: int(v * INNER_GLOW_OPACITY)) + bloom.putalpha(bloom_alpha) + + masked_bloom = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + masked_bloom.paste(bloom, (0, 0), screen_mask) + layer.alpha_composite(masked_bloom) + + # Faint highlight glint top-left + glint = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + gdraw = ImageDraw.Draw(glint) + glint_box = ( + box[0] + int(frame_side * 0.04), + box[1] + int(frame_side * 0.04), + box[0] + int(frame_side * 0.42), + box[1] + int(frame_side * 0.18), + ) + gdraw.rounded_rectangle(glint_box, radius=int(frame_side * 0.05), fill=(255, 255, 255, 22)) + glint = glint.filter(ImageFilter.GaussianBlur(radius=size * 0.012)) + masked_glint = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + masked_glint.paste(glint, (0, 0), screen_mask) + layer.alpha_composite(masked_glint) + + return layer + + +def add_outer_frame_glow(frame_rgba: Image.Image) -> Image.Image: + """Take the spectrum frame and produce a blurred, brightened copy for glow.""" + glow = frame_rgba.copy() + # Slightly inflate brightness for glow + r, g, b, a = glow.split() + glow = Image.merge("RGBA", (r, g, b, a.point(lambda v: min(255, int(v * 0.85))))) + glow = glow.filter(ImageFilter.GaussianBlur(radius=glow.width * 0.025)) + return glow + + +def render_tray(size: int) -> Image.Image: + """Render a tray-optimised icon: transparent background, bolder frame, + tight outer glow. Designed to read clearly at 16–32 px on top of any + taskbar color.""" + hq = size * SUPERSAMPLE + + # Pull the frame inward a touch and beef up the stroke so it reads at 16 px. + global FRAME_INSET, FRAME_STROKE + saved_inset, saved_stroke = FRAME_INSET, FRAME_STROKE + FRAME_INSET = 0.13 + FRAME_STROKE = 0.115 + try: + frame = draw_spectrum_frame(hq) + finally: + FRAME_INSET, FRAME_STROKE = saved_inset, saved_stroke + + # Tight, bright glow that doesn't bleed past the tray cell. + glow = frame.copy() + r, g, b, a = glow.split() + glow = Image.merge("RGBA", (r, g, b, a.point(lambda v: min(255, int(v * 0.95))))) + glow = glow.filter(ImageFilter.GaussianBlur(radius=hq * 0.012)) + + canvas = Image.new("RGBA", (hq, hq), (0, 0, 0, 0)) + canvas.alpha_composite(glow) + canvas.alpha_composite(frame) + + return canvas.resize((size, size), Image.LANCZOS) + + +def render(size: int, *, maskable: bool = False) -> Image.Image: + """Render the full icon at the given size.""" + hq = size * SUPERSAMPLE + + if maskable: + # Maskable: pad inward so the entire icon survives a circular crop. + # We render the standard composition at 80% of canvas size, centered. + bg = Image.new("RGB", (hq, hq), BG_BOTTOM).convert("RGBA") + bg.paste(vignette_background(hq), (0, 0)) + + inner = render(size, maskable=False).resize((int(hq * 0.78), int(hq * 0.78)), Image.LANCZOS) + # Strip the bg from the inner render: composite the spectrum + # parts on top of our maskable background. + ox = (hq - inner.width) // 2 + oy = (hq - inner.height) // 2 + bg.alpha_composite(inner, (ox, oy)) + return bg.resize((size, size), Image.LANCZOS) + + bg = vignette_background(hq).convert("RGBA") + bloom = draw_chromatic_bloom(hq) + frame = draw_spectrum_frame(hq) + frame_glow = add_outer_frame_glow(frame) + inner_screen = draw_inner_screen(hq) + + # Composite order: bg → bloom → frame_glow → inner_screen → frame + canvas = Image.new("RGBA", (hq, hq), (0, 0, 0, 0)) + canvas.alpha_composite(bg) + canvas.alpha_composite(bloom) + canvas.alpha_composite(frame_glow) + canvas.alpha_composite(inner_screen) + canvas.alpha_composite(frame) + + return canvas.resize((size, size), Image.LANCZOS) + + +def main() -> None: + repo_root = Path(__file__).resolve().parent.parent + targets = [ + repo_root / "server" / "src" / "ledgrab" / "static" / "icons", + repo_root + / "android" + / "app" + / "build" + / "python" + / "sources" + / "debug" + / "ledgrab" + / "static" + / "icons", + ] + + print("Rendering 1024 master...") + master = render(1024, maskable=False) + + print("Rendering maskable 1024 master...") + maskable_master = render(1024, maskable=True) + + print("Rendering tray 512 master (transparent bg)...") + tray_master = render_tray(512) + + for icons_dir in targets: + if not icons_dir.exists(): + print(f" skip (missing): {icons_dir}") + continue + + out_512 = icons_dir / "icon-512.png" + out_192 = icons_dir / "icon-192.png" + out_mask = icons_dir / "icon-512-maskable.png" + out_tray = icons_dir / "icon-tray.png" + out_ico = icons_dir / "icon.ico" + + master.resize((512, 512), Image.LANCZOS).save(out_512, "PNG", optimize=True) + master.resize((192, 192), Image.LANCZOS).save(out_192, "PNG", optimize=True) + maskable_master.resize((512, 512), Image.LANCZOS).save(out_mask, "PNG", optimize=True) + tray_master.save(out_tray, "PNG", optimize=True) + + # Pre-resize each frame from the 1024 master for maximum crispness. + # Pass them via the `sizes` arg so Pillow embeds every variant. + ico_sizes = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)] + # Use the tray (transparent-bg) variant for ICO frames so the file/ + # taskbar icon doesn't show a dark tile against light backgrounds. + ico_source = tray_master.resize((256, 256), Image.LANCZOS) + ico_source.save(out_ico, format="ICO", sizes=ico_sizes) + + print(f" wrote: {icons_dir}") + + +if __name__ == "__main__": + main() diff --git a/server/src/ledgrab/__main__.py b/server/src/ledgrab/__main__.py index a48a4b0..4ca8dcd 100644 --- a/server/src/ledgrab/__main__.py +++ b/server/src/ledgrab/__main__.py @@ -49,7 +49,8 @@ from ledgrab.utils.win_shutdown import WindowsShutdownGuard # noqa: E402 setup_logging() logger = get_logger(__name__) -_ICON_PATH = Path(__file__).parent / "static" / "icons" / "icon-192.png" +_ICON_PATH = Path(__file__).parent / "static" / "icons" / "icon-tray.png" +_ICON_FALLBACK_PATH = Path(__file__).parent / "static" / "icons" / "icon-192.png" def _run_server(server: uvicorn.Server) -> None: @@ -154,8 +155,9 @@ def main() -> None: ).start() # Tray on main thread (blocking) + tray_icon = _ICON_PATH if _ICON_PATH.exists() else _ICON_FALLBACK_PATH tray = TrayManager( - icon_path=_ICON_PATH, + icon_path=tray_icon, port=config.server.port, on_exit=lambda: _request_shutdown(server), ) diff --git a/server/src/ledgrab/static/icons/icon-192.png b/server/src/ledgrab/static/icons/icon-192.png index 1a3eb3a..3601e5b 100644 Binary files a/server/src/ledgrab/static/icons/icon-192.png and b/server/src/ledgrab/static/icons/icon-192.png differ diff --git a/server/src/ledgrab/static/icons/icon-512-maskable.png b/server/src/ledgrab/static/icons/icon-512-maskable.png index 433d3da..3b6cf88 100644 Binary files a/server/src/ledgrab/static/icons/icon-512-maskable.png and b/server/src/ledgrab/static/icons/icon-512-maskable.png differ diff --git a/server/src/ledgrab/static/icons/icon-512.png b/server/src/ledgrab/static/icons/icon-512.png index af5b68e..3e11a71 100644 Binary files a/server/src/ledgrab/static/icons/icon-512.png and b/server/src/ledgrab/static/icons/icon-512.png differ diff --git a/server/src/ledgrab/static/icons/icon-tray.png b/server/src/ledgrab/static/icons/icon-tray.png new file mode 100644 index 0000000..2f84116 Binary files /dev/null and b/server/src/ledgrab/static/icons/icon-tray.png differ diff --git a/server/src/ledgrab/static/icons/icon.ico b/server/src/ledgrab/static/icons/icon.ico index fd0e16b..d40469a 100644 Binary files a/server/src/ledgrab/static/icons/icon.ico and b/server/src/ledgrab/static/icons/icon.ico differ