#!/usr/bin/env python3 """Pre-render splash art as ANSI half-block text for instant display. Converts source images (PNG/JPG/GIF) into .ans files using Unicode half-block characters with 24-bit ANSI color. This eliminates the runtime Pillow decode and textual-image protocol detection, making splash display instant. Each terminal cell renders two vertical pixels using the upper-half-block character (U+2580 '▀') with fg=top_pixel, bg=bottom_pixel for 2x vertical resolution. Usage: python scripts/prebake_splash.py [--width 80] """ import argparse from pathlib import Path from PIL import Image ASSETS_DIR = Path(__file__).resolve().parent.parent / "src" / "skywalker_tui" / "assets" / "splash" # Source images to pre-bake SOURCE_FILES = [ "seti-satellite.png", "dialtone.jpg", "so-far-away.gif", "prodigy-out-of-space.jpg", "space-docker.gif", ] def image_to_ansi(img_path: Path, width: int = 80) -> str: """Convert an image to ANSI half-block art. Uses ▀ (upper half block) with fg=top pixel, bg=bottom pixel to get 2x vertical resolution. Emits 24-bit ANSI color escape sequences, optimized to only change fg/bg when the color actually changes. """ img = Image.open(img_path) # Use first frame for animated GIFs if hasattr(img, "n_frames") and img.n_frames > 1: img.seek(0) img = img.convert("RGBA") # Resize maintaining aspect ratio, height rounded to even for half-blocks ratio = img.height / img.width pixel_height = int(width * ratio) pixel_height += pixel_height % 2 # ensure even img = img.resize((width, pixel_height), Image.LANCZOS) lines = [] for y in range(0, pixel_height, 2): chunks = [] prev_fg = None prev_bg = None for x in range(width): tr, tg, tb, ta = img.getpixel((x, y)) if y + 1 < pixel_height: br, bg, bb, ba = img.getpixel((x, y + 1)) else: br, bg, bb, ba = 0, 0, 0, 0 # Treat near-transparent pixels as black if ta < 128: tr, tg, tb = 0, 0, 0 if ba < 128: br, bg, bb = 0, 0, 0 fg = (tr, tg, tb) bk = (br, bg, bb) # Only emit escape codes when color changes codes = [] if fg != prev_fg: codes.append(f"\033[38;2;{fg[0]};{fg[1]};{fg[2]}m") prev_fg = fg if bk != prev_bg: codes.append(f"\033[48;2;{bk[0]};{bk[1]};{bk[2]}m") prev_bg = bk chunks.append("".join(codes) + "\u2580") lines.append("".join(chunks) + "\033[0m") return "\n".join(lines) def main(): parser = argparse.ArgumentParser(description="Pre-bake splash art as ANSI half-block text") parser.add_argument("--width", type=int, default=80, help="Output width in columns (default: 80)") args = parser.parse_args() if not ASSETS_DIR.exists(): print(f"Assets directory not found: {ASSETS_DIR}") return for filename in SOURCE_FILES: src = ASSETS_DIR / filename if not src.exists(): print(f" SKIP {filename} (not found)") continue stem = src.stem dst = ASSETS_DIR / f"{stem}.ans" print(f" BAKE {filename} -> {stem}.ans ({args.width} cols) ...", end=" ", flush=True) ansi_text = image_to_ansi(src, width=args.width) dst.write_text(ansi_text) # Stats src_kb = src.stat().st_size / 1024 dst_kb = dst.stat().st_size / 1024 line_count = ansi_text.count("\n") + 1 print(f"{src_kb:.1f}KB -> {dst_kb:.1f}KB ({line_count} lines)") print("\nDone. Pre-baked .ans files are ready for instant splash display.") if __name__ == "__main__": main()