|
| 1 | +"""Export the Welchost splash logo (pixel-art ghost + wordmark) to PNG / GIF. |
| 2 | +
|
| 3 | +The logo is generated at runtime by ``welchost.tui.logo`` as a Rich ``Text`` grid |
| 4 | +of block glyphs in the terracotta accent. Rather than rasterize those glyphs with |
| 5 | +a font (which never matches a terminal's tall 1:2 cells), this script decomposes |
| 6 | +each block character into the *pixels it represents* on a 1-wide x 2-tall sub-cell |
| 7 | +grid, then scales it up. That reproduces the terminal exactly and makes animation |
| 8 | +free: each animation frame is just another grid to decompose. |
| 9 | +
|
| 10 | +Glyph -> (top sub-pixel, bottom sub-pixel): |
| 11 | + ' ' -> background '█' -> accent, accent |
| 12 | + '▀' -> accent, background '▄' -> background, accent |
| 13 | + '░' '▒' -> shade, shade (the double_blocky two-tone seen in the TUI) |
| 14 | +
|
| 15 | +Requires Pillow (dev-only tool, not a project dependency): pip install Pillow |
| 16 | +
|
| 17 | +Usage: |
| 18 | + python scripts/export_logo.py [OUT.png] # static, frame 0 |
| 19 | + python scripts/export_logo.py OUT.gif --gif # animated splash |
| 20 | + python scripts/export_logo.py ... --scale 14 --bg "#1a1a1a" --frames-dir DIR |
| 21 | + python scripts/export_logo.py ... --bg transparent # PNG only (GIF needs solid) |
| 22 | +""" |
| 23 | + |
| 24 | +from __future__ import annotations |
| 25 | + |
| 26 | +import argparse |
| 27 | +import sys |
| 28 | +from pathlib import Path |
| 29 | + |
| 30 | +from PIL import Image |
| 31 | + |
| 32 | +# Make src/ importable when run from the repo root. |
| 33 | +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) |
| 34 | + |
| 35 | +from welchost.themes import ACCENT # noqa: E402 |
| 36 | +from welchost.tui.logo import _FRAMES, _ghost_frame, _wordmark # noqa: E402 |
| 37 | + |
| 38 | +DEFAULT_BG = "#1a1a1a" # Ghostty-ish dark, so the ░ shade reads with depth (Image #2) |
| 39 | + |
| 40 | + |
| 41 | +def hex_rgb(value: str) -> tuple[int, int, int]: |
| 42 | + v = value.lstrip("#") |
| 43 | + return int(v[0:2], 16), int(v[2:4], 16), int(v[4:6], 16) |
| 44 | + |
| 45 | + |
| 46 | +def blend(bg: tuple[int, int, int], fg: tuple[int, int, int], t: float) -> tuple[int, int, int]: |
| 47 | + return tuple(round(b + (f - b) * t) for b, f in zip(bg, fg, strict=True)) # type: ignore[return-value] |
| 48 | + |
| 49 | + |
| 50 | +def frame_lines(frame: int) -> list[str]: |
| 51 | + """Ghost beside the wordmark for one animation frame — the same composition as |
| 52 | + ``logo_text`` minus the shell-prompt/tagline chrome. The ghost floats inside a |
| 53 | + fixed-size lane, so every frame has identical dimensions (required for a GIF).""" |
| 54 | + word = _wordmark() |
| 55 | + ghost = _ghost_frame(frame) |
| 56 | + gw = max((len(g) for g in ghost), default=0) |
| 57 | + total = max(len(ghost), len(word)) |
| 58 | + g_off = (total - len(ghost)) // 2 |
| 59 | + w_off = (total - len(word)) // 2 |
| 60 | + |
| 61 | + rows: list[str] = [] |
| 62 | + for i in range(total): |
| 63 | + gi, wi = i - g_off, i - w_off |
| 64 | + g = ghost[gi] if 0 <= gi < len(ghost) else "" |
| 65 | + w = word[wi] if 0 <= wi < len(word) else "" |
| 66 | + rows.append(g.ljust(gw) + " " + w) |
| 67 | + width = max((len(r) for r in rows), default=0) |
| 68 | + return [r.ljust(width) for r in rows] |
| 69 | + |
| 70 | + |
| 71 | +def render_frame( |
| 72 | + rows: list[str], |
| 73 | + scale: int, |
| 74 | + padding: int, |
| 75 | + bg: tuple[int, int, int] | None, |
| 76 | + accent: tuple[int, int, int], |
| 77 | + shade: tuple[int, int, int], |
| 78 | +) -> Image.Image: |
| 79 | + """Decompose block glyphs to a sub-pixel grid and scale up with nearest-neighbor |
| 80 | + so the pixels stay crisp (no anti-alias blur).""" |
| 81 | + transparent = bg is None |
| 82 | + fill_bg = (0, 0, 0, 0) if transparent else (*bg, 255) |
| 83 | + a, s = (*accent, 255), (*shade, 255) |
| 84 | + |
| 85 | + cols = max((len(r) for r in rows), default=0) |
| 86 | + grid_w, grid_h = cols, len(rows) * 2 # each char row = 2 stacked sub-pixels |
| 87 | + img = Image.new("RGBA", (grid_w, grid_h), fill_bg) |
| 88 | + px = img.load() |
| 89 | + |
| 90 | + for r, line in enumerate(rows): |
| 91 | + for c, ch in enumerate(line): |
| 92 | + if ch == "█": |
| 93 | + top = bot = a |
| 94 | + elif ch == "▀": |
| 95 | + top, bot = a, fill_bg |
| 96 | + elif ch == "▄": |
| 97 | + top, bot = fill_bg, a |
| 98 | + elif ch in "░▒": |
| 99 | + top = bot = s |
| 100 | + elif ch == " ": |
| 101 | + continue |
| 102 | + else: # ▓ or any unexpected dense glyph |
| 103 | + top = bot = a |
| 104 | + px[c, r * 2] = top |
| 105 | + px[c, r * 2 + 1] = bot |
| 106 | + |
| 107 | + img = img.resize((grid_w * scale, grid_h * scale), Image.NEAREST) |
| 108 | + if padding: |
| 109 | + canvas = Image.new("RGBA", (img.width + 2 * padding, img.height + 2 * padding), fill_bg) |
| 110 | + canvas.alpha_composite(img, (padding, padding)) |
| 111 | + img = canvas |
| 112 | + return img |
| 113 | + |
| 114 | + |
| 115 | +def _hex(rgb: tuple[int, int, int]) -> str: |
| 116 | + return "#{:02x}{:02x}{:02x}".format(*rgb) |
| 117 | + |
| 118 | + |
| 119 | +def render_svg( |
| 120 | + rows: list[str], |
| 121 | + scale: int, |
| 122 | + padding: int, |
| 123 | + bg: tuple[int, int, int] | None, |
| 124 | + accent: tuple[int, int, int], |
| 125 | + shade: tuple[int, int, int], |
| 126 | +) -> str: |
| 127 | + """Emit the (static) logo as SVG: each block glyph becomes solid <rect>s on a |
| 128 | + 1x2 sub-cell grid. Horizontally-adjacent same-color cells are merged into one |
| 129 | + rect (run-length) to keep the file small. Vector = crisp at any size.""" |
| 130 | + a, s = _hex(accent), _hex(shade) |
| 131 | + cols = max((len(r) for r in rows), default=0) |
| 132 | + |
| 133 | + # sub[y][x] = color hex or None, where each char row spans two sub-rows. |
| 134 | + sub: list[list[str | None]] = [] |
| 135 | + for line in rows: |
| 136 | + top: list[str | None] = [] |
| 137 | + bot: list[str | None] = [] |
| 138 | + for ch in line.ljust(cols): |
| 139 | + if ch == "█": |
| 140 | + t, b = a, a |
| 141 | + elif ch == "▀": |
| 142 | + t, b = a, None |
| 143 | + elif ch == "▄": |
| 144 | + t, b = None, a |
| 145 | + elif ch in "░▒": |
| 146 | + t, b = s, s |
| 147 | + elif ch == " ": |
| 148 | + t, b = None, None |
| 149 | + else: |
| 150 | + t, b = a, a |
| 151 | + top.append(t) |
| 152 | + bot.append(b) |
| 153 | + sub.append(top) |
| 154 | + sub.append(bot) |
| 155 | + |
| 156 | + rects: list[str] = [] |
| 157 | + for y, srow in enumerate(sub): |
| 158 | + x = 0 |
| 159 | + while x < cols: |
| 160 | + color = srow[x] |
| 161 | + if color is None: |
| 162 | + x += 1 |
| 163 | + continue |
| 164 | + run = 1 |
| 165 | + while x + run < cols and srow[x + run] == color: |
| 166 | + run += 1 |
| 167 | + rects.append( |
| 168 | + f'<rect x="{padding + x * scale}" y="{padding + y * scale}" ' |
| 169 | + f'width="{run * scale}" height="{scale}" fill="{color}"/>' |
| 170 | + ) |
| 171 | + x += run |
| 172 | + |
| 173 | + width = cols * scale + 2 * padding |
| 174 | + height = len(sub) * scale + 2 * padding |
| 175 | + head = ( |
| 176 | + f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" ' |
| 177 | + f'viewBox="0 0 {width} {height}" shape-rendering="crispEdges">' |
| 178 | + ) |
| 179 | + body = f'<rect width="{width}" height="{height}" fill="{_hex(bg)}"/>' if bg else "" |
| 180 | + return head + body + "".join(rects) + "</svg>\n" |
| 181 | + |
| 182 | + |
| 183 | +def _to_indexed( |
| 184 | + frame: Image.Image, accent: tuple[int, int, int], shade: tuple[int, int, int] |
| 185 | +) -> Image.Image: |
| 186 | + """Map a flat-colored RGBA frame to a P-mode image whose palette index 0 is the |
| 187 | + transparent background. Works only because every pixel is exactly transparent, |
| 188 | + accent, or shade — so a 3-entry palette is lossless (no quantization).""" |
| 189 | + w, h = frame.size |
| 190 | + src = frame.load() |
| 191 | + out = Image.new("P", (w, h), 0) |
| 192 | + dst = out.load() |
| 193 | + for y in range(h): |
| 194 | + for x in range(w): |
| 195 | + r, g, b, al = src[x, y] |
| 196 | + if al == 0: |
| 197 | + dst[x, y] = 0 |
| 198 | + elif (r, g, b) == accent: |
| 199 | + dst[x, y] = 1 |
| 200 | + else: |
| 201 | + dst[x, y] = 2 |
| 202 | + out.putpalette([255, 0, 255, *accent, *shade]) # index 0 is the transparent slot |
| 203 | + return out |
| 204 | + |
| 205 | + |
| 206 | +def main() -> None: |
| 207 | + ap = argparse.ArgumentParser(description=__doc__) |
| 208 | + ap.add_argument( |
| 209 | + "output", nargs="?", help="output path (default: ~/Downloads/welchost-logo.{png,gif})" |
| 210 | + ) |
| 211 | + ap.add_argument( |
| 212 | + "--gif", action="store_true", help="render the animated splash instead of a still" |
| 213 | + ) |
| 214 | + ap.add_argument("--scale", type=int, default=14, help="pixels per sub-cell (default: 14)") |
| 215 | + ap.add_argument("--padding", type=int, default=28, help="margin in px (default: 28)") |
| 216 | + ap.add_argument( |
| 217 | + "--bg", default=DEFAULT_BG, help='background hex or "transparent" (default: #1a1a1a)' |
| 218 | + ) |
| 219 | + ap.add_argument( |
| 220 | + "--shade", type=float, default=0.42, help="░ density toward accent, 0..1 (default: 0.42)" |
| 221 | + ) |
| 222 | + ap.add_argument( |
| 223 | + "--duration", type=int, default=500, help="ms per GIF frame (default: 500, matches the TUI)" |
| 224 | + ) |
| 225 | + ap.add_argument("--frames-dir", help="also write each GIF frame as a PNG into this dir") |
| 226 | + ap.add_argument("--svg", action="store_true", help="render a static vector SVG (frame 0)") |
| 227 | + args = ap.parse_args() |
| 228 | + |
| 229 | + transparent = args.bg.lower() == "transparent" |
| 230 | + bg = None if transparent else hex_rgb(args.bg) |
| 231 | + accent = hex_rgb(ACCENT) |
| 232 | + shade_bg = bg if bg is not None else (0, 0, 0) |
| 233 | + shade = blend(shade_bg, accent, args.shade) |
| 234 | + |
| 235 | + svg = args.svg or (args.output is not None and args.output.lower().endswith(".svg")) |
| 236 | + default_name = ( |
| 237 | + "welchost-logo.svg" if svg else "welchost-logo.gif" if args.gif else "welchost-logo.png" |
| 238 | + ) |
| 239 | + out = Path(args.output) if args.output else Path.home() / "Downloads" / default_name |
| 240 | + out.parent.mkdir(parents=True, exist_ok=True) |
| 241 | + |
| 242 | + if svg: |
| 243 | + markup = render_svg(frame_lines(0), args.scale, args.padding, bg, accent, shade) |
| 244 | + out.write_text(markup) |
| 245 | + print(f"Wrote {out} ({len(markup)} bytes)") |
| 246 | + return |
| 247 | + |
| 248 | + if not args.gif: |
| 249 | + img = render_frame(frame_lines(0), args.scale, args.padding, bg, accent, shade) |
| 250 | + img.save(out) |
| 251 | + print(f"Wrote {out} ({img.width}x{img.height})") |
| 252 | + return |
| 253 | + |
| 254 | + frames = [ |
| 255 | + render_frame(frame_lines(i), args.scale, args.padding, bg, accent, shade) |
| 256 | + for i in range(len(_FRAMES)) |
| 257 | + ] |
| 258 | + if args.frames_dir: |
| 259 | + fdir = Path(args.frames_dir) |
| 260 | + fdir.mkdir(parents=True, exist_ok=True) |
| 261 | + for i, fr in enumerate(frames): |
| 262 | + fr.save(fdir / f"frame-{i:02d}.png") |
| 263 | + print(f"Wrote {len(frames)} frames -> {fdir}") |
| 264 | + |
| 265 | + if transparent: |
| 266 | + # 1-bit transparent GIF: palette index 0 is the transparent slot; disposal=2 |
| 267 | + # clears each frame back to transparent so the floating ghost leaves no trail. |
| 268 | + idx = [_to_indexed(f, accent, shade) for f in frames] |
| 269 | + idx[0].save( |
| 270 | + out, |
| 271 | + save_all=True, |
| 272 | + append_images=idx[1:], |
| 273 | + duration=args.duration, |
| 274 | + loop=0, |
| 275 | + transparency=0, |
| 276 | + disposal=2, |
| 277 | + optimize=False, |
| 278 | + ) |
| 279 | + else: |
| 280 | + # Solid bg: flatten and quantize to a tight palette. |
| 281 | + flat = [f.convert("RGB").quantize(colors=8, dither=Image.NONE) for f in frames] |
| 282 | + flat[0].save( |
| 283 | + out, |
| 284 | + save_all=True, |
| 285 | + append_images=flat[1:], |
| 286 | + duration=args.duration, |
| 287 | + loop=0, |
| 288 | + optimize=True, |
| 289 | + disposal=2, |
| 290 | + ) |
| 291 | + w, h = frames[0].width, frames[0].height |
| 292 | + print(f"Wrote {out} ({w}x{h}, {len(frames)} frames @ {args.duration}ms)") |
| 293 | + |
| 294 | + |
| 295 | +if __name__ == "__main__": |
| 296 | + main() |
0 commit comments